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/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 }}

+ +
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/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. 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/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) diff --git a/apps/nominations/tests/test_views.py b/apps/nominations/tests/test_views.py index 441a00d3c..6571aff25 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 @@ -196,6 +197,62 @@ 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 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/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 77568b36a..37dfe01fb 100644 --- a/apps/nominations/views.py +++ b/apps/nominations/views.py @@ -1,10 +1,13 @@ """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.db.models import Q 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 @@ -143,11 +146,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.""" @@ -164,7 +165,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.""" @@ -251,6 +271,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."""