From d5f8017a2e063f7c70f78f3d7ffa038c59307917 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 7 Oct 2026 22:30:30 -0500 Subject: [PATCH 1/7] Document sponsor use cases and make the base class abstract Port of #1928 onto current main. BaseUseCaseWithNotifications becomes an ABC with an abstract execute(), the use case docstrings gain the extra context from the original PR, and the admin docs describe how the sponsors app is operated. The django-extensions dependency and the generated schema image are dropped. Co-authored-by: Bernardo Fontes --- apps/sponsors/use_cases.py | 36 ++++++++++++++++++++++++++++------ docs/source/administration.rst | 5 +++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/sponsors/use_cases.py b/apps/sponsors/use_cases.py index 3e1e48c27..a81bb24fc 100644 --- a/apps/sponsors/use_cases.py +++ b/apps/sponsors/use_cases.py @@ -1,4 +1,10 @@ -"""Use case classes orchestrating sponsorship business logic with notifications.""" +"""Use case classes orchestrating sponsorship business logic with notifications. + +Use cases (UCs) are an indirection layer that triggers sponsorship business rules, +keeping core logic and state management out of the views. +""" + +from abc import ABC, abstractmethod from django.db import transaction @@ -14,8 +20,11 @@ ) -class BaseUseCaseWithNotifications: - """Base class providing notification dispatch for use case implementations.""" +class BaseUseCaseWithNotifications(ABC): + """Abstract base class for use cases. + + Holds the list of notifications the use case dispatches when needed. + """ notifications = [] @@ -30,9 +39,16 @@ def notify(self, **kwargs): @classmethod def build(cls): - """Construct the use case with its default notification list.""" + """Construct the use case with its default notification list. + + Factory method; override to handle complex setup or dependency injection. + """ return cls(cls.notifications) + @abstractmethod + def execute(self, *args, **kwargs): + """Run the use case's business rules.""" + class CreateSponsorshipApplicationUseCase(BaseUseCaseWithNotifications): """Create a new sponsorship application and notify stakeholders.""" @@ -118,7 +134,11 @@ def execute(self, contract, **kwargs): class ExecuteExistingContractUseCase(BaseUseCaseWithNotifications): - """Execute a contract with an already-signed document file.""" + """Execute a contract with an already-signed document file. + + Lets PSF staff upload contracts that weren't generated by the sponsors app. + Expected to become legacy once all contracts are created through the app. + """ notifications = [ notifications.ExecutedExistingContractLogger(), @@ -146,7 +166,11 @@ def execute(self, contract, contract_file, **kwargs): class ExecuteContractUseCase(ExecuteExistingContractUseCase): - """Execute a contract that was previously sent for signature.""" + """Execute a contract that was previously sent for signature. + + PSF staff upload the signed contract; this flags the contract as executed + and the corresponding sponsorship as finalized. + """ notifications = [ notifications.ExecutedContractLogger(), diff --git a/docs/source/administration.rst b/docs/source/administration.rst index 99252509b..bebe9c66a 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -106,6 +106,11 @@ they represent: :contract.py: The `Contract` model which is used to generate the final contract document and other support models; +The sponsors app is mostly an administrative one. The only part regular users interact with is +the sponsorship application form, available at ``/sponsors/application/new/``. Every other +operation should be done via the admin action buttons on the ``Sponsorship`` and ``Contract`` +models. + Agreements ---------- From dafd575cc739f38eb94754ae4831220c4b426753 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 7 Oct 2026 22:36:58 -0500 Subject: [PATCH 2/7] Correct stale sponsor use case and user-flow docs --- apps/sponsors/use_cases.py | 7 ++++--- docs/source/administration.rst | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/sponsors/use_cases.py b/apps/sponsors/use_cases.py index a81bb24fc..9cd507b1b 100644 --- a/apps/sponsors/use_cases.py +++ b/apps/sponsors/use_cases.py @@ -134,10 +134,11 @@ def execute(self, contract, **kwargs): class ExecuteExistingContractUseCase(BaseUseCaseWithNotifications): - """Execute a contract with an already-signed document file. + """Execute a contract using a document the sponsor signed outside the app. - Lets PSF staff upload contracts that weren't generated by the sponsors app. - Expected to become legacy once all contracts are created through the app. + Backs the admin "approve signed sponsorship" action: the contract is created on + approval and executed immediately, bypassing the usual status checks. Any other + active sponsorship of the same sponsor is marked as overlapped by this one. """ notifications = [ diff --git a/docs/source/administration.rst b/docs/source/administration.rst index bebe9c66a..a79d82f31 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -106,10 +106,12 @@ they represent: :contract.py: The `Contract` model which is used to generate the final contract document and other support models; -The sponsors app is mostly an administrative one. The only part regular users interact with is -the sponsorship application form, available at ``/sponsors/application/new/``. Every other -operation should be done via the admin action buttons on the ``Sponsorship`` and ``Contract`` -models. +Regular users interact with the sponsors app in two places: the application flow +(benefit selection at ``/sponsors/application/``, then the form at +``/sponsors/application/new/``) and the sponsorship dashboard at ``/users/sponsorships/``, +where sponsors view their sponsorships, update sponsor information, and upload required +assets. Everything else is done through the admin action buttons on the ``Sponsorship`` +and ``Contract`` models. Agreements ---------- From 2683bd1d32c0bc6dfc3a4b435f634cadea1d8797 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 10:29:38 -0500 Subject: [PATCH 3/7] show support stmt guideance for self noms --- .../nominations/_supporting_statements.html | 12 ++++++++++++ .../templates/nominations/nomination_detail.html | 3 +++ 2 files changed, 15 insertions(+) create mode 100644 apps/nominations/templates/nominations/_supporting_statements.html diff --git a/apps/nominations/templates/nominations/_supporting_statements.html b/apps/nominations/templates/nominations/_supporting_statements.html new file mode 100644 index 000000000..f78fab93f --- /dev/null +++ b/apps/nominations/templates/nominations/_supporting_statements.html @@ -0,0 +1,12 @@ +{% url 'nominations:nomination_create' election=nomination.election.slug as nomination_create_url %} +
+

