Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion apps/nominations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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."""

Expand Down
29 changes: 29 additions & 0 deletions apps/nominations/migrations/0005_endorsement_window.py
Original file line number Diff line number Diff line change
@@ -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."
),
),
]
30 changes: 27 additions & 3 deletions apps/nominations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
4 changes: 4 additions & 0 deletions apps/nominations/templates/nominations/election_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ <h1>{{ election.name }}</h1>
<li>Nominations Close: {{ election.nominations_close_at|date:"r" }}</li>
{% endif %}
{% endif %}
{% if election.endorsements_open %}
<li><a href="{% url 'nominations:endorsement_create' election=election.slug %}">Endorse a candidate</a></li>
<li>Endorsements Close: {{ election.endorsements_close_at|date:"r" }}</li>
{% endif %}
</ul>
{% endtimezone %}
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/nominations/templates/nominations/nomination_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
<header class="article-header">
{% if form_action == 'update' %}
<h1 class="page-title">Update <a href="{{ object.get_absolute_url }}">{{ object.job_title }}</a></h1>
{% elif is_endorsement %}
<h1 class="page-title">Submit an Endorsement for {{ election.name }} Election</h1>
{% else %}
<h1 class="page-title">Submit a Nomination for {{ election.name }} Election</h1>
{% endif %}
Expand Down
29 changes: 27 additions & 2 deletions apps/nominations/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
83 changes: 82 additions & 1 deletion apps/nominations/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading