Skip to content

Commit dec3280

Browse files
committed
Merge remote-tracking branch 'origin/main' into nominations-supporting-statements
# Conflicts: # apps/nominations/views.py
2 parents 5e0cbba + e1ca31a commit dec3280

30 files changed

Lines changed: 485 additions & 374 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Rewrite dead hg.python.org release notes URLs to their GitHub equivalents."""
2+
3+
import re
4+
5+
from django.db import migrations
6+
7+
HG_URL_RE = re.compile(r"^https?://hg\.python\.org/cpython/(?:raw-)?file/(?P<ref>[^/]+)/(?P<path>.+)$")
8+
GITHUB_BLOB_URL = "https://github.com/python/cpython/blob/{ref}/{path}"
9+
10+
# Mercurial changesets do not exist in git; map the ones stored in production
11+
# to the git ref for the same release.
12+
HG_CHANGESET_TO_GIT_REF = {
13+
"15fc83c505e3": "b34ba3f174ced8b6534888179b2f22e0f006c26e", # 2.3 (no v2.3 tag in git)
14+
"e32e3a9f3902": "v2.7.7rc1",
15+
"80ccce248ba2": "v2.7.10rc1",
16+
"15c95b7d81dc": "v2.7.10",
17+
"82dd9545bd93": "v2.7.11rc1",
18+
"53d30ab403f1": "v2.7.11",
19+
}
20+
21+
22+
def github_url_for(hg_url):
23+
match = HG_URL_RE.match(hg_url)
24+
if match is None:
25+
return None
26+
ref = match["ref"]
27+
if not ref.startswith("v"):
28+
ref = HG_CHANGESET_TO_GIT_REF.get(ref)
29+
if ref is None:
30+
return None
31+
return GITHUB_BLOB_URL.format(ref=ref, path=match["path"])
32+
33+
34+
def rewrite_hg_release_notes_urls(apps, schema_editor):
35+
Release = apps.get_model("downloads", "Release")
36+
db_alias = schema_editor.connection.alias
37+
releases = Release.objects.using(db_alias).filter(release_notes_url__contains="hg.python.org")
38+
for release in releases:
39+
url = github_url_for(release.release_notes_url)
40+
if url is not None:
41+
Release.objects.using(db_alias).filter(pk=release.pk).update(release_notes_url=url)
42+
43+
44+
class Migration(migrations.Migration):
45+
dependencies = [
46+
("downloads", "0015_releasefile_python_dot_org_urls"),
47+
]
48+
49+
operations = [
50+
migrations.RunPython(rewrite_hg_release_notes_urls, migrations.RunPython.noop),
51+
]

‎apps/nominations/models.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,15 @@ def visible(self, user=None):
245245
if self.accepted and self.approved and not self.election.nominations_open:
246246
return True
247247

248-
if user is None:
248+
if user is None or not user.is_authenticated:
249249
return False
250250

251-
return bool(user.is_staff or user == self.user)
251+
if user.is_staff or user == self.user:
252+
return True
253+
254+
# A nominator can see the person they nominated, so that the links
255+
# in the nominee list preview work while nominations are open.
256+
return self.nominations.filter(nominator=user, election=self.election).exists()
252257

253258

254259
class Nomination(models.Model):

‎apps/nominations/tests/test_views.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,80 @@ def test_accept_404s_under_wrong_election(self):
390390

391391
def test_edit_still_works_under_own_election(self):
392392
self.assertEqual(self.client.get(self._url("nominations:nomination_edit", self.election)).status_code, 200)
393+
394+
395+
class NomineeListPreviewTests(TestCase):
396+
"""While nominations are open, the list previews the nominees relevant to the user."""
397+
398+
def setUp(self):
399+
self.nominator = UserFactory(first_name="Alan", last_name="Turing")
400+
self.election = open_election("2026 Board Election")
401+
self.other_election = open_election("2026 Packaging Council Election", kind=packaging_council_kind())
402+
403+
self.nominee = self._nominee(self.election, "Grace", "Hopper")
404+
self._nomination(self.election, self.nominator, self.nominee)
405+
406+
# The same user is a candidate in an unrelated election.
407+
self.nominator_as_nominee = Nominee.objects.create(user=self.nominator, election=self.other_election)
408+
self._nomination(
409+
self.other_election, UserFactory(first_name="Barbara", last_name="Liskov"), self.nominator_as_nominee
410+
)
411+
412+
# Somebody else's nomination in the election under test.
413+
self.unrelated_nominee = self._nominee(self.election, "Ada", "Lovelace")
414+
self._nomination(self.election, UserFactory(first_name="Ken", last_name="Thompson"), self.unrelated_nominee)
415+
416+
def _nominee(self, election, first_name, last_name):
417+
user = UserFactory(first_name=first_name, last_name=last_name)
418+
return Nominee.objects.create(user=user, election=election)
419+
420+
def _nomination(self, election, nominator, nominee):
421+
return Nomination.objects.create(
422+
election=election,
423+
nominator=nominator,
424+
nominee=nominee,
425+
name=f"{nominee.user.first_name} {nominee.user.last_name}",
426+
email=nominee.user.email,
427+
nomination_statement="A strong candidate.",
428+
)
429+
430+
def _url(self, election):
431+
return reverse("nominations:nominees_list", kwargs={"election": election.slug})
432+
433+
def test_preview_lists_people_the_user_nominated(self):
434+
self.client.force_login(self.nominator)
435+
response = self.client.get(self._url(self.election))
436+
self.assertEqual(response.status_code, 200)
437+
self.assertIn(self.nominee, response.context["object_list"])
438+
439+
def test_preview_excludes_other_elections(self):
440+
self.client.force_login(self.nominator)
441+
response = self.client.get(self._url(self.election))
442+
self.assertNotIn(self.nominator_as_nominee, response.context["object_list"])
443+
444+
def test_preview_excludes_nominations_by_other_people(self):
445+
self.client.force_login(self.nominator)
446+
response = self.client.get(self._url(self.election))
447+
self.assertNotIn(self.unrelated_nominee, response.context["object_list"])
448+
449+
def test_preview_ignores_nominations_filed_under_another_election(self):
450+
# A nomination whose election disagrees with its nominee's (possible via the admin).
451+
self._nomination(self.other_election, self.nominator, self.unrelated_nominee)
452+
self.client.force_login(self.nominator)
453+
response = self.client.get(self._url(self.election))
454+
self.assertNotIn(self.unrelated_nominee, response.context["object_list"])
455+
456+
def test_preview_includes_the_user_as_a_candidate(self):
457+
self.client.force_login(self.nominator)
458+
response = self.client.get(self._url(self.other_election))
459+
self.assertIn(self.nominator_as_nominee, response.context["object_list"])
460+
461+
def test_nominator_can_open_the_nominee_they_nominated(self):
462+
self.client.force_login(self.nominator)
463+
response = self.client.get(self.nominee.get_absolute_url())
464+
self.assertEqual(response.status_code, 200)
465+
466+
def test_other_users_still_cannot_open_the_nominee(self):
467+
self.client.force_login(UserFactory(first_name="Guido", last_name="van Rossum"))
468+
response = self.client.get(self.nominee.get_absolute_url())
469+
self.assertEqual(response.status_code, 404)

‎apps/nominations/views.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib import messages
55
from django.contrib.auth.mixins import UserPassesTestMixin
66
from django.core.mail import send_mail
7+
from django.db.models import Q
78
from django.http import Http404, JsonResponse
89
from django.shortcuts import get_object_or_404
910
from django.template.loader import render_to_string
@@ -75,7 +76,19 @@ def get_queryset(self, *args, **kwargs):
7576
return Nominee.objects.filter(accepted=True, approved=True, election=election).exclude(user=None)
7677

7778
if self.request.user.is_authenticated:
78-
return Nominee.objects.filter(user=self.request.user)
79+
# Before the results are public, preview the nominees relevant to
80+
# this user in this election: the people they nominated, plus
81+
# themselves when somebody nominated them.
82+
return (
83+
Nominee.objects.filter(
84+
Q(user=self.request.user)
85+
| Q(nominations__nominator=self.request.user, nominations__election=election),
86+
election=election,
87+
)
88+
.exclude(user=None)
89+
.distinct()
90+
.select_related("user")
91+
)
7992
return None
8093

8194

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Template tags for the pages app."""

