Skip to content

Commit bc076a2

Browse files
authored
Merge branch 'main' into feature/issue-2611-deprecate-provided-assets-view
2 parents 4f5283c + 9386430 commit bc076a2

File tree

19 files changed

+392
-201
lines changed

19 files changed

+392
-201
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Rewrite legacy pythoninsider.blogspot.com URLs to blog.python.org.
2+
3+
Blogger's RSS feed historically served entry links under the old
4+
pythoninsider.blogspot.com domain. This one-time migration normalizes
5+
existing BlogEntry rows so that the parser's runtime normalization
6+
(added in the same changeset) doesn't create duplicates via
7+
update_or_create.
8+
"""
9+
10+
from django.db import migrations
11+
from django.db.models import Value
12+
from django.db.models.functions import Replace
13+
14+
15+
def normalize_blogentry_urls(apps, schema_editor):
16+
BlogEntry = apps.get_model("blogs", "BlogEntry")
17+
BlogEntry.objects.filter(url__contains="pythoninsider.blogspot.com").update(
18+
url=Replace("url", Value("pythoninsider.blogspot.com"), Value("blog.python.org")),
19+
)
20+
21+
22+
class Migration(migrations.Migration):
23+
dependencies = [
24+
("blogs", "0003_alter_relatedblog_creator_and_more"),
25+
]
26+
27+
operations = [
28+
migrations.RunPython(
29+
normalize_blogentry_urls,
30+
reverse_code=migrations.RunPython.noop,
31+
),
32+
]

apps/blogs/parser.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""RSS feed parsing and blog supernav rendering utilities."""
22

33
import datetime
4+
from urllib.parse import urlparse, urlunparse
45

56
import feedparser
67
from django.conf import settings
@@ -9,6 +10,19 @@
910
from apps.blogs.models import BlogEntry, Feed
1011
from apps.boxes.models import Box
1112

13+
# Blogger serves RSS entry links with this legacy domain instead of
14+
# the canonical blog.python.org hostname.
15+
_BLOGGER_LEGACY_HOST = "pythoninsider.blogspot.com"
16+
17+
18+
def _normalize_blog_url(url):
19+
"""Rewrite legacy Blogger URLs to the canonical blog.python.org domain."""
20+
parsed = urlparse(url)
21+
if parsed.hostname == _BLOGGER_LEGACY_HOST:
22+
canonical = urlparse(settings.PYTHON_BLOG_URL)
23+
return urlunparse(parsed._replace(scheme=canonical.scheme, netloc=canonical.netloc))
24+
return url
25+
1226

1327
def get_all_entries(feed_url):
1428
"""Retrieve all entries from a feed URL."""
@@ -22,7 +36,7 @@ def get_all_entries(feed_url):
2236
"title": e["title"],
2337
"summary": e.get("summary", ""),
2438
"pub_date": published,
25-
"url": e["link"],
39+
"url": _normalize_blog_url(e["link"]),
2640
}
2741

2842
entries.append(entry)

apps/blogs/tests/test_parser.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import datetime
22
import unittest
33

4-
from apps.blogs.parser import get_all_entries
4+
from apps.blogs.parser import _normalize_blog_url, get_all_entries
55
from apps.blogs.tests.utils import get_test_rss_path
66

77

@@ -24,3 +24,27 @@ def test_entries(self):
2424
self.entries[0]["url"],
2525
"http://feedproxy.google.com/~r/PythonInsider/~3/tGNCqyOiun4/introducing-electronic-contributor.html",
2626
)
27+
28+
29+
class NormalizeBlogUrlTest(unittest.TestCase):
30+
def test_rewrites_pythoninsider_blogspot(self):
31+
url = "https://pythoninsider.blogspot.com/2026/02/join-the-python-security-response-team.html"
32+
self.assertEqual(
33+
_normalize_blog_url(url),
34+
"https://blog.python.org/2026/02/join-the-python-security-response-team.html",
35+
)
36+
37+
def test_rewrites_http_to_canonical_scheme(self):
38+
url = "http://pythoninsider.blogspot.com/2026/01/some-post.html"
39+
self.assertEqual(
40+
_normalize_blog_url(url),
41+
"https://blog.python.org/2026/01/some-post.html",
42+
)
43+
44+
def test_preserves_non_blogspot_urls(self):
45+
url = "http://feedproxy.google.com/~r/PythonInsider/~3/abc/some-post.html"
46+
self.assertEqual(_normalize_blog_url(url), url)
47+
48+
def test_preserves_blog_python_org_urls(self):
49+
url = "https://blog.python.org/2026/02/some-post.html"
50+
self.assertEqual(_normalize_blog_url(url), url)

