Skip to content

Commit

Permalink
add admin UI to archive/unarchive projects
Browse files Browse the repository at this point in the history
Signed-off-by: Facundo Tuesca <facundo.tuesca@trailofbits.com>
  • Loading branch information
facutuesca committed Nov 26, 2024
1 parent 2f3fa08 commit 5f07641
Show file tree
Hide file tree
Showing 6 changed files with 311 additions and 131 deletions.
14 changes: 14 additions & 0 deletions tests/unit/admin/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ def test_includeme():
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.archive",
"/admin/projects/{project_name}/archive/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.unarchive",
"/admin/projects/{project_name}/unarchive/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call("admin.journals.list", "/admin/journals/", domain=warehouse),
pretend.call(
"admin.prohibited_project_names.list",
Expand Down
95 changes: 94 additions & 1 deletion tests/unit/admin/views/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from tests.common.db.oidc import GitHubPublisherFactory
from warehouse.admin.views import projects as views
from warehouse.observations.models import ObservationKind
from warehouse.packaging.models import Project, Role
from warehouse.packaging.models import LifecycleStatus, Project, Role
from warehouse.packaging.tasks import update_release_description
from warehouse.search.tasks import reindex_project
from warehouse.utils.paginate import paginate_url_factory
Expand Down Expand Up @@ -952,3 +952,96 @@ def test_reindexes_project(self, db_request):
assert db_request.session.flash.calls == [
pretend.call("Task sent to reindex the project 'foo'", queue="success")
]


class TestProjectArchival:
def test_archive(self, db_request):
project = ProjectFactory.create(name="foo")
user = UserFactory.create(username="testuser")

db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
db_request.method = "POST"
db_request.user = user
db_request.session = pretend.stub(
flash=pretend.call_recorder(lambda *a, **kw: None)
)

result = views.archive_project_view(project, db_request)

assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
assert project.lifecycle_status == LifecycleStatus.Archived
assert db_request.route_path.calls == [
pretend.call("admin.project.detail", project_name=project.name)
]

def test_unarchive_project(self, db_request):
project = ProjectFactory.create(
name="foo", lifecycle_status=LifecycleStatus.Archived
)
user = UserFactory.create(username="testuser")

db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
db_request.method = "POST"
db_request.user = user
db_request.session = pretend.stub(
flash=pretend.call_recorder(lambda *a, **kw: None)
)

result = views.unarchive_project_view(project, db_request)

assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
assert db_request.route_path.calls == [
pretend.call("admin.project.detail", project_name=project.name)
]
assert project.lifecycle_status is None

def test_disallowed_archive(self, db_request):
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
user = UserFactory.create(username="testuser")

db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
db_request.method = "POST"
db_request.user = user
db_request.session = pretend.stub(
flash=pretend.call_recorder(lambda *a, **kw: None)
)

result = views.archive_project_view(project, db_request)

assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
assert db_request.session.flash.calls == [
pretend.call(
f"Cannot archive project with status {project.lifecycle_status}",
queue="error",
)
]
assert db_request.route_path.calls == [
pretend.call("admin.project.detail", project_name="foo")
]
assert project.lifecycle_status == "quarantine-enter"

def test_disallowed_unarchive(self, db_request):
project = ProjectFactory.create(name="foo", lifecycle_status="quarantine-enter")
user = UserFactory.create(username="testuser")

db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
db_request.method = "POST"
db_request.user = user
db_request.session = pretend.stub(
flash=pretend.call_recorder(lambda *a, **kw: None)
)

result = views.unarchive_project_view(project, db_request)

assert isinstance(result, HTTPSeeOther)
assert result.headers["Location"] == "/the-redirect"
assert db_request.session.flash.calls == [
pretend.call("Can only unarchive an archived project", queue="error")
]
assert db_request.route_path.calls == [
pretend.call("admin.project.detail", project_name="foo")
]
assert project.lifecycle_status == "quarantine-enter"
14 changes: 14 additions & 0 deletions warehouse/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,20 @@ def includeme(config):
traverse="/{project_name}",
domain=warehouse,
)
config.add_route(
"admin.project.archive",
"/admin/projects/{project_name}/archive/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
)
config.add_route(
"admin.project.unarchive",
"/admin/projects/{project_name}/unarchive/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
)

# Journal related Admin pages
config.add_route("admin.journals.list", "/admin/journals/", domain=warehouse)
Expand Down
27 changes: 27 additions & 0 deletions warehouse/admin/templates/admin/projects/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,33 @@ <h3 class="card-title">Prohibit Project Name</h3>
</div>
</form> <!-- .card #prohibitedProjectName -->

<div class="card card-warning card-outline collapsed-card" id="archivedProjectName">
<div class="card-header">
<h3 class="card-title">Archive project</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse"><i class="fas fa-plus"></i>
</button>
</div>
</div>

<div class="card-footer">
{% set can_be_archived = not project.lifecycle_status or project.lifecycle_status == "quarantine-exit" %}
{% set can_be_unarchived = project.lifecycle_status == "archived" %}
<form method="POST" action="{{ request.route_path('admin.project.unarchive', project_name=project.name) }}">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
<div class="float-right">
<button type="submit" class="btn btn-primary" title="{{ 'Unarchiving requires superuser privileges' if not request.has_permission(Permissions.AdminProjectsWrite) }}" {{ "disabled" if not request.has_permission(Permissions.AdminProjectsWrite) or not can_be_unarchived }}>Unarchive</button>
</div>
</form>
<form method="POST" action="{{ request.route_path('admin.project.archive', project_name=project.name) }}">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
<div class="float-right">
<button type="submit" class="btn btn-primary" title="{{ 'Archiving requires superuser privileges' if not request.has_permission(Permissions.AdminProjectsWrite) }}" {{ "disabled" if not request.has_permission(Permissions.AdminProjectsWrite) or not can_be_archived}}>Archive</button>
</div>
</form>
</div>
</div>

<!-- Deletion form -->
{% include 'delete.html' %}

Expand Down
36 changes: 36 additions & 0 deletions warehouse/admin/views/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
from warehouse.search.tasks import reindex_project as _reindex_project
from warehouse.utils.paginate import paginate_url_factory
from warehouse.utils.project import (
archive_project,
clear_project_quarantine,
confirm_project,
remove_project,
unarchive_project,
)

UPLOAD_LIMIT_CAP = ONE_GIB
Expand Down Expand Up @@ -737,3 +739,37 @@ def reindex_project(project, request):
return HTTPSeeOther(
request.route_path("admin.project.detail", project_name=project.normalized_name)
)


@view_config(
route_name="admin.project.archive",
permission=Permissions.AdminProjectsWrite,
context=Project,
uses_session=True,
require_methods=["POST"],
)
def archive_project_view(project, request) -> HTTPSeeOther:
"""
Archive a Project. Reversible action.
"""
archive_project(project, request)
return HTTPSeeOther(
request.route_path("admin.project.detail", project_name=project.name)
)


@view_config(
route_name="admin.project.unarchive",
permission=Permissions.AdminProjectsWrite,
context=Project,
uses_session=True,
require_methods=["POST"],
)
def unarchive_project_view(project, request) -> HTTPSeeOther:
"""
Unarchive a Project. Reversible action.
"""
unarchive_project(project, request)
return HTTPSeeOther(
request.route_path("admin.project.detail", project_name=project.name)
)
Loading

0 comments on commit 5f07641

Please sign in to comment.