‎apps/pages/templatetags/pages.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Template filters for rendering CMS page content."""
2+
3+
import re
4+
from collections import Counter
5+
6+
from dateutil import parser as date_parser
7+
from django import template
8+
from django.utils.html import mark_safe
9+
10+
register = template.Library()
11+
12+
_MONTH = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?"
13+
# A board resolution is a <blockquote> followed by a paragraph with its vote date, e.g.
14+
# "Approved; 9-0-1, 2026-08-12", "Declined 3-4-0, December 2, 2014", "September 4, 2018".
15+
_RESOLUTION_RE = re.compile(
16+
r"<blockquote>(?P<body>(?:(?!<blockquote>).)*?</blockquote>\s*<p>[^<]*?)"
17+
rf"(?P<date>\d{{4}}-\d\d-\d\d|{_MONTH}\s+\d{{1,2}},?\s+\d{{4}}|\d{{1,2}}\s+{_MONTH},?\s+\d{{4}})",
18+
re.DOTALL,
19+
)
20+
21+
22+
@register.filter
23+
def resolution_anchors(content):
24+
"""Anchor each board resolution at its vote date and turn that date into a link to it.
25+
26+
Ids are ISO dates; later resolutions on the same date get ``-2``, ``-3``, ... in page order.
27+
"""
28+
seen = Counter()
29+
30+
def anchor(match):
31+
try:
32+
date = date_parser.parse(match["date"]).date().isoformat()
33+
except ValueError:
34+
return match[0]
35+
seen[date] += 1
36+
anchor_id = date if seen[date] == 1 else f"{date}-{seen[date]}"
37+
return f'<blockquote id="{anchor_id}">{match["body"]}<a href="#{anchor_id}">{match["date"]}</a>'
38+
39+
return mark_safe(_RESOLUTION_RE.sub(anchor, str(content))) # noqa: S308
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.test import SimpleTestCase
2+
3+
from apps.pages.templatetags.pages import resolution_anchors
4+
5+
6+
class ResolutionAnchorsTests(SimpleTestCase):
7+
def test_ids_from_vote_dates(self):
8+
"""Each resolution is keyed by its vote date, numbered in page order when a date repeats."""
9+
html = (
10+
"<blockquote>A</blockquote>\n<p>Approved; 9-0-1, 2026-08-12</p>\n"
11+
"<blockquote>B</blockquote>\n<p>Approved; 9-0-0, 2026-08-12</p>\n"
12+
"<blockquote>C</blockquote>\n<p>Declined 3-4-0, December 2, 2014</p>\n"
13+
"<blockquote>D</blockquote>\n<p>Approved, 10-0-0 by IRC vote, 8 June 2015.</p>\n"
14+
"<blockquote>E</blockquote>\n<p>September 4, 2018</p>"
15+
)
16+
self.assertHTMLEqual(
17+
resolution_anchors(html),
18+
'<blockquote id="2026-08-12">A</blockquote><p>Approved; 9-0-1, <a href="#2026-08-12">2026-08-12</a></p>'
19+
'<blockquote id="2026-08-12-2">B</blockquote><p>Approved; 9-0-0, <a href="#2026-08-12-2">2026-08-12</a></p>'
20+
'<blockquote id="2014-12-02">C</blockquote><p>Declined 3-4-0, <a href="#2014-12-02">December 2, 2014</a></p>'
21+
'<blockquote id="2015-06-08">D</blockquote>'
22+
'<p>Approved, 10-0-0 by IRC vote, <a href="#2015-06-08">8 June 2015</a>.</p>'
23+
'<blockquote id="2018-09-04">E</blockquote><p><a href="#2018-09-04">September 4, 2018</a></p>',
24+
)
25+
26+
def test_unmatched_content_unchanged(self):
27+
"""Paragraphs after a quote without a full date, and dated paragraphs not after a quote, are left alone."""
28+
html = (
29+
"<blockquote>A</blockquote><p>Approved, 10-0-0, May 2010.</p>"
30+
"<blockquote>B</blockquote><p>with:</p>"
31+
"<p>Approved; 9-0-0, 2026-08-12</p>"
32+
"<blockquote>C</blockquote><p>Approved 5-0-0, Feb 30, 2014</p>"
33+
)
34+
self.assertHTMLEqual(resolution_anchors(html), html)

‎apps/sponsors/models/benefits.py‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,6 @@ def user_edit_url(self):
316316
url = reverse("users:update_sponsorship_assets", args=[self.sponsor_benefit.sponsorship.pk])
317317
return url + f"?required_asset={self.pk}"
318318

319-
@property
320-
def user_view_url(self):
321-
"""Return the URL for sponsors to view this provided asset."""
322-
url = reverse("users:view_provided_sponsorship_assets", args=[self.sponsor_benefit.sponsorship.pk])
323-
return url + f"?provided_asset={self.pk}"
324-
325319

326320
class RequiredAssetMixin(AssetMixin):
327321
"""Mixin for required assets submitted by the user.