apps/boxes/templatetags/boxes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
def box(label):
1616
"""Render the content of a Box identified by its label slug."""
1717
try:
18-
return mark_safe(Box.objects.only("content").get(label=label).content.rendered)
18+
return mark_safe(Box.objects.only("content").get(label=label).content.rendered) # noqa: S308
1919
except Box.DoesNotExist:
2020
log.warning("WARNING: box not found: label=%s", label)
2121
return ""

apps/companies/templatetags/companies.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from django import template
44
from django.template.defaultfilters import stringfilter
5-
from django.utils.html import format_html
5+
from django.utils.html import format_html_join, mark_safe
66

77
register = template.Library()
88

99

10-
@register.filter(is_safe=True)
10+
@register.filter()
1111
@stringfilter
1212
def render_email(value):
1313
"""Render an email address with obfuscated dots and at-sign using spans."""
@@ -16,8 +16,8 @@ def render_email(value):
1616
mailbox_tokens = mailbox.split(".")
1717
domain_tokens = domain.split(".")
1818

19-
mailbox = "<span>.</span>".join(mailbox_tokens)
20-
domain = "<span>.</span>".join(domain_tokens)
19+
mailbox = format_html_join(mark_safe("<span>.</span>"), "{}", [(token,) for token in mailbox_tokens])
20+
domain = format_html_join(mark_safe("<span>.</span>"), "{}", [(token,) for token in domain_tokens])
2121

22-
return format_html(f"{mailbox}<span>@</span>{domain}")
22+
return mailbox + mark_safe("<span>@</span>") + domain
2323
return None
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 5.2.11 on 2026-02-25 14:13
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("downloads", "0014_releasefile_sha256_sum"),
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
]
12+
13+
operations = [
14+
migrations.AddConstraint(
15+
model_name="releasefile",
16+
constraint=models.CheckConstraint(
17+
condition=models.Q(
18+
models.Q(
19+
("url__exact", ""),
20+
("url__startswith", "https://www.python.org/"),
21+
("url__startswith", "http://www.python.org/"),
22+
_connector="OR",
23+
),
24+
models.Q(
25+
("gpg_signature_file__exact", ""),
26+
("gpg_signature_file__startswith", "https://www.python.org/"),
27+
("gpg_signature_file__startswith", "http://www.python.org/"),
28+
_connector="OR",
29+
),
30+
models.Q(
31+
("sigstore_signature_file__exact", ""),
32+
("sigstore_signature_file__startswith", "https://www.python.org/"),
33+
("sigstore_signature_file__startswith", "http://www.python.org/"),
34+
_connector="OR",
35+
),
36+
models.Q(
37+
("sigstore_cert_file__exact", ""),
38+
("sigstore_cert_file__startswith", "https://www.python.org/"),
39+
("sigstore_cert_file__startswith", "http://www.python.org/"),
40+
_connector="OR",
41+
),
42+
models.Q(
43+
("sigstore_bundle_file__exact", ""),
44+
("sigstore_bundle_file__startswith", "https://www.python.org/"),
45+
("sigstore_bundle_file__startswith", "http://www.python.org/"),
46+
_connector="OR",
47+
),
48+
models.Q(
49+
("sbom_spdx2_file__exact", ""),
50+
("sbom_spdx2_file__startswith", "https://www.python.org/"),
51+
("sbom_spdx2_file__startswith", "http://www.python.org/"),
52+
_connector="OR",
53+
),
54+
),
55+
name="only_python_dot_org_urls",
56+
violation_error_message="All file URLs must begin with 'https://www.python.org/'",
57+
),
58+
),
59+
]

