Skip to content

Commit d907160

Browse files
authored
Harden sponsors admin actions: require change permission and make lock POST-only (#3035)
1 parent 8507d95 commit d907160

3 files changed

Lines changed: 162 additions & 5 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends 'admin/base_site.html' %}
2+
{% load i18n static sponsors %}
3+
4+
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}">{% endblock %}
5+
6+
{% block title %}Lock Sponsorship {{ sponsorship }} | python.org{% endblock %}
7+
8+
{% block breadcrumbs %}
9+
<div class="breadcrumbs">
10+
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> &gt
11+
<a href="{% url 'admin:app_list' app_label='sponsors' %}">{% trans 'Sponsors' %}</a> &gt
12+
<a href="{% url 'admin:sponsors_sponsorship_changelist' %}">{% trans 'Sponsorship' %}</a> &gt
13+
<a href="{% url 'admin:sponsors_sponsorship_change' sponsorship.pk %}">{{ sponsorship }}</a> &gt
14+
{% trans 'Lock Sponsorship' %}
15+
</div>
16+
{% endblock %}
17+
18+
{% block content %}
19+
<h1>Lock Sponsorship</h1>
20+
<p>Please review the sponsorship application and click in the Lock button if you want to proceed.</p>
21+
<div id="content-main">
22+
<form action="" method="post">
23+
{% csrf_token %}
24+
25+
<pre>{% full_sponsorship sponsorship display_fee=True %}</pre>
26+
27+
<input name="confirm" value="yes" style="display:none">
28+
29+
<div class="submit-row">
30+
<input type="submit" value="Lock" class="default">
31+
</div>
32+
33+
</form>
34+
<div>
35+
</div>{% endblock %}

apps/sponsors/tests/test_views_admin.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,87 @@ def test_message_user_if_rejecting_invalid_sponsorship(self):
205205
assert_message(msg, "Can't reject a Finalized sponsorship.", messages.ERROR)
206206

207207

208+
class LockSponsorshipAdminViewTests(TestCase):
209+
def setUp(self):
210+
self.user = baker.make(settings.AUTH_USER_MODEL, is_staff=True, is_superuser=True)
211+
self.client.force_login(self.user)
212+
self.sponsorship = baker.make(
213+
Sponsorship,
214+
status=Sponsorship.APPLIED,
215+
submited_by=self.user,
216+
_fill_optional=True,
217+
)
218+
self.url = reverse("admin:sponsors_sponsorship_lock", args=[self.sponsorship.pk])
219+
220+
def test_display_confirmation_form_on_get(self):
221+
response = self.client.get(self.url)
222+
self.sponsorship.refresh_from_db()
223+
224+
self.assertTemplateUsed(response, "sponsors/admin/lock.html")
225+
self.assertEqual(response.context["sponsorship"], self.sponsorship)
226+
self.assertFalse(self.sponsorship.locked) # GET must not lock
227+
228+
def test_lock_sponsorship_on_post(self):
229+
response = self.client.post(self.url, data={"confirm": "yes"})
230+
self.sponsorship.refresh_from_db()
231+
232+
expected_url = reverse("admin:sponsors_sponsorship_change", args=[self.sponsorship.pk])
233+
self.assertRedirects(response, expected_url, fetch_redirect_response=True)
234+
self.assertTrue(self.sponsorship.locked)
235+
msg = next(iter(get_messages(response.wsgi_request)))
236+
assert_message(msg, "Sponsorship is now locked!", messages.SUCCESS)
237+
238+
def test_do_not_lock_if_invalid_post(self):
239+
response = self.client.post(self.url, data={})
240+
self.sponsorship.refresh_from_db()
241+
self.assertTemplateUsed(response, "sponsors/admin/lock.html")
242+
self.assertFalse(self.sponsorship.locked) # did not lock
243+
244+
response = self.client.post(self.url, data={"confirm": "invalid"})
245+
self.sponsorship.refresh_from_db()
246+
self.assertTemplateUsed(response, "sponsors/admin/lock.html")
247+
self.assertFalse(self.sponsorship.locked)
248+
249+
def test_404_if_sponsorship_does_not_exist(self):
250+
self.sponsorship.delete()
251+
response = self.client.get(self.url)
252+
self.assertEqual(response.status_code, 404)
253+
254+
def test_login_required(self):
255+
login_url = reverse("admin:login")
256+
redirect_url = f"{login_url}?next={self.url}"
257+
self.client.logout()
258+
259+
r = self.client.get(self.url)
260+
261+
self.assertRedirects(r, redirect_url)
262+
263+
def test_staff_required(self):
264+
login_url = reverse("admin:login")
265+
redirect_url = f"{login_url}?next={self.url}"
266+
self.user.is_staff = False
267+
self.user.save()
268+
self.client.force_login(self.user)
269+
270+
r = self.client.get(self.url)
271+
272+
self.assertRedirects(r, redirect_url, fetch_redirect_response=False)
273+
274+
def test_change_permission_required(self):
275+
# A staff account without the sponsorship change permission must not
276+
# reach the action, and a GET must never lock the sponsorship.
277+
staff = baker.make(settings.AUTH_USER_MODEL, is_staff=True, is_superuser=False)
278+
self.client.force_login(staff)
279+
280+
get_response = self.client.get(self.url)
281+
post_response = self.client.post(self.url, data={"confirm": "yes"})
282+
self.sponsorship.refresh_from_db()
283+
284+
self.assertEqual(get_response.status_code, 403)
285+
self.assertEqual(post_response.status_code, 403)
286+
self.assertFalse(self.sponsorship.locked)
287+
288+
208289
class ApproveSponsorshipAdminViewTests(TestCase):
209290
def setUp(self):
210291
self.user = baker.make(settings.AUTH_USER_MODEL, is_staff=True, is_superuser=True)