‎apps/sponsors/templates/sponsors/partials/sponsors-list.html‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
{% cache 86400 CACHED_DOWNLOAD_SPONSORS_LIST %}
99
<h2 class="widget-title" style="text-align: center;">Sponsors</h2>
1010
<p style="text-align: center;">Visionary sponsors help to host Python downloads.</p>
11-
<div style="display: grid; grid-gap: 2em; grid-template-columns: repeat(auto-fit, minmax(150px, 0fr)); align-items: center; justify-content: center; margin-top: 1.5em;">
11+
<div style="display: grid; gap: 2.5rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: center; justify-content: center; margin-top: 1.5em;">
1212
{% for sponsorship in sponsorships %}
1313
<div style="text-align: center;">
1414
<a href="{{ sponsorship.sponsor.landing_page_url }}" rel="sponsored noopener" target="_blank" style="border-bottom: 0;">
@@ -30,7 +30,7 @@ <h2 class="widget-title" style="text-align: center;">Sponsors</h2>
3030
{% comment %}cache for 1 day{% endcomment %}
3131
{% cache 86400 CACHED_JOBS_SPONSORS_LIST %}
3232
<h3 class="widget-title">Job Board Sponsors</h3>
33-
<div style="display: grid; grid-gap: 1em; grid-template-columns: repeat(auto-fit, minmax(100px, 0fr)); grid-template-rows: repeat(1, minmax(50px, 0fr)); align-items: center; justify-content: center; margin-top: 1em;">
33+
<div style="display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); align-items: center; justify-content: center; margin-top: 1em;">
3434
{% for sponsorship in sponsorships %}
3535
<div>
3636
{% if sponsorship.sponsor.web_logo.name|lower|slice:"-4:" == ".svg" %}
@@ -51,14 +51,14 @@ <h3 class="widget-title">Job Board Sponsors</h3>
5151

