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 %} +
+{% 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 @@