From af0165e121e33904d8d4f858d162f25efa1da43c Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 30 Sep 2024 13:35:26 -0400 Subject: [PATCH] packaging: deletion window machinery This adds `in_deletion_window` to the File, Release, and Project models. The three models currently compose in deletion semantics as follows: 1. A file is deletable iff it was uploaded within the last 7 days 2. A release is deletable iff all of its files are deletable 3. A project is deletable iff all of its releases are deletable This results in no special casing: the deletability of releases and projects is entirely "driven" by the deletability of files, as files are the primary way in which users drive the creation of releases and projects. This PR does **not** connect the deletion semantics to actual UI or view changes; I'll do that in a follow-up, to keep the patches small. Signed-off-by: William Woodruff --- tests/unit/packaging/test_models.py | 108 ++++++++++++++++++++++++++++ warehouse/packaging/models.py | 32 +++++++++ 2 files changed, 140 insertions(+) diff --git a/tests/unit/packaging/test_models.py b/tests/unit/packaging/test_models.py index 3658d733b503..c75d39f3c7d7 100644 --- a/tests/unit/packaging/test_models.py +++ b/tests/unit/packaging/test_models.py @@ -11,7 +11,9 @@ # limitations under the License. from collections import OrderedDict +from datetime import datetime, timedelta +import freezegun import pretend import pytest @@ -479,6 +481,47 @@ def test_deletion_macaroon_with_macaroon_warning( == 0 ) + def test_in_deletion_window(self, db_session): + project = DBProjectFactory.create() + + # Empty project, trivially deletable. + assert project.in_deletion_window + + fake_now = datetime(year=2000, month=1, day=1) + + # Releases are all deletable, so the project is deletable. + release1 = DBReleaseFactory.create(project=project) + DBFileFactory.create( + release=release1, upload_time=fake_now, packagetype="bdist_wheel" + ) + DBFileFactory.create( + release=release1, + upload_time=fake_now - timedelta(hours=1), + packagetype="bdist_wheel", + ) + + release2 = DBReleaseFactory.create(project=project) + DBFileFactory.create( + release=release2, + upload_time=fake_now - timedelta(days=2), + packagetype="bdist_wheel", + ) + DBFileFactory.create( + release=release2, + upload_time=fake_now - timedelta(days=3), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + assert project.in_deletion_window + + # One release is not deletable, so the entire project is not deletable. + release3 = DBReleaseFactory.create(project=project) + DBFileFactory.create(release=release3, upload_time=fake_now - timedelta(days=8)) + with freezegun.freeze_time(fake_now): + assert not release3.in_deletion_window + assert not project.in_deletion_window + class TestDependency: def test_repr(self, db_session): @@ -1087,6 +1130,43 @@ def test_description_relationship(self, db_request): assert release in db_request.db.deleted assert description in db_request.db.deleted + def test_in_deletion_window(self, db_session): + project = DBProjectFactory.create() + release = DBReleaseFactory.create(project=project) + + # No files, trivially deletable. + assert release.in_deletion_window + + fake_now = datetime(year=2000, month=1, day=1) + + DBFileFactory.create( + release=release, upload_time=fake_now, packagetype="bdist_wheel" + ) + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(hours=1), + packagetype="bdist_wheel", + ) + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(days=1, hours=1), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + # All files are deletable, so release is deletable. + assert release.in_deletion_window + + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(days=7, hours=1), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + # One file is not deletable, so the entire release is not deletable. + assert not release.in_deletion_window + class TestFile: def test_requires_python(self, db_session): @@ -1215,3 +1295,31 @@ def test_pretty_wheel_tags(self, db_session): ) assert rfile.pretty_wheel_tags == ["Source"] + + @pytest.mark.parametrize( + ("upload_time", "deletable"), + [ + # Deletable at the instant of upload + (datetime(year=2000, month=1, day=1), True), + # Deletable within the normal period + (datetime(year=2000, month=1, day=1) - timedelta(hours=1), True), + (datetime(year=2000, month=1, day=1) - timedelta(days=1), True), + (datetime(year=2000, month=1, day=1) - timedelta(days=5), True), + # Deletable at the instant of the end of the deletion period + (datetime(year=2000, month=1, day=1) - timedelta(days=7), True), + # Deletable when inexplicably uploaded in the future + (datetime(year=2000, month=1, day=1) + timedelta(hours=1), True), + # Not deletable outside of the deletion period + (datetime(year=2000, month=1, day=1) - timedelta(days=7, seconds=1), False), + (datetime(year=2000, month=1, day=1) - timedelta(days=8), False), + ], + ) + def test_in_deletion_window(self, db_session, upload_time, deletable): + project = DBProjectFactory.create() + release = DBReleaseFactory.create(project=project) + + fake_now = datetime(year=2000, month=1, day=1) + + with freezegun.freeze_time(fake_now): + file = DBFileFactory.create(release=release, upload_time=upload_time) + assert file.in_deletion_window == deletable diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 949635f6e7c9..2806bd08bf14 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -15,6 +15,7 @@ import typing from collections import OrderedDict +from datetime import datetime, timedelta from uuid import UUID import packaging.utils @@ -459,6 +460,17 @@ def latest_version(self): .first() ) + @property + def in_deletion_window(self) -> bool: + """ + A project can be deleted by a non-admin owner if it's within its + "deletion window," i.e. each of its releases and constituent files + are within their respective deletion windows. + + See `Release.in_deletion_window`. + """ + return all(release.in_deletion_window for release in self.releases) + class DependencyKind(enum.IntEnum): requires = 1 @@ -812,6 +824,18 @@ def trusted_published(self) -> bool: return False return all(file.uploaded_via_trusted_publisher for file in files) + @property + def in_deletion_window(self) -> bool: + """ + A release can be deleted by a non-admin owner if its within its + "deletion window," i.e. each of its files is within its respective + deletion window. + + See `File.in_deletion_window`. + """ + files = self.files.all() # type: ignore[attr-defined] + return all(file.in_deletion_window for file in files) + class PackageType(str, enum.Enum): bdist_dmg = "bdist_dmg" @@ -924,6 +948,14 @@ def validates_requires_python(self, *args, **kwargs): def pretty_wheel_tags(self) -> list[str]: return wheel.filename_to_pretty_tags(self.filename) + @property + def in_deletion_window(self) -> bool: + """ + A file can be deleted by a non-admin owner if it's within its + "deletion window," i.e. was uploaded no more than 7 days ago. + """ + return self.upload_time >= datetime.now() - timedelta(days=7) + class Filename(db.ModelBase): __tablename__ = "file_registry"