From a31225dfc3e03e10c7a01883c04bcca99f64d11d Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 29 Jul 2026 09:18:28 -0500 Subject: [PATCH] Restrict editing nominations outside window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tonghuaroot (童话) <23011166+tonghuaroot@users.noreply.github.com> --- apps/nominations/tests/test_views.py | 65 +++++++++++++++++++++++++++- apps/nominations/views.py | 9 ++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index 139b457fc..87b8c04b9 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -1,9 +1,16 @@ import datetime +from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse -from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind, Nomination +from apps.nominations.models import ( + DEFAULT_ACCENT_COLOR, + Election, + ElectionKind, + Nomination, + Nominee, +) from apps.nominations.tests.utils import nomination_payload, open_election, packaging_council_kind from apps.users.factories import UserFactory @@ -32,6 +39,62 @@ def test_detail_falls_back_to_default_accent_without_kind(self): self.assertContains(response, f"--election-accent: {DEFAULT_ACCENT_COLOR}") +class NominationEditLockTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.nominator = user_model.objects.create_user("nominator", "nominator@example.com", "password") + nominee_user = user_model.objects.create_user( + "nominee", + "nominee@example.com", + "password", + first_name="The", + last_name="Nominee", + ) + now = datetime.datetime.now(datetime.UTC) + self.election = Election.objects.create( + name="2026 Board Election", + date=datetime.date(2026, 1, 1), + nominations_open_at=now - datetime.timedelta(days=2), + nominations_close_at=now - datetime.timedelta(days=1), + ) + self.nominee = Nominee.objects.create(user=nominee_user, election=self.election, accepted=True) + self.nomination = Nomination.objects.create( + election=self.election, + nominator=self.nominator, + nominee=self.nominee, + name="Jane Original", + email="jane@example.com", + nomination_statement="Original statement.", + accepted=True, + approved=True, + ) + + def test_nominator_cannot_edit_after_approval_and_close(self): + self.assertFalse(self.nomination.editable(self.nominator)) + self.client.force_login(self.nominator) + url = reverse( + "nominations:nomination_edit", + kwargs={"election": self.election.slug, "pk": self.nomination.pk}, + ) + + response = self.client.post(url, {"name": "Tampered"}) + + self.assertEqual(response.status_code, 403) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.name, "Jane Original") + + def test_nominee_cannot_accept_after_close(self): + self.client.force_login(self.nominee.user) + url = reverse( + "nominations:nomination_accept", + kwargs={"election": self.election.slug, "pk": self.nomination.pk}, + ) + + response = self.client.post(url, {"accepted": True}) + + self.assertEqual(response.status_code, 403) + + class UnknownElectionSlugTests(TestCase): """An unknown election slug must 404 rather than blow up with DoesNotExist.""" diff --git a/apps/nominations/views.py b/apps/nominations/views.py index 6f992aa90..699da3231 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -167,8 +167,8 @@ class NominationEdit(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, U raise_exception = True def test_func(self): - """Only allow the original nominator to edit.""" - return self.request.user == self.get_object().nominator + """Allow editing only while the nomination is still editable.""" + return self.get_object().editable(self.request.user) def get_queryset(self): """Fetch the nomination for the URL's election with its kind in one query.""" @@ -209,8 +209,9 @@ class NominationAccept(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, raise_exception = True def test_func(self): - """Only allow the nominee to accept.""" - return self.request.user == self.get_object().nominee.user + """Only allow the nominee to accept while nominations are open.""" + nomination = self.get_object() + return self.request.user == nomination.nominee.user and nomination.election.nominations_open def get_queryset(self): """Fetch the URL election's nomination with the related objects the template renders."""