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.""" diff --git a/apps/nominations/forms.py b/apps/nominations/forms.py index f00b46732..25c5acd50 100644 --- a/apps/nominations/forms.py +++ b/apps/nominations/forms.py @@ -1,15 +1,151 @@ """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 " + '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." +) + +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, +} + +#: 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): """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) + self.variant = ( + self.election.nomination_form_variant if self.election else ElectionKind.NominationFormVariant.BOARD + ) + super().__init__(*args, **kwargs) + # 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): + """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.""" @@ -26,26 +162,33 @@ 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.", } -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 +201,69 @@ 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(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 NominationAcceptForm(forms.ModelForm): """Form for a nominee to accept or decline a nomination.""" 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), + ), + ] 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}>" 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 %} 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 }}
  • 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/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index f258aa3ad..c6ecb535f 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -14,6 +14,47 @@ {% block left_sidebar %}{% endblock %} {% block content_attributes %}{% endblock %} +{% block extra_js %} + +{% endblock %} + {% block content %}
    @@ -28,9 +69,46 @@

    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.name == "nomination_statement" %} +

    + + {% endif %} + {% if field.help_text %}
    {{ field.help_text }}{% endif %} + {{ field.errors }} +
    + {% include "nominations/_acknowledgments.html" %}
    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 %} diff --git a/apps/nominations/tests/test_forms.py b/apps/nominations/tests/test_forms.py new file mode 100644 index 000000000..f53378a28 --- /dev/null +++ b/apps/nominations/tests/test_forms.py @@ -0,0 +1,171 @@ +import datetime + +from django.test import RequestFactory, TestCase + +from apps.nominations.forms import ( + BoardNominationCreateForm, + NominationForm, + PackagingCouncilNominationCreateForm, +) +from apps.nominations.models import Election, Nomination +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")) + + 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): + 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_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( + name="Inaugural Packaging Council Election", + date=datetime.date(2026, 12, 1), + kind=self.kind, + hide_previous_service=True, + ) + data = nomination_payload() + del data["previous_service"] + form = self._form(data, election=election) + self.assertNotIn("previous_service", form.fields) + self.assertNotIn("previous_service_years", form.fields) + self.assertTrue(form.is_valid(), form.errors) + + +class NominationEditFormTests(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 = 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 = 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 d07b096ea..dd2aceb2c 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,149 @@ 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 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("