Skip to content

Commit 6cd30d2

Browse files
Allow marking projects as "archived" (#17005)
Co-authored-by: Mike Fiedler <[email protected]>
1 parent 4116520 commit 6cd30d2

File tree

17 files changed

+754
-105
lines changed

17 files changed

+754
-105
lines changed

tests/unit/admin/test_routes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,20 @@ def test_includeme():
248248
traverse="/{project_name}",
249249
domain=warehouse,
250250
),
251+
pretend.call(
252+
"admin.project.archive",
253+
"/admin/projects/{project_name}/archive/",
254+
factory="warehouse.packaging.models:ProjectFactory",
255+
traverse="/{project_name}",
256+
domain=warehouse,
257+
),
258+
pretend.call(
259+
"admin.project.unarchive",
260+
"/admin/projects/{project_name}/unarchive/",
261+
factory="warehouse.packaging.models:ProjectFactory",
262+
traverse="/{project_name}",
263+
domain=warehouse,
264+
),
251265
pretend.call("admin.journals.list", "/admin/journals/", domain=warehouse),
252266
pretend.call(
253267
"admin.prohibited_project_names.list",

tests/unit/admin/views/test_projects.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from tests.common.db.oidc import GitHubPublisherFactory
2727
from warehouse.admin.views import projects as views
2828
from warehouse.observations.models import ObservationKind
29-
from warehouse.packaging.models import Project, Role
29+
from warehouse.packaging.models import LifecycleStatus, Project, Role
3030
from warehouse.packaging.tasks import update_release_description
3131
from warehouse.search.tasks import reindex_project
3232
from warehouse.utils.paginate import paginate_url_factory
@@ -952,3 +952,96 @@ def test_reindexes_project(self, db_request):
952952
assert db_request.session.flash.calls == [
953953
pretend.call("Task sent to reindex the project 'foo'", queue="success")
954954
]
955+
956+
957+
class TestProjectArchival:
958+
def test_archive(self, db_request):
959+
project = ProjectFactory.create(name="foo")
960+
user = UserFactory.create(username="testuser")
961+
962+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
963+
db_request.method = "POST"
964+
db_request.user = user
965+
db_request.session = pretend.stub(
966+
flash=pretend.call_recorder(lambda *a, **kw: None)
967+
)
968+
969+
result = views.archive_project_view(project, db_request)
970+
971+
assert isinstance(result, HTTPSeeOther)
972+
assert result.headers["Location"] == "/the-redirect"
973+
assert project.lifecycle_status == LifecycleStatus.Archived
974+
assert db_request.route_path.calls == [
975+
pretend.call("admin.project.detail", project_name=project.name)
976+
]
977+
978+
def test_unarchive_project(self, db_request):
979+
project = ProjectFactory.create(
980+
name="foo", lifecycle_status=LifecycleStatus.Archived
981+
)
982+
user = UserFactory.create(username="testuser")
983+
984+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
985+
db_request.method = "POST"
986+
db_request.user = user
987+
db_request.session = pretend.stub(
988+
flash=pretend.call_recorder(lambda *a, **kw: None)
989+
)
990+
991+
result = views.unarchive_project_view(project, db_request)
992+
993+
assert isinstance(result, HTTPSeeOther)
994+
assert result.headers["Location"] == "/the-redirect"
995+
assert db_request.route_path.calls == [
996+
pretend.call("admin.project.detail", project_name=project.name)
997+
]
998+
assert project.lifecycle_status is None
999+
1000+
def test_disallowed_archive(self, db_request):
1001+
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
1002+
user = UserFactory.create(username="testuser")
1003+
1004+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
1005+
db_request.method = "POST"
1006+
db_request.user = user
1007+
db_request.session = pretend.stub(
1008+
flash=pretend.call_recorder(lambda *a, **kw: None)
1009+
)
1010+
1011+
result = views.archive_project_view(project, db_request)
1012+
1013+
assert isinstance(result, HTTPSeeOther)
1014+
assert result.headers["Location"] == "/the-redirect"
1015+
assert db_request.session.flash.calls == [
1016+
pretend.call(
1017+
f"Cannot archive project with status {project.lifecycle_status}",
1018+
queue="error",
1019+
)
1020+
]
1021+
assert db_request.route_path.calls == [
1022+
pretend.call("admin.project.detail", project_name="foo")
1023+
]
1024+
assert project.lifecycle_status == "quarantine-enter"
1025+
1026+
def test_disallowed_unarchive(self, db_request):
1027+
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
1028+
user = UserFactory.create(username="testuser")
1029+
1030+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
1031+
db_request.method = "POST"
1032+
db_request.user = user
1033+
db_request.session = pretend.stub(
1034+
flash=pretend.call_recorder(lambda *a, **kw: None)
1035+
)
1036+
1037+
result = views.unarchive_project_view(project, db_request)
1038+
1039+
assert isinstance(result, HTTPSeeOther)
1040+
assert result.headers["Location"] == "/the-redirect"
1041+
assert db_request.session.flash.calls == [
1042+
pretend.call("Can only unarchive an archived project", queue="error")
1043+
]
1044+
assert db_request.route_path.calls == [
1045+
pretend.call("admin.project.detail", project_name="foo")
1046+
]
1047+
assert project.lifecycle_status == "quarantine-enter"

tests/unit/manage/test_views.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from warehouse.packaging.models import (
6464
File,
6565
JournalEntry,
66+
LifecycleStatus,
6667
Project,
6768
Release,
6869
Role,
@@ -2602,7 +2603,7 @@ class TestManageProjectSettings:
26022603
@pytest.mark.parametrize("enabled", [False, True])
26032604
def test_manage_project_settings(self, enabled, monkeypatch):
26042605
request = pretend.stub(organization_access=enabled)
2605-
project = pretend.stub(organization=None)
2606+
project = pretend.stub(organization=None, lifecycle_status=None)
26062607
view = views.ManageProjectSettingsViews(project, request)
26072608
form = pretend.stub()
26082609
view.transfer_organization_project_form_class = lambda *a, **kw: form
@@ -2629,7 +2630,7 @@ def test_manage_project_settings_in_organization_managed(self, monkeypatch):
26292630
request = pretend.stub(organization_access=True)
26302631
organization_managed = pretend.stub(name="managed-org", is_active=True)
26312632
organization_owned = pretend.stub(name="owned-org", is_active=True)
2632-
project = pretend.stub(organization=organization_managed)
2633+
project = pretend.stub(organization=organization_managed, lifecycle_status=None)
26332634
view = views.ManageProjectSettingsViews(project, request)
26342635
form = pretend.stub()
26352636
view.transfer_organization_project_form_class = pretend.call_recorder(
@@ -2661,7 +2662,7 @@ def test_manage_project_settings_in_organization_owned(self, monkeypatch):
26612662
request = pretend.stub(organization_access=True)
26622663
organization_managed = pretend.stub(name="managed-org", is_active=True)
26632664
organization_owned = pretend.stub(name="owned-org", is_active=True)
2664-
project = pretend.stub(organization=organization_owned)
2665+
project = pretend.stub(organization=organization_owned, lifecycle_status=None)
26652666
view = views.ManageProjectSettingsViews(project, request)
26662667
form = pretend.stub()
26672668
view.transfer_organization_project_form_class = pretend.call_recorder(
@@ -7955,3 +7956,96 @@ def test_delete_oidc_publisher_admin_disabled(self, monkeypatch):
79557956
queue="error",
79567957
)
79577958
]
7959+
7960+
7961+
class TestArchiveProject:
7962+
def test_archive(self, db_request):
7963+
project = ProjectFactory.create(name="foo")
7964+
user = UserFactory.create(username="testuser")
7965+
7966+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
7967+
db_request.method = "POST"
7968+
db_request.user = user
7969+
db_request.session = pretend.stub(
7970+
flash=pretend.call_recorder(lambda *a, **kw: None)
7971+
)
7972+
7973+
result = views.archive_project_view(project, db_request)
7974+
7975+
assert isinstance(result, HTTPSeeOther)
7976+
assert result.headers["Location"] == "/the-redirect"
7977+
assert project.lifecycle_status == LifecycleStatus.Archived
7978+
assert db_request.route_path.calls == [
7979+
pretend.call("manage.project.settings", project_name=project.name)
7980+
]
7981+
7982+
def test_unarchive_project(self, db_request):
7983+
project = ProjectFactory.create(
7984+
name="foo", lifecycle_status=LifecycleStatus.Archived
7985+
)
7986+
user = UserFactory.create(username="testuser")
7987+
7988+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
7989+
db_request.method = "POST"
7990+
db_request.user = user
7991+
db_request.session = pretend.stub(
7992+
flash=pretend.call_recorder(lambda *a, **kw: None)
7993+
)
7994+
7995+
result = views.unarchive_project_view(project, db_request)
7996+
7997+
assert isinstance(result, HTTPSeeOther)
7998+
assert result.headers["Location"] == "/the-redirect"
7999+
assert db_request.route_path.calls == [
8000+
pretend.call("manage.project.settings", project_name=project.name)
8001+
]
8002+
assert project.lifecycle_status is None
8003+
8004+
def test_disallowed_archive(self, db_request):
8005+
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
8006+
user = UserFactory.create(username="testuser")
8007+
8008+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
8009+
db_request.method = "POST"
8010+
db_request.user = user
8011+
db_request.session = pretend.stub(
8012+
flash=pretend.call_recorder(lambda *a, **kw: None)
8013+
)
8014+
8015+
result = views.archive_project_view(project, db_request)
8016+
8017+
assert isinstance(result, HTTPSeeOther)
8018+
assert result.headers["Location"] == "/the-redirect"
8019+
assert db_request.session.flash.calls == [
8020+
pretend.call(
8021+
f"Cannot archive project with status {project.lifecycle_status}",
8022+
queue="error",
8023+
)
8024+
]
8025+
assert db_request.route_path.calls == [
8026+
pretend.call("manage.project.settings", project_name="foo")
8027+
]
8028+
assert project.lifecycle_status == "quarantine-enter"
8029+
8030+
def test_disallowed_unarchive(self, db_request):
8031+
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
8032+
user = UserFactory.create(username="testuser")
8033+
8034+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
8035+
db_request.method = "POST"
8036+
db_request.user = user
8037+
db_request.session = pretend.stub(
8038+
flash=pretend.call_recorder(lambda *a, **kw: None)
8039+
)
8040+
8041+
result = views.unarchive_project_view(project, db_request)
8042+
8043+
assert isinstance(result, HTTPSeeOther)
8044+
assert result.headers["Location"] == "/the-redirect"
8045+
assert db_request.session.flash.calls == [
8046+
pretend.call("Can only unarchive an archived project", queue="error")
8047+
]
8048+
assert db_request.route_path.calls == [
8049+
pretend.call("manage.project.settings", project_name="foo")
8050+
]
8051+
assert project.lifecycle_status == "quarantine-enter"

tests/unit/packaging/test_models.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,99 @@ def test_acl_for_quarantined_project(self, db_session):
365365
key=lambda x: x[1],
366366
)
367367

368+
def test_acl_for_archived_project(self, db_session):
369+
"""
370+
If a Project is archived, the Project ACL should disallow uploads.
371+
"""
372+
project = DBProjectFactory.create(lifecycle_status="archived")
373+
owner1 = DBRoleFactory.create(project=project)
374+
owner2 = DBRoleFactory.create(project=project)
375+
376+
# Maintainers should not appear in the ACLs, since they only have
377+
# upload permissions, and archived projects don't allow upload
378+
DBRoleFactory.create_batch(2, project=project, role_name="Maintainer")
379+
380+
organization = DBOrganizationFactory.create()
381+
owner3 = DBOrganizationRoleFactory.create(organization=organization)
382+
DBOrganizationProjectFactory.create(organization=organization, project=project)
383+
384+
team = DBTeamFactory.create()
385+
owner4 = DBTeamRoleFactory.create(team=team)
386+
DBTeamProjectRoleFactory.create(
387+
team=team, project=project, role_name=TeamProjectRoleType.Owner
388+
)
389+
390+
# Publishers should not appear in the ACLs, since they only have upload
391+
# permissions, and archived projects don't allow upload
392+
GitHubPublisherFactory.create(projects=[project])
393+
394+
acls = []
395+
for location in lineage(project):
396+
try:
397+
acl = location.__acl__
398+
except AttributeError:
399+
continue
400+
401+
if acl and callable(acl):
402+
acl = acl()
403+
404+
acls.extend(acl)
405+
406+
_perms_read_and_write = [
407+
Permissions.ProjectsRead,
408+
Permissions.ProjectsWrite,
409+
]
410+
assert acls == [
411+
(
412+
Allow,
413+
"group:admins",
414+
(
415+
Permissions.AdminDashboardSidebarRead,
416+
Permissions.AdminObservationsRead,
417+
Permissions.AdminObservationsWrite,
418+
Permissions.AdminProhibitedProjectsWrite,
419+
Permissions.AdminProhibitedUsernameWrite,
420+
Permissions.AdminProjectsDelete,
421+
Permissions.AdminProjectsRead,
422+
Permissions.AdminProjectsSetLimit,
423+
Permissions.AdminProjectsWrite,
424+
Permissions.AdminRoleAdd,
425+
Permissions.AdminRoleDelete,
426+
),
427+
),
428+
(
429+
Allow,
430+
"group:moderators",
431+
(
432+
Permissions.AdminDashboardSidebarRead,
433+
Permissions.AdminObservationsRead,
434+
Permissions.AdminObservationsWrite,
435+
Permissions.AdminProjectsRead,
436+
Permissions.AdminProjectsSetLimit,
437+
Permissions.AdminRoleAdd,
438+
Permissions.AdminRoleDelete,
439+
),
440+
),
441+
(
442+
Allow,
443+
"group:observers",
444+
Permissions.APIObservationsAdd,
445+
),
446+
(
447+
Allow,
448+
Authenticated,
449+
Permissions.SubmitMalwareObservation,
450+
),
451+
] + sorted(
452+
[
453+
(Allow, f"user:{owner1.user.id}", _perms_read_and_write),
454+
(Allow, f"user:{owner2.user.id}", _perms_read_and_write),
455+
(Allow, f"user:{owner3.user.id}", _perms_read_and_write),
456+
(Allow, f"user:{owner4.user.id}", _perms_read_and_write),
457+
],
458+
key=lambda x: x[1],
459+
)
460+
368461
def test_repr(self, db_request):
369462
project = DBProjectFactory()
370463
assert isinstance(repr(project), str)

tests/unit/test_routes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,20 @@ def add_redirect_rule(*args, **kwargs):
493493
traverse="/{project_name}",
494494
domain=warehouse,
495495
),
496+
pretend.call(
497+
"manage.project.archive",
498+
"/manage/project/{project_name}/archive/",
499+
factory="warehouse.packaging.models:ProjectFactory",
500+
traverse="/{project_name}",
501+
domain=warehouse,
502+
),
503+
pretend.call(
504+
"manage.project.unarchive",
505+
"/manage/project/{project_name}/unarchive/",
506+
factory="warehouse.packaging.models:ProjectFactory",
507+
traverse="/{project_name}",
508+
domain=warehouse,
509+
),
496510
pretend.call(
497511
"manage.project.history",
498512
"/manage/project/{project_name}/history/",

0 commit comments

Comments
 (0)