apps/sponsors/views_admin.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import io
44
import zipfile
5+
from functools import wraps
56

67
from django.contrib import messages
8+
from django.core.exceptions import PermissionDenied
79
from django.db import transaction
810
from django.http import HttpResponse
911
from django.shortcuts import get_object_or_404, redirect, render
@@ -22,6 +24,24 @@
2224
from apps.sponsors.models import BenefitFeature, EmailTargetable, SponsorshipCurrentYear
2325

2426

27+
def require_change_permission(view):
28+
"""Require the model's change permission for a custom admin view.
29+
30+
``AdminSite.admin_view`` only checks that the user is active staff, so the
31+
custom action URLs registered in ``admin.py`` must enforce the per-model
32+
permission themselves.
33+
"""
34+
35+
@wraps(view)
36+
def wrapper(model_admin, request, *args, **kwargs):
37+
if not model_admin.has_change_permission(request):
38+
raise PermissionDenied
39+
return view(model_admin, request, *args, **kwargs)
40+
41+
return wrapper
42+
43+
44+
@require_change_permission
2545
def preview_contract_view(model_admin, request, pk):
2646
"""Render a contract preview as PDF or DOCX based on the format query parameter."""
2747
contract = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -34,6 +54,7 @@ def preview_contract_view(model_admin, request, pk):
3454
return response
3555

3656

57+
@require_change_permission
3758
def reject_sponsorship_view(model_admin, request, pk):
3859
"""Handle rejection of a sponsorship application with confirmation."""
3960
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -53,6 +74,7 @@ def reject_sponsorship_view(model_admin, request, pk):
5374
return render(request, "sponsors/admin/reject_application.html", context=context)
5475

5576

77+
@require_change_permission
5678
def approve_sponsorship_view(model_admin, request, pk):
5779
"""Approves a sponsorship and create an empty contract."""
5880
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -88,6 +110,7 @@ def approve_sponsorship_view(model_admin, request, pk):
88110
return render(request, "sponsors/admin/approve_application.html", context=context)
89111

90112

