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' %}
+ {% 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."""