apps/downloads/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,17 @@ def update_boxes_on_release_file_delete(sender, instance, **kwargs):
357357
_update_boxes_for_release_file(instance)
358358

359359

360+
def condition_url_is_blank_or_python_dot_org(column: str):
361+
"""Conditions for a URLField column to force 'http[s]://python.org'."""
362+
return (
363+
models.Q(**{f"{column}__exact": ""})
364+
| models.Q(**{f"{column}__startswith": "https://www.python.org/"})
365+
# Older releases allowed 'http://'. 'https://' is required at
366+
# the API level, so shouldn't show up in newer releases.
367+
| models.Q(**{f"{column}__startswith": "http://www.python.org/"})
368+
)
369+
370+
360371
class ReleaseFile(ContentManageable, NameSlugModel):
361372
"""Individual files in a release.
362373
@@ -406,4 +417,16 @@ class Meta:
406417
condition=models.Q(download_button=True),
407418
name="only_one_download_per_os_per_release",
408419
),
420+
models.CheckConstraint(
421+
condition=(
422+
condition_url_is_blank_or_python_dot_org("url")
423+
& condition_url_is_blank_or_python_dot_org("gpg_signature_file")
424+
& condition_url_is_blank_or_python_dot_org("sigstore_signature_file")
425+
& condition_url_is_blank_or_python_dot_org("sigstore_cert_file")
426+
& condition_url_is_blank_or_python_dot_org("sigstore_bundle_file")
427+
& condition_url_is_blank_or_python_dot_org("sbom_spdx2_file")
428+
),
429+
name="only_python_dot_org_urls",
430+
violation_error_message="All file URLs must begin with 'https://www.python.org/'",
431+
),
409432
]

apps/downloads/templatetags/download_tags.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import requests
77
from django import template
88
from django.core.cache import cache
9-
from django.utils.html import format_html
10-
from django.utils.safestring import mark_safe
9+
from django.utils.html import format_html, format_html_join, mark_safe
1110

1211
from apps.downloads.models import Release
1312

@@ -111,11 +110,11 @@ def wbr_wrap(value: str | None) -> str:
111110

112111
# Split into two halves, each half has internal <wbr> breaks
113112
midpoint = len(chunks) // 2
114-
first_half = "<wbr>".join(chunks[:midpoint])
115-
second_half = "<wbr>".join(chunks[midpoint:])
113+
first_half = format_html_join(mark_safe("<wbr>"), "{}", [(chunk,) for chunk in chunks[:midpoint]])
114+
second_half = format_html_join(mark_safe("<wbr>"), "{}", [(chunk,) for chunk in chunks[midpoint:]])
116115

117-
return mark_safe(
118-
f'<span class="checksum-half">{first_half}</span><wbr><span class="checksum-half">{second_half}</span>'
116+
return format_html(
117+
'<span class="checksum-half">{}</span><wbr><span class="checksum-half">{}</span>', first_half, second_half
119118
)
120119

121120

apps/downloads/tests/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ def setUp(self):
3636
release=self.release_275,
3737
name="Windows x86 MSI Installer (2.7.5)",
3838
description="Windows binary -- does not include source",
39-
url="ftp/python/2.7.5/python-2.7.5.msi",
39+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.msi",
4040
)
4141
self.release_275_windows_64bit = ReleaseFile.objects.create(
4242
os=self.windows,
4343
release=self.release_275,
4444
name="Windows X86-64 MSI Installer (2.7.5)",
4545
description="Windows AMD64 / Intel 64 / X86-64 binary -- does not include source",
46-
url="ftp/python/2.7.5/python-2.7.5.amd64.msi",
46+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.amd64.msi",
4747
)
4848

4949
self.release_275_osx = ReleaseFile.objects.create(
5050
os=self.osx,
5151
release=self.release_275,
5252
name="Mac OSX 64-bit/32-bit",
5353
description="Mac OS X 10.6 and later",
54-
url="ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg",
54+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg",
5555
)
5656

5757
self.release_275_linux = ReleaseFile.objects.create(
@@ -60,7 +60,7 @@ def setUp(self):
6060
release=self.release_275,
6161
is_source=True,
6262
description="Gzipped source",
63-
url="ftp/python/2.7.5/Python-2.7.5.tgz",
63+
url="https://www.python.org/ftp/python/2.7.5/Python-2.7.5.tgz",
6464
filesize=12345678,
6565
)
6666

@@ -77,7 +77,7 @@ def setUp(self):
7777
release=self.draft_release,
7878
is_source=True,
7979
description="Gzipped source",
80-
url="ftp/python/9.7.2/Python-9.7.2.tgz",
80+
url="https://www.python.org/ftp/python/9.7.2/Python-9.7.2.tgz",
8181
)
8282

8383
self.hidden_release = Release.objects.create(

apps/downloads/tests/test_models.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import datetime as dt
22
from unittest.mock import patch
33

4-
from apps.downloads.models import Release, ReleaseFile
4+
from django.db import IntegrityError, transaction
5+
from django.db.models import URLField
6+
7+
from apps.downloads.models import OS, Release, ReleaseFile
58
from apps.downloads.tests.base import BaseDownloadTests
69

710

@@ -160,7 +163,7 @@ def test_update_supernav(self):
160163
release=self.python_3,
161164
slug=slug,
162165
name="Python 3.10",
163-
url=f"/ftp/python/{slug}.zip",
166+
url=f"https://www.python.org/ftp/python/{slug}.zip",
164167
download_button=True,
165168
)
166169

@@ -179,7 +182,7 @@ def test_update_supernav(self):
179182
os=self.windows,
180183
release=release,
181184
name="MSIX",
182-
url="/ftp/python/pymanager/pymanager-25.0.msix",
185+
url="https://www.python.org/ftp/python/pymanager/pymanager-25.0.msix",
183186
download_button=True,
184187
)
185188

@@ -199,7 +202,7 @@ def test_update_supernav_skips_os_without_files(self):
199202
"""
200203
# Arrange
201204
from apps.boxes.models import Box
202-
from apps.downloads.models import OS, update_supernav
205+
from apps.downloads.models import update_supernav
203206