113+
@require_change_permission
91114
def approve_signed_sponsorship_view(model_admin, request, pk):
92115
"""Approves a sponsorship and execute contract for existing file."""
93116
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -123,6 +146,7 @@ def approve_signed_sponsorship_view(model_admin, request, pk):
123146
return render(request, "sponsors/admin/approve_application.html", context=context)
124147

125148

149+
@require_change_permission
126150
def send_contract_view(model_admin, request, pk):
127151
"""Send a finalized contract to the sponsor for signature."""
128152
contract = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -147,6 +171,7 @@ def send_contract_view(model_admin, request, pk):
147171
return render(request, "sponsors/admin/send_contract.html", context=context)
148172

149173

174+
@require_change_permission
150175
def rollback_to_editing_view(model_admin, request, pk):
151176
"""Roll back a sponsorship to editing status with confirmation."""
152177
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -170,6 +195,7 @@ def rollback_to_editing_view(model_admin, request, pk):
170195
)
171196

172197

198+
@require_change_permission
173199
def unlock_view(model_admin, request, pk):
174200
"""Unlock a sponsorship to allow editing with confirmation."""
175201
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -193,17 +219,28 @@ def unlock_view(model_admin, request, pk):
193219
)
194220

195221

222+
@require_change_permission
196223
def lock_view(model_admin, request, pk):
197-
"""Lock a sponsorship to prevent further editing."""
224+
"""Lock a sponsorship to prevent further editing with confirmation."""
198225
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
199226

200-
sponsorship.locked = True
201-
sponsorship.save()
227+
if request.method.upper() == "POST" and request.POST.get("confirm") == "yes":
228+
sponsorship.locked = True
229+
sponsorship.save(update_fields=["locked"])
230+
model_admin.message_user(request, "Sponsorship is now locked!", messages.SUCCESS)
202231

203-
redirect_url = reverse("admin:sponsors_sponsorship_change", args=[sponsorship.pk])
204-
return redirect(redirect_url)
232+
redirect_url = reverse("admin:sponsors_sponsorship_change", args=[sponsorship.pk])
233+
return redirect(redirect_url)
234+
235+
context = {"sponsorship": sponsorship}
236+
return render(
237+
request,
238+
"sponsors/admin/lock.html",
239+
context=context,
240+
)
205241

206242

243+
@require_change_permission
207244
def execute_contract_view(model_admin, request, pk):
208245
"""Execute a contract by uploading the signed document."""
209246
contract = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -234,6 +271,7 @@ def execute_contract_view(model_admin, request, pk):
234271
return render(request, "sponsors/admin/execute_contract.html", context=context)
235272

236273

274+
@require_change_permission
237275
def nullify_contract_view(model_admin, request, pk):
238276
"""Nullify a contract with confirmation."""
239277
contract = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -258,6 +296,7 @@ def nullify_contract_view(model_admin, request, pk):
258296
return render(request, "sponsors/admin/nullify_contract.html", context=context)
259297

260298

299+
@require_change_permission
261300
@transaction.atomic
262301
def update_related_sponsorships(model_admin, request, pk):
263302
"""Update all related SponsorBenefit from a SponsorshipBenefit.
@@ -288,6 +327,7 @@ def update_related_sponsorships(model_admin, request, pk):
288327
return render(request, "sponsors/admin/update_related_sponsorships.html", context=context)
289328

290329

330+
@require_change_permission
291331
def list_uploaded_assets(model_admin, request, pk):
292332
"""List and export assets uploaded by the user."""
293333
sponsorship = get_object_or_404(model_admin.get_queryset(request), pk=pk)
@@ -296,6 +336,7 @@ def list_uploaded_assets(model_admin, request, pk):
296336
return render(request, "sponsors/admin/list_uploaded_assets.html", context=context)
297337

298338

339+
@require_change_permission
299340
def clone_application_config(model_admin, request):
300341
"""Clone sponsorship application configuration from one year to another."""
301342
form = CloneApplicationConfigForm()

0 commit comments

Comments
 (0)