diff --git a/apps/nominations/forms.py b/apps/nominations/forms.py index 3e4ee49e6..c3779b9af 100644 --- a/apps/nominations/forms.py +++ b/apps/nominations/forms.py @@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe from markupfield.widgets import MarkupTextarea -from apps.nominations.models import ElectionKind, Nomination +from apps.nominations.models import ElectionKind, Nomination, Nominee COC_LABEL = mark_safe( "I agree to adhere to the Python Software Foundation's " @@ -272,6 +272,63 @@ class Meta(NominationForm.Meta): fields = (*NominationForm.Meta.fields, "coc_acknowledged", "eligibility_confirmed") +class EndorsementEditForm(forms.ModelForm): + """Edit an endorsement. The candidate is fixed at submission time and not editable.""" + + #: Acknowledgment plumbing the shared nomination_form.html template expects. + acknowledgment_field_names = () + acknowledgment_fields = () + + def __init__(self, *args, **kwargs): + """Accept (and ignore) the election kwarg the nomination views pass.""" + self.election = kwargs.pop("election", None) + super().__init__(*args, **kwargs) + + class Meta: + """Meta configuration for EndorsementEditForm.""" + + model = Nomination + fields = ( + "employer", + "other_affiliations", + "nomination_statement", + ) + widgets = {"nomination_statement": MarkupTextarea()} + help_texts = { + "employer": "Nominee's current employer.", + "other_affiliations": "Any other relevant affiliations the Nominee has.", + "nomination_statement": "Markdown syntax supported.", + } + + +class EndorsementCreateForm(EndorsementEditForm): + """Endorse an already-approved candidate other than yourself, picked from a list.""" + + nominee = forms.ModelChoiceField( + queryset=Nominee.objects.none(), + label="Candidate", + help_text="Only candidates already accepted and approved for this election can be endorsed.", + ) + + def __init__(self, *args, **kwargs): + """Scope the candidate choices to the election's approved nominees, excluding the requester.""" + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + queryset = ( + Nominee.objects.filter(election=self.election, accepted=True, approved=True) + .exclude(user=None) + .select_related("user") + ) + if self.request is not None: + queryset = queryset.exclude(user=self.request.user) + self.fields["nominee"].queryset = queryset + + class Meta(EndorsementEditForm.Meta): + """Meta configuration for EndorsementCreateForm.""" + + fields = ("nominee", *EndorsementEditForm.Meta.fields) + + class NominationAcceptForm(forms.ModelForm): """Form for a nominee to accept or decline a nomination.""" diff --git a/apps/nominations/migrations/0005_endorsement_window.py b/apps/nominations/migrations/0005_endorsement_window.py new file mode 100644 index 000000000..d5d3fdd6b --- /dev/null +++ b/apps/nominations/migrations/0005_endorsement_window.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.17 on 2026-10-08 15:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("nominations", "0004_acknowledgments_and_form_variant"), + ] + + operations = [ + migrations.AddField( + model_name="election", + name="endorsements_close_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="election", + name="endorsements_open_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="nomination", + name="is_endorsement", + field=models.BooleanField( + default=False, help_text="Submitted via the endorsement window for an already-approved candidate." + ), + ), + ] diff --git a/apps/nominations/models.py b/apps/nominations/models.py index e152869ef..0b0939649 100644 --- a/apps/nominations/models.py +++ b/apps/nominations/models.py @@ -78,6 +78,8 @@ class Election(models.Model): ) nominations_open_at = models.DateTimeField(blank=True, null=True) nominations_close_at = models.DateTimeField(blank=True, null=True) + endorsements_open_at = models.DateTimeField(blank=True, null=True) + endorsements_close_at = models.DateTimeField(blank=True, null=True) description = MarkupField(escape_html=False, markup_type="markdown", blank=False, null=True) hide_previous_service = models.BooleanField( default=False, @@ -123,6 +125,18 @@ def nominations_open(self): return False + @property + def endorsements_open(self): + """Return True if the current time is within the endorsement window. + + Independent of the nomination window: endorsements can be reopened for + already-approved candidates after nominations have closed. + """ + if self.endorsements_open_at and self.endorsements_close_at: + return self.endorsements_open_at < datetime.datetime.now(datetime.UTC) < self.endorsements_close_at + + return False + @property def nominations_complete(self): """Return True if the nomination window has closed.""" @@ -280,6 +294,11 @@ class Nomination(models.Model): accepted = models.BooleanField(null=False, default=False) approved = models.BooleanField(null=False, default=False) + is_endorsement = models.BooleanField( + default=False, + help_text="Submitted via the endorsement window for an already-approved candidate.", + ) + # Candidate acknowledgments collected at submission time; wording and # which are mandatory vary per election kind (see the create forms). coc_acknowledged = models.BooleanField(default=False) @@ -324,11 +343,16 @@ def get_accept_url(self): ) def editable(self, user=None): - """Return True if the given user can edit this nomination.""" - if self.nominee and user == self.nominee.user and self.election.nominations_open: + """Return True if the given user can edit this nomination. + + Endorsements are gated on the endorsement window rather than the + nomination window, which is closed by the time they are submitted. + """ + window_open = self.election.endorsements_open if self.is_endorsement else self.election.nominations_open + if self.nominee and user == self.nominee.user and window_open: return True - return bool(user == self.nominator and not (self.accepted or self.approved) and self.election.nominations_open) + return bool(user == self.nominator and not (self.accepted or self.approved) and window_open) def visible(self, user=None): """Return True if the nomination is visible to the given user.""" diff --git a/apps/nominations/templates/nominations/election_detail.html b/apps/nominations/templates/nominations/election_detail.html index a06f7afa7..51dc636f1 100644 --- a/apps/nominations/templates/nominations/election_detail.html +++ b/apps/nominations/templates/nominations/election_detail.html @@ -37,6 +37,10 @@

{{ election.name }}

  • Nominations Close: {{ election.nominations_close_at|date:"r" }}
  • {% endif %} {% endif %} + {% if election.endorsements_open %} +
  • Endorse a candidate
  • +
  • Endorsements Close: {{ election.endorsements_close_at|date:"r" }}
  • + {% endif %} {% endtimezone %} diff --git a/apps/nominations/templates/nominations/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index e3ad0b469..71da2edb8 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -69,6 +69,8 @@
    {% if form_action == 'update' %}

    Update {{ object.job_title }}

    + {% elif is_endorsement %} +

    Submit an Endorsement for {{ election.name }} Election

    {% else %}

    Submit a Nomination for {{ election.name }} Election

    {% endif %} diff --git a/apps/nominations/tests/test_forms.py b/apps/nominations/tests/test_forms.py index f53378a28..17fe994c5 100644 --- a/apps/nominations/tests/test_forms.py +++ b/apps/nominations/tests/test_forms.py @@ -4,11 +4,12 @@ from apps.nominations.forms import ( BoardNominationCreateForm, + EndorsementCreateForm, NominationForm, PackagingCouncilNominationCreateForm, ) -from apps.nominations.models import Election, Nomination -from apps.nominations.tests.utils import nomination_payload, packaging_council_kind +from apps.nominations.models import Election, Nomination, Nominee +from apps.nominations.tests.utils import endorsement_election, nomination_payload, packaging_council_kind from apps.users.factories import UserFactory @@ -169,3 +170,27 @@ def test_prefills_no_from_stored_new_member(self): nomination = Nomination(previous_board_service="New Packaging Council member") form = NominationForm(instance=nomination, election=self._election()) self.assertEqual(form.fields["previous_service"].initial, "no") + + +class EndorsementCreateFormTests(TestCase): + def setUp(self): + self.election = endorsement_election("2026 Board Election") + self.request = RequestFactory().get("/") + self.request.user = UserFactory(first_name="Ellen", last_name="Endorser") + self.candidate = self._nominee(accepted=True, approved=True) + + def _nominee(self, election=None, **flags): + return Nominee.objects.create(user=UserFactory(), election=election or self.election, **flags) + + def test_candidates_limited_to_approved_nominees_other_than_requester(self): + pending = self._nominee(accepted=True) + unaccepted = self._nominee(approved=True) + other_election = self._nominee(election=endorsement_election("Other Election"), accepted=True, approved=True) + myself = Nominee.objects.create(user=self.request.user, election=self.election, accepted=True, approved=True) + + form = EndorsementCreateForm(request=self.request, election=self.election) + queryset = form.fields["nominee"].queryset + + self.assertIn(self.candidate, queryset) + for excluded in (pending, unaccepted, other_election, myself): + self.assertNotIn(excluded, queryset) diff --git a/apps/nominations/tests/test_models.py b/apps/nominations/tests/test_models.py index bfd86146d..17117aa54 100644 --- a/apps/nominations/tests/test_models.py +++ b/apps/nominations/tests/test_models.py @@ -2,8 +2,10 @@ from django.conf import settings from django.test import TestCase +from django.utils import timezone -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.users.factories import UserFactory class ElectionKindModelTests(TestCase): @@ -53,6 +55,85 @@ def test_accent_color_falls_back_after_kind_deleted(self): self.assertEqual(self.election.accent_color, DEFAULT_ACCENT_COLOR) +class EndorsementWindowTests(TestCase): + """``endorsements_open`` must be independent of the nomination window.""" + + def _election(self, **extra): + return Election.objects.create(name="2026 Board Election", date=datetime.date(2026, 12, 1), **extra) + + def test_closed_when_only_one_date_set(self): + now = timezone.now() + self.assertFalse(self._election(endorsements_open_at=now - datetime.timedelta(days=1)).endorsements_open) + self.assertFalse(self._election(endorsements_close_at=now + datetime.timedelta(days=1)).endorsements_open) + + def test_open_between_dates(self): + now = timezone.now() + election = self._election( + endorsements_open_at=now - datetime.timedelta(days=1), + endorsements_close_at=now + datetime.timedelta(days=1), + ) + self.assertTrue(election.endorsements_open) + # The nomination window is untouched by the endorsement window. + self.assertFalse(election.nominations_open) + + +class NominationEditableWindowTests(TestCase): + """``editable()`` follows the endorsement window for endorsements only.""" + + def setUp(self): + self.nominator = UserFactory() + self.nominee_user = UserFactory(first_name="Grace", last_name="Hopper") + + def _election(self, **extra): + return Election.objects.create(name="2026 Board Election", date=datetime.date(2026, 12, 1), **extra) + + def _nomination(self, election, **extra): + nominee = Nominee.objects.create(user=self.nominee_user, election=election, accepted=True, approved=True) + return Nomination.objects.create( + election=election, + nominator=self.nominator, + nominee=nominee, + name="Grace Hopper", + email="grace@example.com", + nomination_statement="A strong candidate.", + **extra, + ) + + def test_endorsement_editable_during_endorsement_window(self): + now = timezone.now() + election = self._election( + nominations_open_at=now - datetime.timedelta(days=10), + nominations_close_at=now - datetime.timedelta(days=5), + endorsements_open_at=now - datetime.timedelta(days=1), + endorsements_close_at=now + datetime.timedelta(days=1), + ) + nomination = self._nomination(election, is_endorsement=True) + self.assertTrue(nomination.editable(self.nominator)) + self.assertTrue(nomination.editable(self.nominee_user)) + + def test_endorsement_not_editable_after_endorsement_window(self): + now = timezone.now() + election = self._election( + endorsements_open_at=now - datetime.timedelta(days=2), + endorsements_close_at=now - datetime.timedelta(days=1), + ) + nomination = self._nomination(election, is_endorsement=True) + self.assertFalse(nomination.editable(self.nominator)) + self.assertFalse(nomination.editable(self.nominee_user)) + + def test_legacy_nomination_ignores_endorsement_window(self): + now = timezone.now() + election = self._election( + nominations_open_at=now - datetime.timedelta(days=10), + nominations_close_at=now - datetime.timedelta(days=5), + endorsements_open_at=now - datetime.timedelta(days=1), + endorsements_close_at=now + datetime.timedelta(days=1), + ) + nomination = self._nomination(election) + self.assertFalse(nomination.editable(self.nominator)) + self.assertFalse(nomination.editable(self.nominee_user)) + + class MarkupSanitizationTests(TestCase): def _render(self, markup_type, text): renderers = {entry[0]: entry[1] for entry in settings.MARKUP_FIELD_TYPES} diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index 441a00d3c..8a0b54980 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -11,7 +11,12 @@ Nomination, Nominee, ) -from apps.nominations.tests.utils import nomination_payload, open_election, packaging_council_kind +from apps.nominations.tests.utils import ( + endorsement_election, + nomination_payload, + open_election, + packaging_council_kind, +) from apps.users.factories import UserFactory @@ -410,3 +415,167 @@ def test_other_users_still_cannot_open_the_nominee(self): self.client.force_login(UserFactory(first_name="Guido", last_name="van Rossum")) response = self.client.get(self.nominee.get_absolute_url()) self.assertEqual(response.status_code, 404) + + +class EndorsementCreateTests(TestCase): + """The endorsement window is separate from the nomination window.""" + + def setUp(self): + self.user = UserFactory(first_name="Ellen", last_name="Endorser") + self.election = endorsement_election("2026 Board Election") + self.candidate = Nominee.objects.create( + user=UserFactory(first_name="Grace", last_name="Hopper", email="grace@example.com"), + election=self.election, + accepted=True, + approved=True, + ) + self.client.force_login(self.user) + + def _url(self, election=None): + return reverse("nominations:endorsement_create", kwargs={"election": (election or self.election).slug}) + + def _payload(self, **overrides): + data = { + "nominee": self.candidate.pk, + "employer": "US Navy", + "other_affiliations": "", + "nomination_statement": "A strong candidate.", + } + data.update(overrides) + return data + + def test_available_while_nominations_closed(self): + self.assertFalse(self.election.nominations_open) + response = self.client.get(self._url()) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Submit an Endorsement for") + + def test_404s_when_endorsements_closed_even_if_nominations_open(self): + election = open_election("2026 Packaging Council Election", kind=packaging_council_kind()) + self.assertTrue(election.nominations_open) + self.assertFalse(election.endorsements_open) + self.assertEqual(self.client.get(self._url(election)).status_code, 404) + + def test_nomination_create_still_404s_during_endorsement_window(self): + url = reverse("nominations:nomination_create", kwargs={"election": self.election.slug}) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_successful_post_links_candidate_without_creating_a_nominee(self): + nominee_count = Nominee.objects.count() + response = self.client.post(self._url(), self._payload()) + + self.assertEqual(response.status_code, 302) + self.assertEqual(Nominee.objects.count(), nominee_count) + nomination = Nomination.objects.get(election=self.election) + self.assertTrue(nomination.is_endorsement) + self.assertEqual(nomination.nominee, self.candidate) + self.assertEqual(nomination.name, self.candidate.name) + self.assertEqual(nomination.email, self.candidate.user.email) + self.assertEqual(nomination.nominator, self.user) + self.assertFalse(nomination.accepted) + self.assertFalse(nomination.approved) + + def test_ineligible_candidate_is_rejected(self): + pending = Nominee.objects.create(user=UserFactory(), election=self.election, accepted=True) + response = self.client.post(self._url(), self._payload(nominee=pending.pk)) + self.assertEqual(response.status_code, 200) + self.assertFalse(Nomination.objects.filter(election=self.election).exists()) + + def test_self_endorsement_is_rejected(self): + myself = Nominee.objects.create(user=self.user, election=self.election, accepted=True, approved=True) + response = self.client.post(self._url(), self._payload(nominee=myself.pk)) + self.assertEqual(response.status_code, 200) + self.assertFalse(Nomination.objects.filter(election=self.election).exists()) + + +class EndorsementAcceptTests(TestCase): + """A nominee can accept an endorsement while the endorsement window is open.""" + + def _endorsement(self, election): + candidate = Nominee.objects.create( + user=UserFactory(first_name="Grace", last_name="Hopper"), + election=election, + accepted=True, + approved=True, + ) + return Nomination.objects.create( + election=election, + nominator=UserFactory(), + nominee=candidate, + name=candidate.name, + email=candidate.user.email, + nomination_statement="A strong candidate.", + is_endorsement=True, + ) + + def _accept(self, endorsement): + self.client.force_login(endorsement.nominee.user) + url = reverse( + "nominations:nomination_accept", + kwargs={"election": endorsement.election.slug, "pk": endorsement.pk}, + ) + return self.client.post(url, {"accepted": True}) + + def test_nominee_can_accept_during_endorsement_window(self): + endorsement = self._endorsement(endorsement_election("2026 Board Election")) + response = self._accept(endorsement) + self.assertEqual(response.status_code, 302) + endorsement.refresh_from_db() + self.assertTrue(endorsement.accepted) + + def test_nominee_denied_after_endorsement_window(self): + now = datetime.datetime.now(datetime.UTC) + election = Election.objects.create( + name="2026 Board Election", + date=datetime.date(2026, 12, 1), + endorsements_open_at=now - datetime.timedelta(days=2), + endorsements_close_at=now - datetime.timedelta(days=1), + ) + endorsement = self._endorsement(election) + self.assertEqual(self._accept(endorsement).status_code, 403) + + +class EndorsementEditTests(TestCase): + """Editing an endorsement must not let the endorser rewrite the candidate.""" + + def setUp(self): + self.nominator = UserFactory(first_name="Ellen", last_name="Endorser") + self.election = endorsement_election("2026 Board Election") + self.candidate = Nominee.objects.create( + user=UserFactory(first_name="Grace", last_name="Hopper", email="grace@example.com"), + election=self.election, + accepted=True, + approved=True, + ) + self.endorsement = Nomination.objects.create( + election=self.election, + nominator=self.nominator, + nominee=self.candidate, + name=self.candidate.name, + email=self.candidate.user.email, + nomination_statement="A strong candidate.", + is_endorsement=True, + ) + self.client.force_login(self.nominator) + self.url = reverse( + "nominations:nomination_edit", + kwargs={"election": self.election.slug, "pk": self.endorsement.pk}, + ) + + def test_posted_candidate_identity_is_ignored(self): + response = self.client.post( + self.url, + { + "name": "Tampered", + "email": "tampered@example.com", + "employer": "US Navy", + "other_affiliations": "", + "nomination_statement": "Updated statement.", + }, + ) + + self.assertEqual(response.status_code, 302) + self.endorsement.refresh_from_db() + self.assertEqual(self.endorsement.name, "Grace Hopper") + self.assertEqual(self.endorsement.email, "grace@example.com") + self.assertEqual(self.endorsement.employer, "US Navy") diff --git a/apps/nominations/tests/utils.py b/apps/nominations/tests/utils.py index fe4f2216a..1db35ac32 100644 --- a/apps/nominations/tests/utils.py +++ b/apps/nominations/tests/utils.py @@ -28,6 +28,21 @@ def open_election(name, kind=None, **extra): ) +def endorsement_election(name, kind=None, **extra): + """Create an election whose nominations have closed but whose endorsements are open.""" + now = timezone.now() + return Election.objects.create( + name=name, + date=(now + datetime.timedelta(days=30)).date(), + kind=kind, + nominations_open_at=now - datetime.timedelta(days=10), + nominations_close_at=now - datetime.timedelta(days=5), + endorsements_open_at=now - datetime.timedelta(days=1), + endorsements_close_at=now + datetime.timedelta(days=1), + **extra, + ) + + def nomination_payload(**overrides): """Return a minimally valid nomination POST payload.""" data = { diff --git a/apps/nominations/urls.py b/apps/nominations/urls.py index 08abcacc7..206453411 100644 --- a/apps/nominations/urls.py +++ b/apps/nominations/urls.py @@ -28,6 +28,11 @@ views.NominationCreate.as_view(), name="nomination_create", ), + path( + "/endorse/", + views.EndorsementCreate.as_view(), + name="endorsement_create", + ), path( "//", views.NominationView.as_view(), diff --git a/apps/nominations/views.py b/apps/nominations/views.py index 77568b36a..af77b3166 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -12,6 +12,8 @@ from apps.nominations.forms import ( BoardNominationCreateForm, + EndorsementCreateForm, + EndorsementEditForm, NominationAcceptForm, NominationForm, PackagingCouncilNominationCreateForm, @@ -171,6 +173,60 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) +class EndorsementCreate(LoginRequiredMixin, NominationMixin, CreateView): + """Endorse a candidate already accepted and approved for an election. + + Separate from ``NominationCreate``: it can only be used while the + endorsement window is open, never creates a ``Nominee``, and cannot be + used to endorse yourself. + """ + + model = Nomination + template_name = "nominations/nomination_form.html" + + login_message = "Please login to submit an endorsement." + + def get_form_kwargs(self): + """Add the request and election to the form kwargs.""" + kwargs = super().get_form_kwargs() + kwargs.update({"request": self.request, "election": self.election}) + return kwargs + + def get_form_class(self): + """Return the endorsement form, 404ing when the endorsement window is not open.""" + election = self.election + if not election.endorsements_open: + messages.error(self.request, f"Endorsements for {election.name} Election are not open") + msg = f"Endorsements for {election.name} Election are not open" + raise Http404(msg) + + return EndorsementCreateForm + + def get_success_url(self): + """Return the URL for the newly created endorsement detail page.""" + return reverse( + "nominations:nomination_detail", + kwargs={"election": self.object.election.slug, "pk": self.object.id}, + ) + + def form_valid(self, form): + """Link the endorsement to the selected candidate before saving.""" + nominee = form.cleaned_data["nominee"] + form.instance.nominator = self.request.user + form.instance.election = self.election + form.instance.nominee = nominee + form.instance.name = nominee.name + form.instance.email = nominee.user.email + form.instance.is_endorsement = True + return super().form_valid(form) + + def get_context_data(self, **kwargs): + """Flag the shared nomination form template as rendering an endorsement.""" + context = super().get_context_data(**kwargs) + context["is_endorsement"] = True + return context + + class NominationEdit(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, UpdateView): """Edit an existing nomination.""" @@ -183,6 +239,13 @@ def test_func(self): """Allow editing only while the nomination is still editable.""" return self.get_object().editable(self.request.user) + def get_form_class(self): + """Use the endorsement form for endorsements so the candidate stays fixed.""" + if self.object.is_endorsement: + return EndorsementEditForm + + return self.form_class + def get_queryset(self): """Fetch the nomination for the URL's election with its kind in one query.""" return Nomination.objects.filter(election__slug=self.kwargs["election"]).select_related("election__kind") @@ -222,9 +285,12 @@ class NominationAccept(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, raise_exception = True def test_func(self): - """Only allow the nominee to accept while nominations are open.""" + """Only allow the nominee to accept while the relevant submission window is open.""" nomination = self.get_object() - return self.request.user == nomination.nominee.user and nomination.election.nominations_open + window_open = ( + nomination.election.endorsements_open if nomination.is_endorsement else nomination.election.nominations_open + ) + return self.request.user == nomination.nominee.user and window_open def get_queryset(self): """Fetch the URL election's nomination with the related objects the template renders."""