204207
# Create an OS without any release files
205208
OS.objects.create(name="Android", slug="android")
@@ -215,7 +218,7 @@ def test_update_supernav_skips_os_without_files(self):
215218
release=self.python_3,
216219
slug=slug,
217220
name="Python 3.10",
218-
url=f"/ftp/python/{slug}.zip",
221+
url=f"https://www.python.org/ftp/python/{slug}.zip",
219222
download_button=True,
220223
)
221224

@@ -247,7 +250,7 @@ def test_release_file_save_triggers_box_updates(self, mock_home, mock_sources, m
247250
os=self.windows,
248251
release=self.python_3,
249252
name="Windows installer",
250-
url="/ftp/python/3.10.19/python-3.10.19.exe",
253+
url="https://www.python.org/ftp/python/3.10.19/python-3.10.19.exe",
251254
download_button=True,
252255
)
253256

@@ -268,7 +271,7 @@ def test_release_file_save_skips_unpublished_release(self, mock_home, mock_sourc
268271
os=self.windows,
269272
release=self.draft_release,
270273
name="Windows installer draft",
271-
url="/ftp/python/9.7.2/python-9.7.2.exe",
274+
url="https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
272275
)
273276

274277
mock_supernav.assert_not_called()
@@ -289,3 +292,22 @@ def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources,
289292
mock_supernav.assert_called()
290293
mock_sources.assert_called()
291294
mock_home.assert_called()
295+
296+
def test_release_file_urls_not_python_dot_org(self):
297+
for field in ReleaseFile._meta.get_fields(): # noqa: SLF001
298+
if not isinstance(field, URLField):
299+
continue
300+
with self.subTest(field.name), transaction.atomic():
301+
kwargs = {
302+
"url": "https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
303+
# field.name may be 'url', but will replace the default value.
304+
field.name: "https://notpython.com/python-9.7.2.txt",
305+
}
306+
307+
with self.assertRaises(IntegrityError):
308+
ReleaseFile.objects.create(
309+
os=self.windows,
310+
release=self.draft_release,
311+
name="Windows installer draft",
312+
**kwargs,
313+
)

0 commit comments

Comments
 (0)