Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/nominations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,21 @@ 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(
mark_safe(
'You must set your First and Last name in your <a href="/users/edit/">User Profile</a> 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 <a href="/users/edit/">User Profile</a> to self nominate.'
)
)

return data

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{% url 'nominations:nomination_create' election=nomination.election.slug as nomination_create_url %}
<div class="nomination-supporting-statements">
<h2>Getting supporting statements</h2>
<p>Other people can support your candidacy by submitting their own nomination for you. Share this link with them:</p>
<p><a href="{{ nomination_create_url }}">{{ request.scheme }}://{{ request.get_host }}{{ nomination_create_url }}</a></p>
<ul>
<li>They need to sign in with their own python.org account.</li>
<li>Ask them to leave “self nomination” unchecked and enter your name and <b>{{ request.user.email }}</b>, the email address on your python.org account, so their nomination can be matched to you.</li>
<li>Once a supporting nomination is linked to you, accept it from <a href="{% url 'users:user_nominations_view' %}">your nominations</a>. Only accepted nominations are published.</li>
<li>Supporting nominations must be submitted before nominations close.</li>
</ul>
</div>
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Your self-nomination for the {{ nomination.election.name }} Election has been received
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ <h1 class="page-title">Nomination for {{ nomination.name }} in {{ nomination.ele
<i>It is no longer editable as nominations are closed.</i>
{% endif %}
</center>
{% if nomination.nominator == request.user and nomination.election.nominations_open %}
{% include "nominations/_supporting_statements.html" %}
{% endif %}
{% elif nomination.nominator == request.user %}
<center>
<i>You made this nomination.</i>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}
<article class="text">
<header class="article-header">
<h1 class="page-title">Your self-nomination for the {{ nomination.election.name }} Election has been received</h1>
</header>

<h2>What you need to know</h2>
<ul>
<li>Your nomination is already marked as accepted. You don't need to accept it again.</li>
<li>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 %}.</li>
<li>PSF staff will review your nomination before it is published.</li>
<li>Approved nominees are listed publicly once nominations close.</li>
</ul>
<p>
<a href="{{ nomination.get_absolute_url }}">View your nomination</a>
{% if nomination.election.nominations_open %}| <a href="{{ nomination.get_edit_url }}">Edit it</a>{% endif %}
| <a href="{% url 'users:user_nominations_view' %}">See all your nominations</a>
</p>

{% if nomination.election.nominations_open %}
{% include "nominations/_supporting_statements.html" %}
{% endif %}
</article>
{% endblock content %}
11 changes: 11 additions & 0 deletions apps/nominations/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions apps/nominations/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions apps/nominations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
views.NominationView.as_view(),
name="nomination_detail",
),
path(
"<slug:election>/<int:pk>/submitted/",
views.NominationSubmitted.as_view(),
name="nomination_submitted",
),
path(
"<slug:election>/<int:pk>/edit/",
views.NominationEdit.as_view(),
Expand Down
55 changes: 49 additions & 6 deletions apps/nominations/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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])
Comment thread
JacobCoffee marked this conversation as resolved.

def get_context_data(self, **kwargs):
"""Return context data for the nomination creation page."""
Expand Down Expand Up @@ -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."""

Expand Down
Loading