From 27702e882341a5de47dd3563a291ba89e31f7ac2 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:23:10 -0500 Subject: [PATCH 01/13] update nom accept --- .../templates/nominations/nomination_accept_form.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/nominations/templates/nominations/nomination_accept_form.html b/apps/nominations/templates/nominations/nomination_accept_form.html index 4e0de693a..e4a0b75b8 100644 --- a/apps/nominations/templates/nominations/nomination_accept_form.html +++ b/apps/nominations/templates/nominations/nomination_accept_form.html @@ -20,7 +20,9 @@

Nomination for {{ nomination.name }} in {{ nomination.ele
  • Name: {{ nomination.name }}
  • Employer: {{ nomination.employer }}
  • Other Affiliations: {{ nomination.other_affiliations }}
  • -
  • Previous Board Service: {{ nomination.previous_board_service }}
  • + {% if nomination.previous_board_service %} +
  • {{ nomination.election.previous_service_label }}: {{ nomination.previous_board_service }}
  • + {% endif %}
  • Nominated By: {{ nomination.nominator.first_name }} {{ nomination.nominator.last_name }}
  • {% if nomination.nominee.user == request.user %}
  • Accepted: {{ nomination.accepted }}
  • From 8e9db992880c50dd6c13f56dbd9596004907479d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:23:39 -0500 Subject: [PATCH 02/13] update nom forms --- .../templates/nominations/nomination_detail.html | 4 +++- .../templates/nominations/nominee_detail.html | 9 ++++++++- apps/nominations/templates/nominations/nominee_list.html | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/nominations/templates/nominations/nomination_detail.html b/apps/nominations/templates/nominations/nomination_detail.html index 8c57f6fd3..24e629494 100644 --- a/apps/nominations/templates/nominations/nomination_detail.html +++ b/apps/nominations/templates/nominations/nomination_detail.html @@ -51,7 +51,9 @@

    Nomination for {{ nomination.name }} in {{ nomination.ele
  • Name: {{ nomination.name }}
  • Employer: {{ nomination.employer }}
  • Other Affiliations: {{ nomination.other_affiliations }}
  • -
  • Previous Board Service: {{ nomination.previous_board_service }}
  • + {% if nomination.previous_board_service %} +
  • {{ nomination.election.previous_service_label }}: {{ nomination.previous_board_service }}
  • + {% endif %} {% if nomination.nominee.user == request.user %}
  • Nominated By: Self
  • Accepted: {{ nomination.accepted }}
  • diff --git a/apps/nominations/templates/nominations/nominee_detail.html b/apps/nominations/templates/nominations/nominee_detail.html index 911e9b161..29d0df052 100644 --- a/apps/nominations/templates/nominations/nominee_detail.html +++ b/apps/nominations/templates/nominations/nominee_detail.html @@ -16,9 +16,16 @@

    Nominee for {{ election.name }} Election

    {{ nominee.display_name }}

      -
    • Previous Board Service: {{ nominee.display_previous_board_service }}
    • + {% with previous_service=nominee.display_previous_board_service %} + {% if previous_service %} +
    • {{ election.previous_service_label }}: {{ previous_service }}
    • + {% endif %} + {% endwith %}
    • Employer: {{ nominee.display_employer }}
    • Other Affiliations: {{ nominee.display_other_affiliations }}
    • + {% if nominee.display_mission_alignment %} +
    • Mission Alignment: This candidate affirms their nomination and positions are aligned with the current mission and bylaws of the Python Software Foundation.
    • + {% endif %} {% if nominee.self_nomination %}
    • Nominee Statement:
        diff --git a/apps/nominations/templates/nominations/nominee_list.html b/apps/nominations/templates/nominations/nominee_list.html index def5fdca5..54a3b011f 100644 --- a/apps/nominations/templates/nominations/nominee_list.html +++ b/apps/nominations/templates/nominations/nominee_list.html @@ -339,7 +339,11 @@

        {{ nominee.display_name }}

        • Name: {{ nominee.display_name }}
        • -
        • Previous Board Service: {{ nominee.display_previous_board_service }}
        • + {% with previous_service=nominee.display_previous_board_service %} + {% if previous_service %} +
        • {{ election.previous_service_label }}: {{ previous_service }}
        • + {% endif %} + {% endwith %}
        • Employer: {{ nominee.display_employer }}
        • Other Affiliations: {{ nominee.display_other_affiliations }}
        • {% if nominee.self_nomination %} From cbe1c8e94c92e877589a0c3da20094eeaee9cd13 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:23:56 -0500 Subject: [PATCH 03/13] update form, add acks --- .../nominations/nomination_form.html | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/nominations/templates/nominations/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index f258aa3ad..7d71e61a0 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -14,6 +14,20 @@ {% block left_sidebar %}{% endblock %} {% block content_attributes %}{% endblock %} +{% block extra_js %} + +{% endblock %} + {% block content %}
          @@ -28,9 +42,22 @@

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

          {% csrf_token %} {% if next %}{% endif %} + {{ form.non_field_errors }} - {{ form.as_table }} + {% for field in form %} + {% if field.name not in form.acknowledgment_field_names %} + + + + + {% endif %} + {% endfor %}
          {{ field.label_tag }} + {{ field }} + {% if field.help_text %}
          {{ field.help_text }}{% endif %} + {{ field.errors }} +
          + {% include "nominations/_acknowledgments.html" %}
          From 810f9c985fae65d5fb9b5cae7b8b50780d7d3b9d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:24:09 -0500 Subject: [PATCH 04/13] more admin list --- apps/nominations/admin.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/nominations/admin.py b/apps/nominations/admin.py index b9d157dff..6171f4888 100644 --- a/apps/nominations/admin.py +++ b/apps/nominations/admin.py @@ -10,7 +10,7 @@ class ElectionKindAdmin(admin.ModelAdmin): """Admin interface for managing election kinds and their accent colors.""" - list_display = ("name", "accent_color", "slug") + list_display = ("name", "nomination_form", "accent_color", "slug") readonly_fields = ("slug",) @@ -18,7 +18,7 @@ class ElectionKindAdmin(admin.ModelAdmin): class ElectionAdmin(admin.ModelAdmin): """Admin interface for managing elections.""" - list_display = ("name", "kind", "date", "slug") + list_display = ("name", "kind", "date", "hide_previous_service", "slug") list_filter = ("kind",) readonly_fields = ("slug",) @@ -42,8 +42,24 @@ class NominationAdmin(admin.ModelAdmin): """Admin interface for managing nominations.""" raw_id_fields = ("nominee", "nominator") - list_display = ("__str__", "election", "accepted", "approved", "nominee") - list_filter = ("election", "accepted", "approved") + list_display = ( + "__str__", + "election", + "accepted", + "approved", + "nominee", + "coc_acknowledged", + "mission_alignment", + "eligibility_confirmed", + ) + list_filter = ( + "election", + "accepted", + "approved", + "coc_acknowledged", + "mission_alignment", + "eligibility_confirmed", + ) def get_ordering(self, request): """Return ordering by election and nominee last name.""" From 845f9a34a1d9bf31a23ea256192601cbc5f4745a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:24:39 -0500 Subject: [PATCH 05/13] add new fields and props --- apps/nominations/models.py | 48 +++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/nominations/models.py b/apps/nominations/models.py index 888a2853d..8ef33e514 100644 --- a/apps/nominations/models.py +++ b/apps/nominations/models.py @@ -8,6 +8,7 @@ from django.dispatch import receiver from django.urls import reverse from django.utils import timezone +from django.utils.functional import cached_property from django.utils.text import slugify from markupfield.fields import MarkupField @@ -25,9 +26,21 @@ class ElectionKind(models.Model): code changes. """ + class NominationFormVariant(models.TextChoices): + """Which public nomination form (and acknowledgment wording) a kind uses.""" + + BOARD = "board", "PSF Board" + PACKAGING_COUNCIL = "packaging_council", "Packaging Council" + name = models.CharField(max_length=100, unique=True) slug = models.SlugField(max_length=120, unique=True, blank=True, null=True) accent_color = ColorField(default=DEFAULT_ACCENT_COLOR, help_text="Accent color used to theme this kind's pages.") + nomination_form = models.CharField( + max_length=32, + choices=NominationFormVariant.choices, + default=NominationFormVariant.BOARD, + help_text="Which nomination form and acknowledgment wording elections of this kind use.", + ) class Meta: """Meta configuration for ElectionKind.""" @@ -43,6 +56,13 @@ def save(self, *args, **kwargs): self.slug = slugify(self.name) super().save(*args, **kwargs) + @property + def previous_service_label(self): + """Return the display label for the 'previous service' field for this kind.""" + if self.nomination_form == self.NominationFormVariant.PACKAGING_COUNCIL: + return "Previous Packaging Council Service" + return "Previous Board Service" + class Election(models.Model): """A PSF board election with nomination open/close dates.""" @@ -59,6 +79,10 @@ class Election(models.Model): nominations_open_at = models.DateTimeField(blank=True, null=True) nominations_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, + help_text="Hide the 'previous service' field on the nomination form (e.g. an inaugural election with no prior terms).", + ) slug = models.SlugField(max_length=255, blank=True, null=True) # noqa: DJ001 @@ -81,6 +105,16 @@ def accent_color(self): """Return the CSS accent color for this election's kind.""" return self.kind.accent_color if self.kind else DEFAULT_ACCENT_COLOR + @property + def previous_service_label(self): + """Return the display label for the 'previous service' field for this election.""" + return self.kind.previous_service_label if self.kind else "Previous Board Service" + + @property + def nomination_form_variant(self): + """Return the nomination form variant for this election's kind (Board when kindless).""" + return self.kind.nomination_form if self.kind else ElectionKind.NominationFormVariant.BOARD + @property def nominations_open(self): """Return True if the current time is within the nomination window.""" @@ -166,7 +200,7 @@ def nominations_pending(self): """Return pending nominations excluding self-nominations.""" return self.nominations.exclude(accepted=False, approved=False).exclude(nominator=self.user).all() - @property + @cached_property def self_nomination(self): """Return the self-nomination for this nominee, if any.""" return self.nominations.filter(nominator=self.user).first() @@ -200,6 +234,12 @@ def display_other_affiliations(self): return self.nominations.first().other_affiliations + @property + def display_mission_alignment(self): + """Return True if the relevant nomination affirmed the voter-clarity statement.""" + nomination = self.self_nomination or self.nominations.filter(accepted=True, approved=True).first() + return bool(nomination and nomination.mission_alignment) + def visible(self, user=None): """Return True if the nominee is visible to the given user.""" if self.accepted and self.approved and not self.election.nominations_open: @@ -235,6 +275,12 @@ class Nomination(models.Model): accepted = models.BooleanField(null=False, default=False) approved = models.BooleanField(null=False, default=False) + # 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) + mission_alignment = models.BooleanField(default=False) + eligibility_confirmed = models.BooleanField(default=False) + def __str__(self): """Return the nominee name and email.""" return f"{self.name} <{self.email}>" From 024e33f977298fe623cc7b92d4ad3c1b164493b3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:24:53 -0500 Subject: [PATCH 06/13] need migrate --- .../0004_acknowledgments_and_form_variant.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/nominations/migrations/0004_acknowledgments_and_form_variant.py diff --git a/apps/nominations/migrations/0004_acknowledgments_and_form_variant.py b/apps/nominations/migrations/0004_acknowledgments_and_form_variant.py new file mode 100644 index 000000000..6d24831be --- /dev/null +++ b/apps/nominations/migrations/0004_acknowledgments_and_form_variant.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("nominations", "0003_election_kind"), + ] + + operations = [ + migrations.AddField( + model_name="electionkind", + name="nomination_form", + field=models.CharField( + choices=[("board", "PSF Board"), ("packaging_council", "Packaging Council")], + default="board", + help_text="Which nomination form and acknowledgment wording elections of this kind use.", + max_length=32, + ), + ), + migrations.AddField( + model_name="election", + name="hide_previous_service", + field=models.BooleanField( + default=False, + help_text="Hide the 'previous service' field on the nomination form (e.g. an inaugural election with no prior terms).", + ), + ), + migrations.AddField( + model_name="nomination", + name="coc_acknowledged", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="nomination", + name="mission_alignment", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="nomination", + name="eligibility_confirmed", + field=models.BooleanField(default=False), + ), + ] From 2fc0f6a34f468d3296abd45bb6480f5db0accdea Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:25:04 -0500 Subject: [PATCH 07/13] commit ack --- .../nominations/_acknowledgments.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/nominations/templates/nominations/_acknowledgments.html diff --git a/apps/nominations/templates/nominations/_acknowledgments.html b/apps/nominations/templates/nominations/_acknowledgments.html new file mode 100644 index 000000000..a29eea6a7 --- /dev/null +++ b/apps/nominations/templates/nominations/_acknowledgments.html @@ -0,0 +1,20 @@ +{% comment %} +Acknowledgment checkboxes for a nomination create form, kept out of the main +field table so the multi-paragraph legal text reads cleanly. Hidden until +self-nomination is checked (JS in nomination_form.html), but rendered visible +on redisplay so the flow works without JavaScript. +{% endcomment %} +{% if form.acknowledgment_fields %} +
          + Acknowledgments + {% for field in form.acknowledgment_fields %} +
          + {% if field.help_text %}

          {{ field.help_text }}

          {% endif %} + + {% if field.errors %}
          {{ field.errors }}
          {% endif %} +
          + {% endfor %} +
          +{% endif %} From d5c2512689159b4f3f3babbd26393664205f4ba8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:25:24 -0500 Subject: [PATCH 08/13] add lil helpers for testing --- apps/nominations/tests/utils.py | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/nominations/tests/utils.py diff --git a/apps/nominations/tests/utils.py b/apps/nominations/tests/utils.py new file mode 100644 index 000000000..d6591135b --- /dev/null +++ b/apps/nominations/tests/utils.py @@ -0,0 +1,42 @@ +"""Shared helpers for the nominations test suite.""" + +import datetime + +from django.utils import timezone + +from apps.nominations.models import Election, ElectionKind + + +def packaging_council_kind(): + """Return an ElectionKind that uses the Packaging Council nomination form.""" + return ElectionKind.objects.create( + name="Packaging Council", + nomination_form=ElectionKind.NominationFormVariant.PACKAGING_COUNCIL, + ) + + +def open_election(name, kind=None, **extra): + """Create an election whose nomination window is currently 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=1), + nominations_close_at=now + datetime.timedelta(days=1), + **extra, + ) + + +def nomination_payload(**overrides): + """Return a minimally valid nomination POST payload.""" + data = { + "name": "Grace Hopper", + "email": "grace@example.com", + "previous_board_service": "New board member", + "employer": "US Navy", + "other_affiliations": "", + "nomination_statement": "A strong candidate.", + } + data.update(overrides) + return data From a247648f465842b5434ed4b102f7fc9e33e9d7c0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:27:09 -0500 Subject: [PATCH 09/13] update views side with new form and helpers --- apps/nominations/views.py | 65 ++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/nominations/views.py b/apps/nominations/views.py index b5c90b455..31add4bec 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -4,10 +4,17 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.http import Http404 from django.urls import reverse +from django.utils.functional import cached_property from django.views.generic import CreateView, DetailView, ListView, UpdateView -from apps.nominations.forms import NominationAcceptForm, NominationCreateForm, NominationForm -from apps.nominations.models import Election, Nomination, Nominee +from apps.nominations.forms import ( + BoardNominationCreateForm, + NominationAcceptForm, + NominationForm, + PackagingCouncilNominationCreateForm, + PackagingCouncilNominationEditForm, +) +from apps.nominations.models import Election, ElectionKind, Nomination, Nominee from pydotorg.mixins import LoginRequiredMixin @@ -40,10 +47,14 @@ def get_context_data(self, **kwargs): class NominationMixin: """Mixin that injects the current election into the template context.""" + @cached_property + def election(self): + """Return the election named by the URL slug.""" + return Election.objects.select_related("kind").get(slug=self.kwargs["election"]) + def get_context_data(self, **kwargs): """Add the election from the URL slug to the context.""" context = super().get_context_data(**kwargs) - self.election = Election.objects.get(slug=self.kwargs["election"]) context["election"] = self.election return context @@ -55,7 +66,7 @@ class NomineeList(NominationMixin, ListView): def get_queryset(self, *args, **kwargs): """Return visible nominees based on election status and user permissions.""" - election = Election.objects.get(slug=self.kwargs["election"]) + election = self.election if election.nominations_complete or self.request.user.is_superuser: return Nominee.objects.filter(accepted=True, approved=True, election=election).exclude(user=None) @@ -78,8 +89,7 @@ def get(self, request, *args, **kwargs): def get_queryset(self): """Return nominees for the election specified in the URL.""" - election = Election.objects.get(slug=self.kwargs["election"]) - return Nominee.objects.filter(election=election).select_related() + return Nominee.objects.filter(election=self.election).select_related() def get_context_data(self, **kwargs): """Return context data for the nominee detail page.""" @@ -93,15 +103,20 @@ class NominationCreate(LoginRequiredMixin, NominationMixin, CreateView): login_message = "Please login to make a nomination." + form_classes = { + ElectionKind.NominationFormVariant.BOARD: BoardNominationCreateForm, + ElectionKind.NominationFormVariant.PACKAGING_COUNCIL: PackagingCouncilNominationCreateForm, + } + def get_form_kwargs(self): - """Add the request to the form kwargs for self-nomination validation.""" + """Add the request and election to the form kwargs.""" kwargs = super().get_form_kwargs() - kwargs.update({"request": self.request}) + kwargs.update({"request": self.request, "election": self.election}) return kwargs def get_form_class(self): - """Return the form class, raising 404 if nominations are closed or not open.""" - election = Election.objects.get(slug=self.kwargs["election"]) + """Return the form class for the election's kind, 404ing when nominations are not open.""" + election = self.election if election.nominations_complete: messages.error(self.request, f"Nominations for {election.name} Election are closed") msg = f"Nominations for {election.name} Election are closed" @@ -111,7 +126,7 @@ def get_form_class(self): msg = f"Nominations for {election.name} Election are not open" raise Http404(msg) - return NominationCreateForm + return self.form_classes[election.nomination_form_variant] def get_success_url(self): """Return the URL for the newly created nomination detail page.""" @@ -123,7 +138,7 @@ def get_success_url(self): def form_valid(self, form): """Set nominator, election, and handle self-nomination before saving.""" form.instance.nominator = self.request.user - form.instance.election = Election.objects.get(slug=self.kwargs["election"]) + form.instance.election = self.election if form.cleaned_data.get("self_nomination", False): try: nominee = Nominee.objects.get(user=self.request.user, election=form.instance.election) @@ -146,12 +161,30 @@ class NominationEdit(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, U """Edit an existing nomination.""" model = Nomination - form_class = NominationForm + + form_classes = { + ElectionKind.NominationFormVariant.BOARD: NominationForm, + ElectionKind.NominationFormVariant.PACKAGING_COUNCIL: PackagingCouncilNominationEditForm, + } def test_func(self): """Only allow the original nominator to edit.""" return self.request.user == self.get_object().nominator + def get_queryset(self): + """Fetch the nomination with its election and kind in one query.""" + return Nomination.objects.select_related("election__kind") + + def get_form_kwargs(self): + """Pass the nomination's election so the form can theme its fields.""" + kwargs = super().get_form_kwargs() + kwargs["election"] = self.object.election + return kwargs + + def get_form_class(self): + """Return the edit form matching the election's kind (Packaging Council vs Board).""" + return self.form_classes[self.object.election.nomination_form_variant] + def get_success_url(self): """Return the next URL from POST data or the nomination detail page.""" next_url = self.request.POST.get("next") @@ -182,6 +215,10 @@ def test_func(self): """Only allow the nominee to accept.""" return self.request.user == self.get_object().nominee.user + def get_queryset(self): + """Fetch the nomination with the related objects the template renders.""" + return Nomination.objects.select_related("election__kind", "nominee__user", "nominator") + def get_success_url(self): """Return the next URL from POST data or the nomination detail page.""" next_url = self.request.POST.get("next") @@ -216,7 +253,7 @@ def get(self, request, *args, **kwargs): def get_queryset(self): """Return all nominations with related objects.""" - return Nomination.objects.select_related() + return Nomination.objects.select_related("election__kind", "nominee__user", "nominator") def get_context_data(self, **kwargs): """Return context data for the nomination detail page.""" From 7fde0bd263abbb98cc197e7834a963a114997c70 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:27:50 -0500 Subject: [PATCH 10/13] update forms and use new label/help var --- apps/nominations/forms.py | 180 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 7 deletions(-) diff --git a/apps/nominations/forms.py b/apps/nominations/forms.py index f00b46732..ae483ac5c 100644 --- a/apps/nominations/forms.py +++ b/apps/nominations/forms.py @@ -6,10 +6,101 @@ from apps.nominations.models import Nomination +COC_LABEL = mark_safe( + "I agree to adhere to the Python Software Foundation's " + 'Code of Conduct, as well as the specific ' + "guidelines of each platform on which I engage, across PSF and Python " + "community platforms (including but not limited to my nomination statement, " + "Discuss.python.org, and PSF and Python-affiliated spaces) for the duration " + "of this election cycle." +) + +COC_HELP_TEMPLATE = ( + "Candidates are expected and required to maintain a safe and respectful " + "environment on PSF and Python community platforms throughout the {cycle} " + "election cycle. Moderation actions resulting from Code of Conduct violations " + "or platform-specific guidelines are applied consistently to all community " + "members, including {cycle} candidates." +) + +VOTER_CLARITY_LABEL = ( + "I believe my nomination and the positions I advocate as a candidate are " + "aligned with the current mission and bylaws of the Python Software " + "Foundation, a 501(c)(3) nonprofit organization incorporated in the United " + "States." +) + +VOTER_CLARITY_HELP = ( + "Candidates are not required to be in full agreement with the PSF's current " + "mission to stand for the PSF Board election. It is within a nominee's rights " + "to run on a platform that differs from or challenges the PSF's current " + "direction. This statement exists to provide voters with clarity on each " + "candidate's position, so that each voter can make an informed choice. " + "Candidates who do not check this box are equally eligible to stand for the " + "PSF Board election." +) + +PACKAGING_COUNCIL_ELIGIBILITY_LABEL = ( + "I confirm that I am a PSF voting member, that I am not currently employed by " + "the Python Software Foundation as a staff member, and that I am not currently " + "serving on the Python Steering Council." +) + +PACKAGING_COUNCIL_ELIGIBILITY_HELP = mark_safe( + "Packaging Council nominees must be PSF voting members. PSF staff members and " + "currently serving Python Steering Council members are not eligible to stand " + "for election to the Packaging Council, as defined in " + 'PEP 772. If you are not yet ' + "a PSF voting member and would like to run for the Packaging Council, you can " + "become one by signing up as a PSF Supporting Member or self-certifying as a " + "PSF Contributing Member." +) + +PACKAGING_COUNCIL_PREVIOUS_SERVICE_HELP = ( + "Has the person previously served on the Packaging Council? If so, what " + "year(s)? Otherwise, please add 'New Packaging Council member'." +) + +COC_REQUIRED_ERROR = "You must agree to the Code of Conduct acknowledgment to submit a self-nomination." + + +class PackagingCouncilPreviousServiceMixin: + """Relabel the previous-service field for Packaging Council forms. + + Used by both the create and edit forms so the field is presented + consistently. The label comes from ``Election.previous_service_label`` so + forms and read-only pages stay in sync. + """ + + def __init__(self, *args, **kwargs): + """Relabel the previous-service field for the Packaging Council.""" + super().__init__(*args, **kwargs) + field = self.fields.get("previous_board_service") + if field is not None and self.election is not None: + field.label = self.election.previous_service_label + field.help_text = PACKAGING_COUNCIL_PREVIOUS_SERVICE_HELP + class NominationForm(forms.ModelForm): """Base form for editing a board election nomination.""" + #: Acknowledgment fields rendered separately by nomination_form.html. + acknowledgment_field_names = () + + def __init__(self, *args, **kwargs): + """Pull the election off kwargs and apply its form settings.""" + self.election = kwargs.pop("election", None) + super().__init__(*args, **kwargs) + # blank=False is a form-layer check only; the DB column is nullable, so + # omitting the field writes NULL rather than failing to save. + if self.election is not None and self.election.hide_previous_service: + self.fields.pop("previous_board_service", None) + + @property + def acknowledgment_fields(self): + """Return the bound acknowledgment fields for separate template rendering.""" + return [self[name] for name in self.acknowledgment_field_names] + class Meta: """Meta configuration for NominationForm.""" @@ -33,19 +124,27 @@ class Meta: } -class NominationCreateForm(NominationForm): - """Form for creating a new nomination with optional self-nomination.""" +class BaseNominationCreateForm(NominationForm): + """Shared plumbing for the public nomination create forms. - def __init__(self, *args, **kwargs): - """Initialize form and extract the request from kwargs.""" - self.request = kwargs.pop("request", None) - super().__init__(*args, **kwargs) + Subclasses declare the acknowledgment fields (whose wording varies per + election kind) and list them in ``acknowledgment_field_names`` so the + template can render them generically. + """ + + #: Acknowledgments that are mandatory only when self-nominating. + self_nomination_required_acknowledgments = () self_nomination = forms.BooleanField( required=False, - help_text="If you are nominating yourself, we will automatically associate the nomination with your python.org user.", + help_text="If you are nominating yourself, we will automatically associate the nomination with your python.org user account.", ) + def __init__(self, *args, **kwargs): + """Pull the request off kwargs before ModelForm init.""" + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + def clean_self_nomination(self): """Validate that self-nominating users have a first and last name set.""" data = self.cleaned_data["self_nomination"] @@ -58,6 +157,73 @@ def clean_self_nomination(self): return data + def clean(self): + """Require the mandatory acknowledgments only for self-nominations.""" + cleaned_data = super().clean() + if cleaned_data.get("self_nomination"): + for name in self.self_nomination_required_acknowledgments: + if not cleaned_data.get(name): + self.add_error(name, self.fields[name].error_messages["required"]) + else: + # First-person attestations only apply to self-nominations; drop + # any values posted with a third-party nomination. + for name in self.acknowledgment_field_names: + cleaned_data[name] = False + return cleaned_data + + +class BoardNominationCreateForm(BaseNominationCreateForm): + """Public nomination form for PSF Board elections.""" + + acknowledgment_field_names = ("coc_acknowledged", "mission_alignment") + self_nomination_required_acknowledgments = ("coc_acknowledged",) + + coc_acknowledged = forms.BooleanField( + required=False, + label=COC_LABEL, + help_text=COC_HELP_TEMPLATE.format(cycle="PSF Board"), + error_messages={"required": COC_REQUIRED_ERROR}, + ) + mission_alignment = forms.BooleanField( + required=False, + label=VOTER_CLARITY_LABEL, + help_text=VOTER_CLARITY_HELP, + ) + + class Meta(NominationForm.Meta): + """Meta configuration for BoardNominationCreateForm.""" + + fields = (*NominationForm.Meta.fields, "coc_acknowledged", "mission_alignment") + + +class PackagingCouncilNominationCreateForm(PackagingCouncilPreviousServiceMixin, BaseNominationCreateForm): + """Public nomination form for Packaging Council elections.""" + + acknowledgment_field_names = ("coc_acknowledged", "eligibility_confirmed") + self_nomination_required_acknowledgments = ("coc_acknowledged", "eligibility_confirmed") + + coc_acknowledged = forms.BooleanField( + required=False, + label=COC_LABEL, + help_text=COC_HELP_TEMPLATE.format(cycle="Packaging Council"), + error_messages={"required": COC_REQUIRED_ERROR}, + ) + eligibility_confirmed = forms.BooleanField( + required=False, + label=PACKAGING_COUNCIL_ELIGIBILITY_LABEL, + help_text=PACKAGING_COUNCIL_ELIGIBILITY_HELP, + error_messages={"required": "You must confirm your eligibility to submit a Packaging Council self-nomination."}, + ) + + class Meta(NominationForm.Meta): + """Meta configuration for PackagingCouncilNominationCreateForm.""" + + fields = (*NominationForm.Meta.fields, "coc_acknowledged", "eligibility_confirmed") + + +class PackagingCouncilNominationEditForm(PackagingCouncilPreviousServiceMixin, NominationForm): + """Edit form for an existing Packaging Council nomination.""" + class NominationAcceptForm(forms.ModelForm): """Form for a nominee to accept or decline a nomination.""" From 7cdcb8fc6207af4c111f2aeb93be6e1086987d58 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 14:28:31 -0500 Subject: [PATCH 11/13] add form and view tests for nomination form variants Co-Authored-By: Codex Co-Authored-By: Claude Fable 5 --- apps/nominations/tests/test_forms.py | 128 +++++++++++++++++++++++++++ apps/nominations/tests/test_views.py | 115 +++++++++++++++++++++++- 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 apps/nominations/tests/test_forms.py diff --git a/apps/nominations/tests/test_forms.py b/apps/nominations/tests/test_forms.py new file mode 100644 index 000000000..f17643e0c --- /dev/null +++ b/apps/nominations/tests/test_forms.py @@ -0,0 +1,128 @@ +import datetime + +from django.test import RequestFactory, TestCase + +from apps.nominations.forms import ( + BoardNominationCreateForm, + PackagingCouncilNominationCreateForm, + PackagingCouncilNominationEditForm, +) +from apps.nominations.models import Election +from apps.nominations.tests.utils import nomination_payload, packaging_council_kind +from apps.users.factories import UserFactory + + +class BoardNominationCreateFormTests(TestCase): + def setUp(self): + self.election = Election.objects.create(name="2026 Board Election", date=datetime.date(2026, 12, 1)) + self.request = RequestFactory().get("/") + self.request.user = UserFactory(first_name="Grace", last_name="Hopper") + + def _form(self, data): + return BoardNominationCreateForm(data=data, request=self.request, election=self.election) + + def test_third_party_nomination_does_not_require_coc(self): + form = self._form(nomination_payload()) + self.assertTrue(form.is_valid(), form.errors) + + def test_third_party_nomination_ignores_posted_acknowledgments(self): + form = self._form(nomination_payload(coc_acknowledged="on", mission_alignment="on")) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.cleaned_data["coc_acknowledged"]) + self.assertFalse(form.cleaned_data["mission_alignment"]) + + def test_self_nomination_requires_coc(self): + form = self._form(nomination_payload(self_nomination="on")) + self.assertFalse(form.is_valid()) + self.assertIn("coc_acknowledged", form.errors) + + def test_self_nomination_valid_with_coc(self): + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on")) + self.assertTrue(form.is_valid(), form.errors) + + def test_mission_alignment_is_optional(self): + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on")) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.cleaned_data["mission_alignment"]) + + def test_mission_alignment_captured_when_checked(self): + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on", mission_alignment="on")) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.cleaned_data["mission_alignment"]) + + def test_has_no_eligibility_field(self): + form = self._form(nomination_payload()) + self.assertNotIn("eligibility_confirmed", form.fields) + self.assertEqual(form.acknowledgment_field_names, ("coc_acknowledged", "mission_alignment")) + + +class PackagingCouncilNominationCreateFormTests(TestCase): + def setUp(self): + self.kind = packaging_council_kind() + self.election = Election.objects.create( + name="2026 Packaging Council Election", + date=datetime.date(2026, 12, 1), + kind=self.kind, + ) + self.request = RequestFactory().get("/") + self.request.user = UserFactory(first_name="Grace", last_name="Hopper") + + def _form(self, data, election=None): + return PackagingCouncilNominationCreateForm(data=data, request=self.request, election=election or self.election) + + def test_third_party_nomination_does_not_require_acknowledgments(self): + form = self._form(nomination_payload()) + self.assertTrue(form.is_valid(), form.errors) + + def test_self_nomination_requires_both_acknowledgments(self): + form = self._form(nomination_payload(self_nomination="on")) + self.assertFalse(form.is_valid()) + self.assertIn("coc_acknowledged", form.errors) + self.assertIn("eligibility_confirmed", form.errors) + + def test_self_nomination_missing_eligibility(self): + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on")) + self.assertFalse(form.is_valid()) + self.assertIn("eligibility_confirmed", form.errors) + + def test_self_nomination_valid_with_both(self): + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on", eligibility_confirmed="on")) + self.assertTrue(form.is_valid(), form.errors) + + def test_previous_service_relabeled(self): + form = self._form(nomination_payload()) + self.assertEqual(form.fields["previous_board_service"].label, "Previous Packaging Council Service") + + def test_hide_previous_service_removes_field(self): + election = Election.objects.create( + name="Inaugural Packaging Council Election", + date=datetime.date(2026, 12, 1), + kind=self.kind, + hide_previous_service=True, + ) + data = nomination_payload() + del data["previous_board_service"] + form = self._form(data, election=election) + self.assertNotIn("previous_board_service", form.fields) + self.assertTrue(form.is_valid(), form.errors) + + +class PackagingCouncilNominationEditFormTests(TestCase): + def setUp(self): + self.kind = packaging_council_kind() + + def _election(self, **extra): + return Election.objects.create( + name="Packaging Council Election", + date=datetime.date(2026, 12, 1), + kind=self.kind, + **extra, + ) + + def test_relabels_previous_service(self): + form = PackagingCouncilNominationEditForm(election=self._election()) + self.assertEqual(form.fields["previous_board_service"].label, "Previous Packaging Council Service") + + def test_hides_previous_service_when_opted_out(self): + form = PackagingCouncilNominationEditForm(election=self._election(hide_previous_service=True)) + self.assertNotIn("previous_board_service", form.fields) diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index d07b096ea..b339b5661 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -3,7 +3,9 @@ from django.test import TestCase from django.urls import reverse -from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind +from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind, Nomination +from apps.nominations.tests.utils import nomination_payload, open_election, packaging_council_kind +from apps.users.factories import UserFactory class ElectionDetailThemeTests(TestCase): @@ -28,3 +30,114 @@ def test_detail_falls_back_to_default_accent_without_kind(self): self.assertEqual(response.status_code, 200) self.assertContains(response, f"--election-accent: {DEFAULT_ACCENT_COLOR}") + + +class NominationCreateFormSelectionTests(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def _create_url(self, election): + return reverse("nominations:nomination_create", kwargs={"election": election.slug}) + + def test_board_election_renders_board_form(self): + election = open_election("2026 Board Election") + response = self.client.get(self._create_url(election)) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "aligned with the current mission and bylaws") + self.assertNotContains(response, "Previous Packaging Council Service") + # Acknowledgments start hidden and are revealed when self-nomination is checked. + self.assertContains(response, 'id="nomination-acknowledgments" hidden') + + def test_packaging_council_election_renders_pc_form(self): + election = open_election("2026 Packaging Council Election", kind=packaging_council_kind()) + response = self.client.get(self._create_url(election)) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Previous Packaging Council Service") + self.assertContains(response, "not currently serving on the Python Steering Council") + + def test_inaugural_pc_election_hides_previous_service(self): + election = open_election( + "Inaugural Packaging Council Election", kind=packaging_council_kind(), hide_previous_service=True + ) + response = self.client.get(self._create_url(election)) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "Previous Packaging Council Service") + + +class NominationCreatePersistenceTests(TestCase): + def setUp(self): + self.user = UserFactory(first_name="Grace", last_name="Hopper") + self.client.force_login(self.user) + + def _create_url(self, election): + return reverse("nominations:nomination_create", kwargs={"election": election.slug}) + + def test_third_party_nomination_needs_no_acknowledgments(self): + election = open_election("2026 Board Election") + response = self.client.post(self._create_url(election), nomination_payload()) + self.assertEqual(response.status_code, 302) + nomination = Nomination.objects.get(election=election) + self.assertFalse(nomination.coc_acknowledged) + + def test_self_nomination_requires_coc(self): + election = open_election("2026 Board Election") + response = self.client.post(self._create_url(election), nomination_payload(self_nomination="on")) + self.assertEqual(response.status_code, 200) + self.assertFalse(Nomination.objects.filter(election=election).exists()) + + def test_self_nomination_persists_board_acknowledgments(self): + election = open_election("2026 Board Election") + response = self.client.post( + self._create_url(election), + nomination_payload(self_nomination="on", coc_acknowledged="on", mission_alignment="on"), + ) + self.assertEqual(response.status_code, 302) + nomination = Nomination.objects.get(election=election) + self.assertTrue(nomination.coc_acknowledged) + self.assertTrue(nomination.mission_alignment) + + def test_packaging_council_self_nomination_requires_eligibility(self): + election = open_election("2026 Packaging Council Election", kind=packaging_council_kind()) + response = self.client.post( + self._create_url(election), + nomination_payload(self_nomination="on", coc_acknowledged="on"), + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(Nomination.objects.filter(election=election).exists()) + + response = self.client.post( + self._create_url(election), + nomination_payload(self_nomination="on", coc_acknowledged="on", eligibility_confirmed="on"), + ) + self.assertEqual(response.status_code, 302) + nomination = Nomination.objects.get(election=election) + self.assertTrue(nomination.coc_acknowledged) + self.assertTrue(nomination.eligibility_confirmed) + + +class NominationEditVariantTests(TestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + + def test_inaugural_pc_edit_hides_and_relabels_previous_service(self): + election = open_election( + "Inaugural Packaging Council Election", kind=packaging_council_kind(), hide_previous_service=True + ) + nomination = Nomination.objects.create( + election=election, + nominator=self.user, + name="Grace Hopper", + email="grace@example.com", + nomination_statement="A strong candidate.", + ) + url = reverse( + "nominations:nomination_edit", + kwargs={"election": election.slug, "pk": nomination.pk}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + # Field is hidden for the inaugural election, so neither label appears. + self.assertNotContains(response, "Previous Packaging Council Service") + self.assertNotContains(response, "Previous Board Service") From 5a857637b95053ca7bb68caef1419b453b499476 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 17:17:24 -0500 Subject: [PATCH 12/13] updates --- apps/nominations/forms.py | 102 ++++++++++++------ .../nominations/nomination_form.html | 53 ++++++++- apps/nominations/urls.py | 5 + apps/nominations/views.py | 30 ++++-- 4 files changed, 147 insertions(+), 43 deletions(-) diff --git a/apps/nominations/forms.py b/apps/nominations/forms.py index ae483ac5c..25c5acd50 100644 --- a/apps/nominations/forms.py +++ b/apps/nominations/forms.py @@ -1,10 +1,13 @@ """Forms for creating and managing board election nominations.""" +import re + from django import forms +from django.utils import timezone from django.utils.safestring import mark_safe from markupfield.widgets import MarkupTextarea -from apps.nominations.models import Nomination +from apps.nominations.models import ElectionKind, Nomination COC_LABEL = mark_safe( "I agree to adhere to the Python Software Foundation's " @@ -56,29 +59,19 @@ "PSF Contributing Member." ) -PACKAGING_COUNCIL_PREVIOUS_SERVICE_HELP = ( - "Has the person previously served on the Packaging Council? If so, what " - "year(s)? Otherwise, please add 'New Packaging Council member'." -) - COC_REQUIRED_ERROR = "You must agree to the Code of Conduct acknowledgment to submit a self-nomination." +#: Earliest selectable service year per nomination form variant. +PREVIOUS_SERVICE_FIRST_YEAR = { + ElectionKind.NominationFormVariant.BOARD: 2001, + ElectionKind.NominationFormVariant.PACKAGING_COUNCIL: 2025, +} -class PackagingCouncilPreviousServiceMixin: - """Relabel the previous-service field for Packaging Council forms. - - Used by both the create and edit forms so the field is presented - consistently. The label comes from ``Election.previous_service_label`` so - forms and read-only pages stay in sync. - """ - - def __init__(self, *args, **kwargs): - """Relabel the previous-service field for the Packaging Council.""" - super().__init__(*args, **kwargs) - field = self.fields.get("previous_board_service") - if field is not None and self.election is not None: - field.label = self.election.previous_service_label - field.help_text = PACKAGING_COUNCIL_PREVIOUS_SERVICE_HELP +#: Canonical stored value for candidates with no previous service. +NEW_MEMBER_LABEL = { + ElectionKind.NominationFormVariant.BOARD: "New board member", + ElectionKind.NominationFormVariant.PACKAGING_COUNCIL: "New Packaging Council member", +} class NominationForm(forms.ModelForm): @@ -90,11 +83,63 @@ class NominationForm(forms.ModelForm): def __init__(self, *args, **kwargs): """Pull the election off kwargs and apply its form settings.""" self.election = kwargs.pop("election", None) + self.variant = ( + self.election.nomination_form_variant if self.election else ElectionKind.NominationFormVariant.BOARD + ) super().__init__(*args, **kwargs) - # blank=False is a form-layer check only; the DB column is nullable, so - # omitting the field writes NULL rather than failing to save. - if self.election is not None and self.election.hide_previous_service: - self.fields.pop("previous_board_service", None) + # The free-text model field is replaced by the structured yes/no + + # year-picker pair below; its column is nullable, so an election with + # hide_previous_service simply writes NULL. + del self.fields["previous_board_service"] + if not (self.election is not None and self.election.hide_previous_service): + self._add_previous_service_fields() + + def _add_previous_service_fields(self): + """Add the strict yes/no + year-picker pair for previous service.""" + label = self.election.previous_service_label if self.election else "Previous Board Service" + years = [str(y) for y in range(timezone.now().year, PREVIOUS_SERVICE_FIRST_YEAR[self.variant] - 1, -1)] + self.fields["previous_service"] = forms.ChoiceField( + label=label, + choices=(("no", "No — has not served before"), ("yes", "Yes — has served previously")), + widget=forms.RadioSelect, + ) + self.fields["previous_service_years"] = forms.MultipleChoiceField( + label="Year(s) served", + choices=[(y, y) for y in years], + widget=forms.CheckboxSelectMultiple, + required=False, + ) + order = [] + for name in self.fields: + if name == "email": + order += [name, "previous_service", "previous_service_years"] + elif name not in ("previous_service", "previous_service_years"): + order.append(name) + self.order_fields(order) + # Best-effort prefill from the stored free-text value when editing. + stored = self.instance.previous_board_service or "" + stored_years = [y for y in re.findall(r"\b(?:19|20)\d{2}\b", stored) if y in years] + if stored_years: + self.fields["previous_service"].initial = "yes" + self.fields["previous_service_years"].initial = stored_years + elif stored: + self.fields["previous_service"].initial = "no" + + def clean(self): + """Compose the structured previous-service answer into the model field.""" + cleaned_data = super().clean() + answer = cleaned_data.get("previous_service") + if answer == "yes" and not cleaned_data.get("previous_service_years"): + self.add_error("previous_service_years", "Select the year(s) served.") + elif answer: + value = ( + ", ".join(sorted(cleaned_data["previous_service_years"])) + if answer == "yes" + else NEW_MEMBER_LABEL[self.variant] + ) + # The model field isn't on the form, so set the instance directly. + self.instance.previous_board_service = value + return cleaned_data @property def acknowledgment_fields(self): @@ -117,7 +162,6 @@ class Meta: help_texts = { "name": "Name of the person you are nominating.", "email": "Email address for the person you are nominating.", - "previous_board_service": "Has the person previously served on the PSF Board? If so what year(s)? Otherwise 'New board member'.", "employer": "Nominee's current employer.", "other_affiliations": "Any other relevant affiliations the Nominee has.", "nomination_statement": "Markdown syntax supported.", @@ -196,7 +240,7 @@ class Meta(NominationForm.Meta): fields = (*NominationForm.Meta.fields, "coc_acknowledged", "mission_alignment") -class PackagingCouncilNominationCreateForm(PackagingCouncilPreviousServiceMixin, BaseNominationCreateForm): +class PackagingCouncilNominationCreateForm(BaseNominationCreateForm): """Public nomination form for Packaging Council elections.""" acknowledgment_field_names = ("coc_acknowledged", "eligibility_confirmed") @@ -221,10 +265,6 @@ class Meta(NominationForm.Meta): fields = (*NominationForm.Meta.fields, "coc_acknowledged", "eligibility_confirmed") -class PackagingCouncilNominationEditForm(PackagingCouncilPreviousServiceMixin, NominationForm): - """Edit form for an existing Packaging Council nomination.""" - - class NominationAcceptForm(forms.ModelForm): """Form for a nominee to accept or decline a nomination.""" diff --git a/apps/nominations/templates/nominations/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index 7d71e61a0..c6ecb535f 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -25,6 +25,33 @@ toggle.addEventListener('change', sync); sync(); })(); + // Reveal the year picker only when previous service is answered "yes". + (function () { + var yearsRow = document.getElementById('row_previous_service_years'); + if (!yearsRow) { return; } + var radios = document.querySelectorAll('input[name="previous_service"]'); + function sync() { + var checked = document.querySelector('input[name="previous_service"]:checked'); + yearsRow.hidden = !(checked && checked.value === 'yes'); + } + radios.forEach(function (radio) { radio.addEventListener('change', sync); }); + sync(); + })(); + // Server-rendered markdown preview for the nomination statement. + (function () { + var button = document.getElementById('preview-statement'); + var target = document.getElementById('statement-preview'); + var textarea = document.getElementById('id_nomination_statement'); + if (!button || !target || !textarea) { return; } + button.addEventListener('click', function () { + var body = new FormData(); + body.append('text', textarea.value); + body.append('csrfmiddlewaretoken', document.querySelector('input[name="csrfmiddlewaretoken"]').value); + fetch('{% url "nominations:nomination_preview" %}', { method: 'POST', body: body }) + .then(function (response) { return response.json(); }) + .then(function (data) { target.innerHTML = data.html; target.hidden = false; }); + }); + })(); {% endblock %} @@ -43,13 +70,37 @@

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

          {% csrf_token %} {% if next %}{% endif %} {{ form.non_field_errors }} + {% for field in form %} {% if field.name not in form.acknowledgment_field_names %} - + diff --git a/apps/nominations/urls.py b/apps/nominations/urls.py index c10c7a646..08abcacc7 100644 --- a/apps/nominations/urls.py +++ b/apps/nominations/urls.py @@ -18,6 +18,11 @@ views.NomineeDetail.as_view(), name="nominee_detail", ), + path( + "statement-preview/", + views.NominationStatementPreview.as_view(), + name="nomination_preview", + ), path( "/create/", views.NominationCreate.as_view(), diff --git a/apps/nominations/views.py b/apps/nominations/views.py index 31add4bec..0970807cb 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -2,17 +2,19 @@ from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin -from django.http import Http404 +from django.http import Http404, JsonResponse from django.urls import reverse from django.utils.functional import cached_property +from django.utils.html import escape +from django.views import View from django.views.generic import CreateView, DetailView, ListView, UpdateView +from markupfield.markup import DEFAULT_MARKUP_TYPES from apps.nominations.forms import ( BoardNominationCreateForm, NominationAcceptForm, NominationForm, PackagingCouncilNominationCreateForm, - PackagingCouncilNominationEditForm, ) from apps.nominations.models import Election, ElectionKind, Nomination, Nominee from pydotorg.mixins import LoginRequiredMixin @@ -161,11 +163,9 @@ class NominationEdit(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, U """Edit an existing nomination.""" model = Nomination - - form_classes = { - ElectionKind.NominationFormVariant.BOARD: NominationForm, - ElectionKind.NominationFormVariant.PACKAGING_COUNCIL: PackagingCouncilNominationEditForm, - } + form_class = NominationForm + # Give authenticated non-owners a 403 instead of a login redirect loop. + raise_exception = True def test_func(self): """Only allow the original nominator to edit.""" @@ -181,10 +181,6 @@ def get_form_kwargs(self): kwargs["election"] = self.object.election return kwargs - def get_form_class(self): - """Return the edit form matching the election's kind (Packaging Council vs Board).""" - return self.form_classes[self.object.election.nomination_form_variant] - def get_success_url(self): """Return the next URL from POST data or the nomination detail page.""" next_url = self.request.POST.get("next") @@ -210,6 +206,8 @@ class NominationAccept(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, model = Nomination form_class = NominationAcceptForm template_name_suffix = "_accept_form" + # Give authenticated non-owners a 403 instead of a login redirect loop. + raise_exception = True def test_func(self): """Only allow the nominee to accept.""" @@ -238,6 +236,16 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) +class NominationStatementPreview(LoginRequiredMixin, View): + """Render a nomination statement preview using the model field's own pipeline.""" + + def post(self, request): + """Return the statement rendered exactly as it will be stored.""" + render_markdown = next(m[1] for m in DEFAULT_MARKUP_TYPES if m[0] == "markdown") + rendered = render_markdown(escape(request.POST.get("text", ""))) + return JsonResponse({"html": rendered}) + + class NominationView(DetailView): """Display details for a single nomination.""" From a54894f790d4d1ec8145427db7f28e0408dd3008 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 24 Jul 2026 17:17:51 -0500 Subject: [PATCH 13/13] update tests for structured previous service and statement preview Co-Authored-By: Codex Co-Authored-By: Claude Fable 5 --- apps/nominations/tests/test_forms.py | 63 +++++++++++++++++++++++----- apps/nominations/tests/test_views.py | 35 ++++++++++++++++ apps/nominations/tests/utils.py | 2 +- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/nominations/tests/test_forms.py b/apps/nominations/tests/test_forms.py index f17643e0c..f53378a28 100644 --- a/apps/nominations/tests/test_forms.py +++ b/apps/nominations/tests/test_forms.py @@ -4,10 +4,10 @@ from apps.nominations.forms import ( BoardNominationCreateForm, + NominationForm, PackagingCouncilNominationCreateForm, - PackagingCouncilNominationEditForm, ) -from apps.nominations.models import Election +from apps.nominations.models import Election, Nomination from apps.nominations.tests.utils import nomination_payload, packaging_council_kind from apps.users.factories import UserFactory @@ -55,6 +55,26 @@ def test_has_no_eligibility_field(self): self.assertNotIn("eligibility_confirmed", form.fields) self.assertEqual(form.acknowledgment_field_names, ("coc_acknowledged", "mission_alignment")) + def test_no_previous_service_stores_new_member(self): + form = self._form(nomination_payload()) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.previous_board_service, "New board member") + + def test_previous_service_yes_requires_years(self): + form = self._form(nomination_payload(previous_service="yes")) + self.assertFalse(form.is_valid()) + self.assertIn("previous_service_years", form.errors) + + def test_previous_service_years_compose_stored_value(self): + form = self._form(nomination_payload(previous_service="yes", previous_service_years=["2021", "2019"])) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.previous_board_service, "2019, 2021") + + def test_previous_service_years_reject_invalid_year(self): + form = self._form(nomination_payload(previous_service="yes", previous_service_years=["1999"])) + self.assertFalse(form.is_valid()) + self.assertIn("previous_service_years", form.errors) + class PackagingCouncilNominationCreateFormTests(TestCase): def setUp(self): @@ -91,7 +111,18 @@ def test_self_nomination_valid_with_both(self): def test_previous_service_relabeled(self): form = self._form(nomination_payload()) - self.assertEqual(form.fields["previous_board_service"].label, "Previous Packaging Council Service") + self.assertEqual(form.fields["previous_service"].label, "Previous Packaging Council Service") + + def test_no_previous_service_stores_pc_new_member(self): + form = self._form(nomination_payload()) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.instance.previous_board_service, "New Packaging Council member") + + def test_pc_years_start_at_2025(self): + form = self._form(nomination_payload()) + years = [choice for choice, _ in form.fields["previous_service_years"].choices] + self.assertNotIn("2024", years) + self.assertIn("2025", years) def test_hide_previous_service_removes_field(self): election = Election.objects.create( @@ -101,13 +132,14 @@ def test_hide_previous_service_removes_field(self): hide_previous_service=True, ) data = nomination_payload() - del data["previous_board_service"] + del data["previous_service"] form = self._form(data, election=election) - self.assertNotIn("previous_board_service", form.fields) + self.assertNotIn("previous_service", form.fields) + self.assertNotIn("previous_service_years", form.fields) self.assertTrue(form.is_valid(), form.errors) -class PackagingCouncilNominationEditFormTests(TestCase): +class NominationEditFormTests(TestCase): def setUp(self): self.kind = packaging_council_kind() @@ -120,9 +152,20 @@ def _election(self, **extra): ) def test_relabels_previous_service(self): - form = PackagingCouncilNominationEditForm(election=self._election()) - self.assertEqual(form.fields["previous_board_service"].label, "Previous Packaging Council Service") + form = NominationForm(election=self._election()) + self.assertEqual(form.fields["previous_service"].label, "Previous Packaging Council Service") def test_hides_previous_service_when_opted_out(self): - form = PackagingCouncilNominationEditForm(election=self._election(hide_previous_service=True)) - self.assertNotIn("previous_board_service", form.fields) + form = NominationForm(election=self._election(hide_previous_service=True)) + self.assertNotIn("previous_service", form.fields) + + def test_prefills_years_from_stored_free_text(self): + nomination = Nomination(previous_board_service="Served 2025 and 2026") + form = NominationForm(instance=nomination, election=self._election()) + self.assertEqual(form.fields["previous_service"].initial, "yes") + self.assertEqual(form.fields["previous_service_years"].initial, ["2025", "2026"]) + + 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") diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index b339b5661..dd2aceb2c 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -116,6 +116,41 @@ def test_packaging_council_self_nomination_requires_eligibility(self): self.assertTrue(nomination.eligibility_confirmed) +class NominationStatementPreviewTests(TestCase): + def test_renders_markdown_with_html_escaped(self): + self.client.force_login(UserFactory()) + response = self.client.post( + reverse("nominations:nomination_preview"), + {"text": "**bold** "}, + ) + self.assertEqual(response.status_code, 200) + html = response.json()["html"] + self.assertIn("bold", html) + self.assertNotIn("
          {{ field.label_tag }} {{ field }} + {% if field.name == "nomination_statement" %} +

          + + {% endif %} {% if field.help_text %}
          {{ field.help_text }}{% endif %} {{ field.errors }}