Skip to content

Commit 0e57edc

Browse files
authored
feat(admin): surface projects in quarantine (#18495)
1 parent 4a52775 commit 0e57edc

File tree

7 files changed

+356
-10
lines changed

7 files changed

+356
-10
lines changed

tests/unit/admin/test_routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ def test_includeme():
376376
"/admin/observations/",
377377
domain=warehouse,
378378
),
379+
pretend.call("admin.quarantine.list", "/admin/quarantine/", domain=warehouse),
379380
pretend.call(
380381
"admin.malware_reports.list",
381382
"/admin/malware_reports/",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
from warehouse.admin.views import quarantine
4+
from warehouse.packaging.models import LifecycleStatus
5+
6+
from ....common.db.packaging import ProjectFactory
7+
8+
9+
class TestQuarantineList:
10+
def test_quarantine_list_no_projects(self, db_request):
11+
"""Test quarantine list view when no projects are quarantined"""
12+
result = quarantine.quarantine_list(db_request)
13+
assert result == {"quarantined_projects": []}
14+
15+
def test_quarantine_list_with_projects(self, db_request):
16+
"""Test quarantine list view with quarantined projects"""
17+
# Create some projects in different states
18+
normal_project = ProjectFactory.create()
19+
archived_project = ProjectFactory.create(
20+
lifecycle_status=LifecycleStatus.Archived
21+
)
22+
quarantined_project_1 = ProjectFactory.create(
23+
lifecycle_status=LifecycleStatus.QuarantineEnter,
24+
lifecycle_status_note="Test quarantine reason 1",
25+
)
26+
quarantined_project_2 = ProjectFactory.create(
27+
lifecycle_status=LifecycleStatus.QuarantineEnter,
28+
lifecycle_status_note="Test quarantine reason 2",
29+
)
30+
31+
result = quarantine.quarantine_list(db_request)
32+
33+
# Should only return quarantined projects
34+
assert len(result["quarantined_projects"]) == 2
35+
project_names = [p.name for p in result["quarantined_projects"]]
36+
assert quarantined_project_1.name in project_names
37+
assert quarantined_project_2.name in project_names
38+
assert normal_project.name not in project_names
39+
assert archived_project.name not in project_names
40+
41+
def test_quarantine_list_ordered_by_date(self, db_request):
42+
"""Quarantined projects are ordered by quarantine date (oldest first)"""
43+
from datetime import datetime, timedelta, timezone
44+
45+
# Create projects with different quarantine dates
46+
base_time = datetime.now(timezone.utc)
47+
48+
newer_project = ProjectFactory.create(
49+
lifecycle_status=LifecycleStatus.QuarantineEnter,
50+
lifecycle_status_changed=base_time,
51+
lifecycle_status_note="Newer quarantine",
52+
)
53+
older_project = ProjectFactory.create(
54+
lifecycle_status=LifecycleStatus.QuarantineEnter,
55+
lifecycle_status_changed=base_time - timedelta(days=5),
56+
lifecycle_status_note="Older quarantine",
57+
)
58+
59+
result = quarantine.quarantine_list(db_request)
60+
61+
# Should be ordered oldest first
62+
assert len(result["quarantined_projects"]) == 2
63+
assert result["quarantined_projects"][0].name == older_project.name
64+
assert result["quarantined_projects"][1].name == newer_project.name
65+
66+
def test_quarantine_list_handles_null_date(self, db_request):
67+
"""Projects with null `lifecycle_status_changed` are handled gracefully"""
68+
# Create a project with no lifecycle_status_changed
69+
quarantined_project = ProjectFactory.create(
70+
lifecycle_status=LifecycleStatus.QuarantineEnter,
71+
lifecycle_status_changed=None,
72+
lifecycle_status_note="No date quarantine",
73+
)
74+
75+
result = quarantine.quarantine_list(db_request)
76+
77+
# Should still return the project
78+
assert len(result["quarantined_projects"]) == 1
79+
assert result["quarantined_projects"][0].name == quarantined_project.name
80+
81+
def test_quarantine_list_with_exit_status(self, db_request):
82+
"""Test that projects with quarantine-exit status are not included"""
83+
# Create projects with different quarantine statuses
84+
entering_quarantine = ProjectFactory.create(
85+
lifecycle_status=LifecycleStatus.QuarantineEnter,
86+
lifecycle_status_note="Entering quarantine",
87+
)
88+
ProjectFactory.create(
89+
lifecycle_status=LifecycleStatus.QuarantineExit,
90+
lifecycle_status_note="Exiting quarantine",
91+
)
92+
93+
result = quarantine.quarantine_list(db_request)
94+
95+
# Should only return projects entering quarantine
96+
assert len(result["quarantined_projects"]) == 1
97+
assert result["quarantined_projects"][0].name == entering_quarantine.name

warehouse/admin/routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ def includeme(config):
386386
config.add_route(
387387
"admin.observations.list", "/admin/observations/", domain=warehouse
388388
)
389+
config.add_route("admin.quarantine.list", "/admin/quarantine/", domain=warehouse)
389390
config.add_route(
390391
"admin.malware_reports.list",
391392
"/admin/malware_reports/",

warehouse/admin/static/css/admin.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,24 @@
2828
.hidden {
2929
display: none;
3030
}
31+
32+
/* Responsive quarantine table */
33+
.quarantine-table {
34+
.project-info {
35+
min-width: 200px;
36+
}
37+
38+
.project-name {
39+
font-weight: 500;
40+
}
41+
42+
@media (width <= 768px) {
43+
td {
44+
padding: 0.75rem 0.5rem;
45+
}
46+
47+
.btn .btn-group-vertical {
48+
font-size: 0.875rem;
49+
}
50+
}
51+
}

warehouse/admin/templates/admin/base.html

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,6 @@
101101
<ul class="nav nav-pills nav-sidebar nav-child-indent flex-column" data-widget="treeview" role="menu" data-accordion="false">
102102
{# TODO: Is there a cleaner way to show/hide the allowed sections? #}
103103
{% if request.has_permission(Permissions.AdminDashboardSidebarRead) %}
104-
<li class="nav-item">
105-
<a href="{{ request.route_path('admin.malware_reports.list') }}" class="nav-link">
106-
<i class="fa-solid fa-dumpster-fire nav-icon"></i> <p>Malware Reports</p>
107-
</a>
108-
</li>
109104
<li class="nav-item">
110105
<a href="{{ request.route_path('admin.organization_application.list') }}?q=is%3Asubmitted" class="nav-link">
111106
<i class="fa fa-sitemap nav-icon"></i> <p>Organization Applications</p>
@@ -141,6 +136,34 @@
141136
<i class="fa fa-book nav-icon"></i> <p>Journals</p>
142137
</a>
143138
</li>
139+
<li class="nav-item menu-closed">
140+
<a href="#" class="nav-link">
141+
<i class="nav-icon fa-solid fa-eye"></i>
142+
<p>
143+
Observations
144+
<i class="right fas fa-angle-left"></i>
145+
</p>
146+
</a>
147+
<ul class="nav nav-treeview">
148+
<li class="nav-item">
149+
<a href="{{ request.route_path('admin.malware_reports.list') }}" class="nav-link">
150+
<i class="nav-icon fa-solid fa-dumpster-fire"></i>
151+
<p>Malware Reports</p>
152+
</a>
153+
</li>
154+
<li class="nav-item">
155+
<a href="{{ request.route_path('admin.quarantine.list') }}" class="nav-link">
156+
<i class="nav-icon fa-solid fa-file-shield"></i> <p>Quarantine</p>
157+
</a>
158+
</li>
159+
<li class="nav-item">
160+
<a href="{{ request.route_path('admin.observations.list') }}" class="nav-link">
161+
<i class="nav-icon fa-solid fa-eye"></i>
162+
<p>All Observations</p>
163+
</a>
164+
</li>
165+
</ul>
166+
</li>
144167
<li class="nav-item menu-closed">
145168
<a href="#" class="nav-link">
146169
<i class="nav-icon fa fa-ban"></i>
@@ -170,11 +193,6 @@
170193
</li>
171194
</ul>
172195
</li>
173-
<li class="nav-item">
174-
<a href="{{ request.route_path('admin.observations.list') }}" class="nav-link">
175-
<i class="fa-solid fa-eye nav-icon"></i> <p>Observations</p>
176-
</a>
177-
</li>
178196
<li class="nav-item">
179197
<a href="{{ request.route_path('admin.emails.list') }}" class="nav-link">
180198
<i class="fa fa-envelope nav-icon"></i> <p>Emails</p>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
{# SPDX-License-Identifier: Apache-2.0 -#}
2+
3+
{% extends "admin/base.html" %}
4+
5+
{% block title %}Quarantine{% endblock %}
6+
7+
{% block breadcrumb %}
8+
<li class="breadcrumb-item active">Quarantine</li>
9+
{% endblock %}
10+
11+
{% block content %}
12+
<div class="card">
13+
<div class="card-header">
14+
<h3 class="card-title">Projects in Quarantine</h3>
15+
</div>
16+
<div class="card-body">
17+
{% if quarantined_projects %}
18+
<div class="table-responsive">
19+
<table class="table table-striped quarantine-table">
20+
<thead>
21+
<tr>
22+
<th>Project</th>
23+
<th class="d-none d-md-table-cell">Days in Quarantine</th>
24+
<th class="d-none d-lg-table-cell">Quarantined Date</th>
25+
<th class="d-none d-xl-table-cell">Note</th>
26+
<th>Actions</th>
27+
</tr>
28+
</thead>
29+
<tbody>
30+
{% for project in quarantined_projects %}
31+
<tr>
32+
<td>
33+
<div class="project-info">
34+
<div class="project-name">
35+
<a href="{{ request.route_path('admin.project.detail', project_name=project.name) }}">
36+
<strong>{{ project.name }}</strong>
37+
</a>
38+
</div>
39+
<!-- Mobile-only information shown under project name -->
40+
<div class="d-md-none text-muted small mt-1">
41+
{% if project.lifecycle_status_changed %}
42+
{% set days_quarantined = (now() - project.lifecycle_status_changed).days %}
43+
<span class="badge badge-{{ 'warning' if days_quarantined > 30 else ('info' if days_quarantined > 7 else 'secondary') }} mr-2">
44+
{{ days_quarantined }} days
45+
</span>
46+
{{ project.lifecycle_status_changed.strftime('%Y-%m-%d') }}
47+
{% else %}
48+
<span class="badge badge-secondary">Unknown</span>
49+
{% endif %}
50+
</div>
51+
{% if project.lifecycle_status_note %}
52+
<div class="d-xl-none text-muted small mt-1">
53+
<strong>Note:</strong> {{ project.lifecycle_status_note }}
54+
</div>
55+
{% endif %}
56+
</div>
57+
</td>
58+
<td class="d-none d-md-table-cell">
59+
{% if project.lifecycle_status_changed %}
60+
{% set days_quarantined = (now() - project.lifecycle_status_changed).days %}
61+
<span class="badge badge-{{ 'warning' if days_quarantined > 30 else ('info' if days_quarantined > 7 else 'secondary') }}">
62+
{{ days_quarantined }} days
63+
</span>
64+
{% else %}
65+
<em>Unknown</em>
66+
{% endif %}
67+
</td>
68+
<td class="d-none d-lg-table-cell">
69+
{% if project.lifecycle_status_changed %}
70+
{{ project.lifecycle_status_changed.strftime('%Y-%m-%d %H:%M:%S UTC') }}
71+
{% else %}
72+
<em>Unknown</em>
73+
{% endif %}
74+
</td>
75+
<td class="d-none d-xl-table-cell">
76+
{% if project.lifecycle_status_note %}
77+
{{ project.lifecycle_status_note }}
78+
{% else %}
79+
<em>No note</em>
80+
{% endif %}
81+
</td>
82+
<td>
83+
<div class="btn-group-vertical d-md-none">
84+
<a href="{{ request.route_path('admin.project.detail', project_name=project.name) }}"
85+
class="btn btn-sm btn-info mb-1" title="View Project Details">
86+
<i class="fa fa-eye"></i> View
87+
</a>
88+
<button type="button"
89+
class="btn btn-sm btn-success"
90+
data-toggle="modal"
91+
data-target="#modal-exit-quarantine-{{ loop.index }}"
92+
title="Clear from Quarantine">
93+
<i class="fa fa-unlock"></i> Release
94+
</button>
95+
</div>
96+
<div class="btn-group d-none d-md-flex" role="group">
97+
<a href="{{ request.route_path('admin.project.detail', project_name=project.name) }}"
98+
class="btn btn-sm btn-info" title="View Project Details">
99+
<i class="fa fa-eye"></i> View
100+
</a>
101+
<button type="button"
102+
class="btn btn-sm btn-success"
103+
data-toggle="modal"
104+
data-target="#modal-exit-quarantine-{{ loop.index }}"
105+
title="Clear from Quarantine">
106+
<i class="fa fa-unlock"></i> Release
107+
</button>
108+
</div>
109+
</td>
110+
</tr>
111+
{% endfor %}
112+
</tbody>
113+
</table>
114+
</div>
115+
116+
<!-- Modals -->
117+
{% for project in quarantined_projects %}
118+
<div class="modal fade" id="modal-exit-quarantine-{{ loop.index }}">
119+
<div class="modal-dialog modal-exit-quarantine">
120+
<form id="exit-quarantine-{{ loop.index }}"
121+
action="{{ request.route_path('admin.project.remove_from_quarantine', project_name=project.name) }}"
122+
method="post">
123+
<input name="csrf_token"
124+
type="hidden"
125+
value="{{ request.session.get_csrf_token() }}">
126+
<div class="modal-content">
127+
<div class="modal-header bg-success">
128+
<h4 class="modal-title">Clear Project from Quarantine</h4>
129+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
130+
<span aria-hidden="true">×</span>
131+
</button>
132+
</div>
133+
<div class="modal-body">
134+
<p>
135+
Confirming that <code>{{ project.name }}</code> will no longer be quarantined.
136+
</p>
137+
<p>
138+
This will restore the project to the index for installation.
139+
It will not affect any frozen user accounts - those will need to be handled separately.
140+
</p>
141+
</div>
142+
<div class="modal-footer justify-content-between">
143+
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
144+
<button type="submit" class="btn btn-outline-success">Clear from Quarantine</button>
145+
</div>
146+
</div>
147+
</form>
148+
</div>
149+
</div>
150+
{% endfor %}
151+
152+
<div class="mt-3">
153+
<div class="alert alert-info">
154+
<i class="fa fa-info-circle"></i>
155+
<strong>{{ quarantined_projects|length }}</strong> project{{ 's' if quarantined_projects|length != 1 else '' }} currently in quarantine.
156+
Projects are sorted by quarantine date (oldest first).
157+
</div>
158+
</div>
159+
{% else %}
160+
<div class="alert alert-success">
161+
<i class="fa fa-check-circle"></i>
162+
No projects are currently in quarantine.
163+
</div>
164+
{% endif %}
165+
</div>
166+
</div>
167+
{% endblock %}

0 commit comments

Comments
 (0)