Getting supporting statements

+

Other people can support your candidacy by submitting their own nomination for you. Share this link with them:

+

{{ request.scheme }}://{{ request.get_host }}{{ nomination_create_url }}

+
    +
  • They need to sign in with their own python.org account.
  • +
  • Ask them to leave “self nomination” unchecked and enter your name and {{ request.user.email }}, the email address on your python.org account, so their nomination can be matched to you.
  • +
  • Once a supporting nomination is linked to you, accept it from your nominations. Only accepted nominations are published.
  • +
  • Supporting nominations must be submitted before nominations close.
  • +
+
diff --git a/apps/nominations/templates/nominations/nomination_detail.html b/apps/nominations/templates/nominations/nomination_detail.html index 24e629494..0f68f1fb2 100644 --- a/apps/nominations/templates/nominations/nomination_detail.html +++ b/apps/nominations/templates/nominations/nomination_detail.html @@ -32,6 +32,9 @@

Nomination for {{ nomination.name }} in {{ nomination.ele It is no longer editable as nominations are closed. {% endif %} + {% if nomination.nominator == request.user and nomination.election.nominations_open %} + {% include "nominations/_supporting_statements.html" %} + {% endif %} {% elif nomination.nominator == request.user %}
You made this nomination. From fcdc7deae88c1c051d8f00123be09e774496b8be Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 10:30:40 -0500 Subject: [PATCH 4/7] add self nom next steps page --- .../nominations/nomination_submitted.html | 36 +++++++++++++++++++ apps/nominations/urls.py | 5 +++ apps/nominations/views.py | 23 ++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 apps/nominations/templates/nominations/nomination_submitted.html diff --git a/apps/nominations/templates/nominations/nomination_submitted.html b/apps/nominations/templates/nominations/nomination_submitted.html new file mode 100644 index 000000000..3ea4b95bd --- /dev/null +++ b/apps/nominations/templates/nominations/nomination_submitted.html @@ -0,0 +1,36 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Self-nomination received for {{ nomination.election.name }} Election +| {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations nominations_view"{% endblock %} +{% block head %}{% include "nominations/_theme.html" with election=nomination.election %}{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Your self-nomination for the {{ nomination.election.name }} Election has been received

+
+ +

What you need to know

+
    +
  • Your nomination is already marked as accepted. You don't need to accept it again.
  • +
  • You can edit it until nominations close{% if nomination.election.nominations_close_at %} on {{ nomination.election.nominations_close_at|date:"F j, Y, P T" }}{% endif %}.
  • +
  • PSF staff will review your nomination before it is published.
  • +
  • Approved nominees are listed publicly once nominations close.
  • +
+

+ View your nomination + {% if nomination.election.nominations_open %}| Edit it{% endif %} + | See all your nominations +

+ + {% if nomination.election.nominations_open %} + {% include "nominations/_supporting_statements.html" %} + {% endif %} +
+{% endblock content %} diff --git a/apps/nominations/urls.py b/apps/nominations/urls.py index 08abcacc7..f120dd2a4 100644 --- a/apps/nominations/urls.py +++ b/apps/nominations/urls.py @@ -33,6 +33,11 @@ views.NominationView.as_view(), name="nomination_detail", ), + path( + "//submitted/", + views.NominationSubmitted.as_view(), + name="nomination_submitted", + ), path( "//edit/", views.NominationEdit.as_view(), diff --git a/apps/nominations/views.py b/apps/nominations/views.py index 699da3231..b75d722bc 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -238,6 +238,29 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) +class NominationSubmitted(LoginRequiredMixin, NominationMixin, UserPassesTestMixin, DetailView): + """Show a self-nominator what happens next and how to gather supporting statements.""" + + model = Nomination + template_name_suffix = "_submitted" + raise_exception = True + + def test_func(self): + """Only the candidate who self-nominated may view this page.""" + nomination = self.get_object() + return ( + nomination.nominator == self.request.user + and nomination.nominee is not None + and nomination.nominee.user == self.request.user + ) + + def get_queryset(self): + """Return the URL election's nominations with related objects.""" + return Nomination.objects.filter(election__slug=self.kwargs["election"]).select_related( + "election__kind", "nominee__user", "nominator" + ) + + class NominationStatementPreview(LoginRequiredMixin, View): """Render a nomination statement preview using the model field's own pipeline.""" From 47c819af017abfc2ddcb3e6000e8f8dcee3c3b9f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 10:31:03 -0500 Subject: [PATCH 5/7] redir self nom to next steps --- apps/nominations/tests/test_views.py | 30 ++++++++++++++++++++++++++++ apps/nominations/views.py | 8 +++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index 87b8c04b9..816c34943 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -196,6 +196,36 @@ def test_packaging_council_self_nomination_requires_eligibility(self): self.assertTrue(nomination.eligibility_confirmed) +class NominationSubmittedTests(TestCase): + def setUp(self): + self.user = UserFactory(first_name="Grace", last_name="Hopper") + self.client.force_login(self.user) + self.election = open_election("2026 Board Election") + self.create_url = reverse("nominations:nomination_create", kwargs={"election": self.election.slug}) + + def test_self_nomination_redirects_to_next_steps(self): + response = self.client.post(self.create_url, nomination_payload(self_nomination="on", coc_acknowledged="on")) + nomination = Nomination.objects.get(election=self.election) + submitted_url = reverse( + "nominations:nomination_submitted", kwargs={"election": self.election.slug, "pk": nomination.pk} + ) + self.assertRedirects(response, submitted_url) + + def test_third_party_nomination_redirects_to_detail(self): + response = self.client.post(self.create_url, nomination_payload()) + nomination = Nomination.objects.get(election=self.election) + self.assertRedirects(response, nomination.get_absolute_url()) + + def test_next_steps_forbidden_to_other_users(self): + self.client.post(self.create_url, nomination_payload(self_nomination="on", coc_acknowledged="on")) + nomination = Nomination.objects.get(election=self.election) + self.client.force_login(UserFactory(is_staff=True)) + response = self.client.get( + reverse("nominations:nomination_submitted", kwargs={"election": self.election.slug, "pk": nomination.pk}) + ) + self.assertEqual(response.status_code, 403) + + class NominationStatementPreviewTests(TestCase): def test_renders_markdown_with_html_escaped(self): self.client.force_login(UserFactory()) diff --git a/apps/nominations/views.py b/apps/nominations/views.py index b75d722bc..ac7a976bd 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -130,11 +130,9 @@ def get_form_class(self): return self.form_classes[election.nomination_form_variant] def get_success_url(self): - """Return the URL for the newly created nomination detail page.""" - return reverse( - "nominations:nomination_detail", - kwargs={"election": self.object.election.slug, "pk": self.object.id}, - ) + """Send self-nominators to the next-steps page, everyone else to the nomination detail page.""" + url_name = "nominations:nomination_submitted" if self.object.nominee_id else "nominations:nomination_detail" + return reverse(url_name, kwargs={"election": self.object.election.slug, "pk": self.object.id}) def form_valid(self, form): """Set nominator, election, and handle self-nomination before saving.""" From 9942bb9e6d3c002f5b87cdde0fd27ac4b78c40b4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 10:35:34 -0500 Subject: [PATCH 6/7] also add email with same info --- .../email/self_nomination_received.txt | 28 +++++++++++++++++++ .../self_nomination_received_subject.txt | 1 + apps/nominations/tests/test_views.py | 27 ++++++++++++++++++ apps/nominations/views.py | 24 +++++++++++++++- 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/nominations/templates/nominations/email/self_nomination_received.txt create mode 100644 apps/nominations/templates/nominations/email/self_nomination_received_subject.txt diff --git a/apps/nominations/templates/nominations/email/self_nomination_received.txt b/apps/nominations/templates/nominations/email/self_nomination_received.txt new file mode 100644 index 000000000..ee79fd83f --- /dev/null +++ b/apps/nominations/templates/nominations/email/self_nomination_received.txt @@ -0,0 +1,28 @@ +{% autoescape off %}Hi {{ nomination.nominator.first_name }}, + +Your self-nomination for the {{ nomination.election.name }} Election has been received. + +What you need to know: + +- Your nomination is already marked as accepted. You don't need to accept it again. +- You can edit it until nominations close{% if nomination.election.nominations_close_at %} on {{ nomination.election.nominations_close_at|date:"F j, Y, P T" }}{% endif %}. +- PSF staff will review your nomination before it is published. +- Approved nominees are listed publicly once nominations close. + +View or edit your nomination: +{{ submitted_url }} + +Getting supporting statements + +Other people can support your candidacy by submitting their own nomination for you. Share this link with them: +{{ nomination_create_url }} + +- They need to sign in with their own python.org account. +- Ask them to leave "self nomination" unchecked and enter your name and {{ nomination.nominator.email }}, the email address on your python.org account, so their nomination can be matched to you. +- Once a supporting nomination is linked to you, accept it from your nominations: {{ user_nominations_url }} + Only accepted nominations are published. +- Supporting nominations must be submitted before nominations close. + +Thank you, +The Python Software Foundation +{% endautoescape %} diff --git a/apps/nominations/templates/nominations/email/self_nomination_received_subject.txt b/apps/nominations/templates/nominations/email/self_nomination_received_subject.txt new file mode 100644 index 000000000..31ea59770 --- /dev/null +++ b/apps/nominations/templates/nominations/email/self_nomination_received_subject.txt @@ -0,0 +1 @@ +Your self-nomination for the {{ nomination.election.name }} Election has been received diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index 816c34943..6b64b9db6 100644 --- a/apps/nominations/tests/test_views.py +++ b/apps/nominations/tests/test_views.py @@ -1,6 +1,7 @@ import datetime from django.contrib.auth import get_user_model +from django.core import mail from django.test import TestCase from django.urls import reverse @@ -226,6 +227,32 @@ def test_next_steps_forbidden_to_other_users(self): self.assertEqual(response.status_code, 403) +class SelfNominationEmailTests(TestCase): + def setUp(self): + self.user = UserFactory(first_name="Grace", last_name="Hopper", email="grace@python.example") + self.client.force_login(self.user) + self.election = open_election("2026 Board Election") + self.create_url = reverse("nominations:nomination_create", kwargs={"election": self.election.slug}) + + def test_self_nomination_emails_candidate_next_steps(self): + self.client.post(self.create_url, nomination_payload(self_nomination="on", coc_acknowledged="on")) + nomination = Nomination.objects.get(election=self.election) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["grace@python.example"]) + submitted_path = reverse( + "nominations:nomination_submitted", kwargs={"election": self.election.slug, "pk": nomination.pk} + ) + self.assertIn(f"http://testserver{submitted_path}", email.body) + self.assertIn(f"http://testserver{self.create_url}", email.body) + + def test_third_party_nomination_sends_no_email(self): + self.client.post(self.create_url, nomination_payload()) + self.assertTrue(Nomination.objects.filter(election=self.election).exists()) + self.assertEqual(mail.outbox, []) + + class NominationStatementPreviewTests(TestCase): def test_renders_markdown_with_html_escaped(self): self.client.force_login(UserFactory()) diff --git a/apps/nominations/views.py b/apps/nominations/views.py index ac7a976bd..1971fdbb3 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -1,9 +1,12 @@ """Views for browsing elections, nominees, and managing nominations.""" +from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.mail import send_mail from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404 +from django.template.loader import render_to_string from django.urls import reverse from django.utils.functional import cached_property from django.views import View @@ -149,7 +152,26 @@ def form_valid(self, form): ) form.instance.nominee = nominee form.instance.accepted = True - return super().form_valid(form) + response = super().form_valid(form) + if self.object.nominee_id: + self.send_self_nomination_email() + return response + + def send_self_nomination_email(self): + """Email the self-nominator the same next steps shown on the submitted page.""" + nomination = self.object + context = { + "nomination": nomination, + "submitted_url": self.request.build_absolute_uri(self.get_success_url()), + "nomination_create_url": self.request.build_absolute_uri( + reverse("nominations:nomination_create", kwargs={"election": nomination.election.slug}) + ), + "user_nominations_url": self.request.build_absolute_uri(reverse("users:user_nominations_view")), + } + # subject can't contain newlines, thus strip() call + subject = render_to_string("nominations/email/self_nomination_received_subject.txt", context).strip() + message = render_to_string("nominations/email/self_nomination_received.txt", context) + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [self.request.user.email]) def get_context_data(self, **kwargs): """Return context data for the nomination creation page.""" From 5e0cbbaff47cf1afd0f68c0bb3594c3fe1693436 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 10:48:01 -0500 Subject: [PATCH 7/7] require account email to self-nominate --- apps/nominations/forms.py | 9 ++++++++- apps/nominations/tests/test_forms.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/nominations/forms.py b/apps/nominations/forms.py index 3e4ee49e6..02a8cf6a5 100644 --- a/apps/nominations/forms.py +++ b/apps/nominations/forms.py @@ -197,7 +197,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def clean_self_nomination(self): - """Validate that self-nominating users have a first and last name set.""" + """Validate that self-nominating users have a first name, last name, and email set.""" data = self.cleaned_data["self_nomination"] if data and (not self.request.user.first_name or not self.request.user.last_name): raise forms.ValidationError( @@ -205,6 +205,13 @@ def clean_self_nomination(self): 'You must set your First and Last name in your User Profile to self nominate.' ) ) + # The confirmation email and supporting-statement instructions both use the account email. + if data and not self.request.user.email: + raise forms.ValidationError( + mark_safe( + 'You must set your email address in your User Profile to self nominate.' + ) + ) return data diff --git a/apps/nominations/tests/test_forms.py b/apps/nominations/tests/test_forms.py index f53378a28..6154ac05c 100644 --- a/apps/nominations/tests/test_forms.py +++ b/apps/nominations/tests/test_forms.py @@ -40,6 +40,17 @@ 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_self_nomination_requires_account_email(self): + self.request.user.email = "" + form = self._form(nomination_payload(self_nomination="on", coc_acknowledged="on")) + self.assertFalse(form.is_valid()) + self.assertIn("self_nomination", form.errors) + + def test_third_party_nomination_allowed_without_account_email(self): + self.request.user.email = "" + form = self._form(nomination_payload()) + 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)