5252
{% for package, placement_info in sponsorships_by_package.items %}
5353
{% if placement_info.sponsorships %}
54-
<div title="{{ package }} Sponsors" align="center">
54+
<div title="{{ package }} Sponsors" align="center"{% if not forloop.last %} style="margin-bottom: 3rem;"{% endif %}>
5555
{% with dimension=placement_info.logo_dimension %}
5656

5757
<h1 style="font-size: {% if forloop.first %}350%{% else %}300%{% endif %}">{{ placement_info.label }} Sponsors</h1>
5858

59-
<div style="display: grid; grid-gap: 2em; grid-template-columns: repeat(auto-fit, minmax({{ dimension }}px, 0fr)); grid-template-rows: repeat(1, minmax({{ dimension }}px, 0fr)); align-items: center; justify-content: center;">
59+
<div style="display: grid; gap: 2.5rem; grid-template-columns: repeat(auto-fit, minmax({{ dimension }}px, 1fr)); align-items: center; justify-content: center;">
6060
{% for sponsorship in placement_info.sponsorships %}
61-
<div id="{{ sponsorship.sponsor.slug }}" data-internal-year={{ sponsorship.year }}>
61+
<div id="{{ sponsorship.sponsor.slug }}" data-internal-year="{{ sponsorship.year|default_if_none:'' }}">
6262
<div
6363
data-ea-publisher="psf"
6464
data-ea-type="psf-image-only"

‎apps/sponsors/templatetags/sponsors.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ def ideal_size(image, ideal_dimension):
9797
ideal_dimension = int(ideal_dimension)
9898
try:
9999
w, h = image.width, image.height
100-
except FileNotFoundError:
101-
# local dev doesn't have all images if DB is a copy from prod environment
102-
# this is just a fallback to return ideal_dimension instead
100+
except (FileNotFoundError, ValueError):
101+
# FileNotFoundError: local dev doesn't have all images if DB is a copy from prod environment.
102+
# ValueError: no file is associated with the field.
103+
# Size as a square logo would be instead of erroring.
103104
w, h = ideal_dimension, ideal_dimension
104105

105106
return int(w * math.sqrt((100 * ideal_dimension) / (w * h)))

0 commit comments

Comments
 (0)