From f52b546876b33d5f9c526bcfb8c5c8a34128d433 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 25 Feb 2026 11:29:21 -0600 Subject: [PATCH 1/5] Force 'https://www.python.org/' URLs for ReleaseFile properties --- .../0015_releasefile_python_dot_org_urls.py | 58 +++++++++++++++++++ apps/downloads/models.py | 23 ++++++++ apps/downloads/tests/base.py | 10 ++-- apps/downloads/tests/test_models.py | 36 +++++++++--- 4 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py diff --git a/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py new file mode 100644 index 000000000..8451401a4 --- /dev/null +++ b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.11 on 2026-02-25 14:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("downloads", "0014_releasefile_sha256_sum"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name="releasefile", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q( + ("url__exact", ""), + ("url__startswith", "https://www.python.org/"), + ("url__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("gpg_signature_file__exact", ""), + ("gpg_signature_file__startswith", "https://www.python.org/"), + ("gpg_signature_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_signature_file__exact", ""), + ("sigstore_signature_file__startswith", "https://www.python.org/"), + ("sigstore_signature_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_cert_file__exact", ""), + ("sigstore_cert_file__startswith", "https://www.python.org/"), + ("sigstore_cert_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_bundle_file__exact", ""), + ("sigstore_bundle_file__startswith", "https://www.python.org/"), + ("sigstore_bundle_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sbom_spdx2_file__exact", ""), + ("sbom_spdx2_file__startswith", "https://www.python.org/"), + ("sbom_spdx2_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + ), + name="only_python_dot_org_urls", + ), + ), + ] diff --git a/apps/downloads/models.py b/apps/downloads/models.py index a8feb85f8..cbbef8095 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -357,6 +357,18 @@ def update_boxes_on_release_file_delete(sender, instance, **kwargs): _update_boxes_for_release_file(instance) +def condition_url_is_blank_or_python_dot_org(column: str): + # Creates a condition for a URLField column to either be + # blank or to start with 'http(s)://www.python.org/'. + return ( + models.Q(**{f"{column}__exact": ""}) + | models.Q(**{f"{column}__startswith": "https://www.python.org/"}) + # Older releases allowed 'http://'. 'https://' is required at + # the API level, so shouldn't show up in newer releases. + | models.Q(**{f"{column}__startswith": "http://www.python.org/"}) + ) + + class ReleaseFile(ContentManageable, NameSlugModel): """Individual files in a release. @@ -406,4 +418,15 @@ class Meta: condition=models.Q(download_button=True), name="only_one_download_per_os_per_release", ), + models.CheckConstraint( + condition=( + condition_url_is_blank_or_python_dot_org("url") + & condition_url_is_blank_or_python_dot_org("gpg_signature_file") + & condition_url_is_blank_or_python_dot_org("sigstore_signature_file") + & condition_url_is_blank_or_python_dot_org("sigstore_cert_file") + & condition_url_is_blank_or_python_dot_org("sigstore_bundle_file") + & condition_url_is_blank_or_python_dot_org("sbom_spdx2_file") + ), + name="only_python_dot_org_urls", + ), ] diff --git a/apps/downloads/tests/base.py b/apps/downloads/tests/base.py index 18005aaa5..fb38d5d92 100644 --- a/apps/downloads/tests/base.py +++ b/apps/downloads/tests/base.py @@ -36,14 +36,14 @@ def setUp(self): release=self.release_275, name="Windows x86 MSI Installer (2.7.5)", description="Windows binary -- does not include source", - url="ftp/python/2.7.5/python-2.7.5.msi", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.msi", ) self.release_275_windows_64bit = ReleaseFile.objects.create( os=self.windows, release=self.release_275, name="Windows X86-64 MSI Installer (2.7.5)", description="Windows AMD64 / Intel 64 / X86-64 binary -- does not include source", - url="ftp/python/2.7.5/python-2.7.5.amd64.msi", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.amd64.msi", ) self.release_275_osx = ReleaseFile.objects.create( @@ -51,7 +51,7 @@ def setUp(self): release=self.release_275, name="Mac OSX 64-bit/32-bit", description="Mac OS X 10.6 and later", - url="ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg", ) self.release_275_linux = ReleaseFile.objects.create( @@ -60,7 +60,7 @@ def setUp(self): release=self.release_275, is_source=True, description="Gzipped source", - url="ftp/python/2.7.5/Python-2.7.5.tgz", + url="https://www.python.org/ftp/python/2.7.5/Python-2.7.5.tgz", filesize=12345678, ) @@ -77,7 +77,7 @@ def setUp(self): release=self.draft_release, is_source=True, description="Gzipped source", - url="ftp/python/9.7.2/Python-9.7.2.tgz", + url="https://www.python.org/ftp/python/9.7.2/Python-9.7.2.tgz", ) self.hidden_release = Release.objects.create( diff --git a/apps/downloads/tests/test_models.py b/apps/downloads/tests/test_models.py index a409b51f0..c3f1d90e1 100644 --- a/apps/downloads/tests/test_models.py +++ b/apps/downloads/tests/test_models.py @@ -1,8 +1,11 @@ import datetime as dt from unittest.mock import patch -from apps.downloads.models import Release, ReleaseFile -from apps.downloads.tests.base import BaseDownloadTests +from django.db import transaction +from django.db import IntegrityError +from django.db.models import URLField +from apps.downloads.models import Release, ReleaseFile, Page, OS +from apps.downloads.tests.base import BaseDownloadTests, TestCase class DownloadModelTests(BaseDownloadTests): @@ -160,7 +163,7 @@ def test_update_supernav(self): release=self.python_3, slug=slug, name="Python 3.10", - url=f"/ftp/python/{slug}.zip", + url=f"https://www.python.org/ftp/python/{slug}.zip", download_button=True, ) @@ -179,7 +182,7 @@ def test_update_supernav(self): os=self.windows, release=release, name="MSIX", - url="/ftp/python/pymanager/pymanager-25.0.msix", + url="https://www.python.org/ftp/python/pymanager/pymanager-25.0.msix", download_button=True, ) @@ -215,7 +218,7 @@ def test_update_supernav_skips_os_without_files(self): release=self.python_3, slug=slug, name="Python 3.10", - url=f"/ftp/python/{slug}.zip", + url=f"https://www.python.org/ftp/python/{slug}.zip", download_button=True, ) @@ -247,7 +250,7 @@ def test_release_file_save_triggers_box_updates(self, mock_home, mock_sources, m os=self.windows, release=self.python_3, name="Windows installer", - url="/ftp/python/3.10.19/python-3.10.19.exe", + url="https://www.python.org/ftp/python/3.10.19/python-3.10.19.exe", download_button=True, ) @@ -268,7 +271,7 @@ def test_release_file_save_skips_unpublished_release(self, mock_home, mock_sourc os=self.windows, release=self.draft_release, name="Windows installer draft", - url="/ftp/python/9.7.2/python-9.7.2.exe", + url="https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe", ) mock_supernav.assert_not_called() @@ -289,3 +292,22 @@ def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources, mock_supernav.assert_called() mock_sources.assert_called() mock_home.assert_called() + + def test_release_file_urls_not_python_dot_org(self): + for field in ReleaseFile._meta.get_fields(): + if not isinstance(field, URLField): + continue + with self.subTest(field.name), transaction.atomic(): + kwargs = { + "url": "https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe", + # field.name may be 'url', but will replace the default value. + field.name: "https://notpython.com/python-9.7.2.txt", + } + + with self.assertRaises(IntegrityError): + ReleaseFile.objects.create( + os=self.windows, + release=self.draft_release, + name="Windows installer draft", + **kwargs, + ) From c0ba1ff801b575898ebe2c1b5b287b47cc35284c Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 25 Feb 2026 12:19:41 -0600 Subject: [PATCH 2/5] Fix linting issues --- apps/downloads/models.py | 3 +-- apps/downloads/tests/test_models.py | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/downloads/models.py b/apps/downloads/models.py index cbbef8095..57b24e47b 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -358,8 +358,7 @@ def update_boxes_on_release_file_delete(sender, instance, **kwargs): def condition_url_is_blank_or_python_dot_org(column: str): - # Creates a condition for a URLField column to either be - # blank or to start with 'http(s)://www.python.org/'. + """Conditions for a URLField column to force 'http[s]://python.org'.""" return ( models.Q(**{f"{column}__exact": ""}) | models.Q(**{f"{column}__startswith": "https://www.python.org/"}) diff --git a/apps/downloads/tests/test_models.py b/apps/downloads/tests/test_models.py index c3f1d90e1..d1d4c97b3 100644 --- a/apps/downloads/tests/test_models.py +++ b/apps/downloads/tests/test_models.py @@ -1,11 +1,11 @@ import datetime as dt from unittest.mock import patch -from django.db import transaction -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.db.models import URLField -from apps.downloads.models import Release, ReleaseFile, Page, OS -from apps.downloads.tests.base import BaseDownloadTests, TestCase + +from apps.downloads.models import OS, Release, ReleaseFile +from apps.downloads.tests.base import BaseDownloadTests class DownloadModelTests(BaseDownloadTests): @@ -202,7 +202,7 @@ def test_update_supernav_skips_os_without_files(self): """ # Arrange from apps.boxes.models import Box - from apps.downloads.models import OS, update_supernav + from apps.downloads.models import update_supernav # Create an OS without any release files OS.objects.create(name="Android", slug="android") @@ -294,7 +294,7 @@ def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources, mock_home.assert_called() def test_release_file_urls_not_python_dot_org(self): - for field in ReleaseFile._meta.get_fields(): + for field in ReleaseFile._meta.get_fields(): # noqa: SLF001 if not isinstance(field, URLField): continue with self.subTest(field.name), transaction.atomic(): From 93e2795498a604ddf5f67d3831470a174223632a Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 25 Feb 2026 14:03:23 -0600 Subject: [PATCH 3/5] Add more descriptive validation_error_message --- apps/downloads/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/downloads/models.py b/apps/downloads/models.py index 57b24e47b..86780b244 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -427,5 +427,6 @@ class Meta: & condition_url_is_blank_or_python_dot_org("sbom_spdx2_file") ), name="only_python_dot_org_urls", + violation_error_message="All file URLs must begin with 'https://www.python.org/'" ), ] From f470e140bf266bbbdb7c8b5b28e331202017b59c Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 25 Feb 2026 14:04:42 -0600 Subject: [PATCH 4/5] , --- apps/downloads/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/downloads/models.py b/apps/downloads/models.py index 86780b244..8af0a1c1d 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -427,6 +427,6 @@ class Meta: & condition_url_is_blank_or_python_dot_org("sbom_spdx2_file") ), name="only_python_dot_org_urls", - violation_error_message="All file URLs must begin with 'https://www.python.org/'" + violation_error_message="All file URLs must begin with 'https://www.python.org/'", ), ] From 03deb6499b52ec40e88b552b12cf11d6dd98d13b Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 25 Feb 2026 14:07:56 -0600 Subject: [PATCH 5/5] Also add message to migration --- .../downloads/migrations/0015_releasefile_python_dot_org_urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py index 8451401a4..a743d0908 100644 --- a/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py +++ b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py @@ -53,6 +53,7 @@ class Migration(migrations.Migration): ), ), name="only_python_dot_org_urls", + violation_error_message="All file URLs must begin with 'https://www.python.org/'", ), ), ]