summaryrefslogtreecommitdiff
path: root/releases
diff options
context:
space:
mode:
authorBaptiste Mispelon <bmispelon@gmail.com>2024-12-07 15:16:32 +0100
committerBaptiste Mispelon <bmispelon@gmail.com>2025-03-13 21:00:57 +0100
commit30322026dba9f7f180df50272fc90ba8231c5533 (patch)
tree93d88ef3ffb8d73357048399edaea8d72ebcf7d0 /releases
parentf1fca5e8a412da1bb8b5ffd11a59eac8579738bb (diff)
Improved Release model to support PEP625 changes.
Three new file fields (tarball, wheel, checksum) were added, making the link generation to these files more robust to changes like the one mandated by PEP625 (changing of file case). A new is_active boolean field was also added, making it easier to start work on a release without it being public.
Diffstat (limited to 'releases')
-rw-r--r--releases/admin.py26
-rw-r--r--releases/migrations/0005_release_artifacts.py385
-rw-r--r--releases/models.py215
-rw-r--r--releases/tests.py386
-rw-r--r--releases/urls.py2
-rw-r--r--releases/views.py8
6 files changed, 873 insertions, 149 deletions
diff --git a/releases/admin.py b/releases/admin.py
index 5b8d9426..106e8768 100644
--- a/releases/admin.py
+++ b/releases/admin.py
@@ -5,8 +5,14 @@ from .models import Release
@admin.register(Release)
class ReleaseAdmin(admin.ModelAdmin):
+ fieldsets = [
+ (None, {"fields": ["version", "is_active", "is_lts"]}),
+ ("Dates", {"fields": ["date", "eol_date"]}),
+ ("Artifacts", {"fields": ["tarball", "wheel", "checksum"]}),
+ ]
list_display = (
"version",
+ "show_is_published",
"is_lts",
"date",
"eol_date",
@@ -16,12 +22,30 @@ class ReleaseAdmin(admin.ModelAdmin):
"show_status",
"iteration",
)
- list_filter = ("status", "is_lts")
+ list_filter = ("status", "is_lts", "is_active")
ordering = ("-major", "-minor", "-micro", "-status", "-iteration")
+ def get_form(self, request, obj=None, change=False, **kwargs):
+ form_class = super().get_form(request, obj=obj, change=change, **kwargs)
+ # Add `accept` attributes to the artifact file fields to make it a bit
+ # easier to pick the right files in the browser's 'filepicker
+ extensions = {"tarball": ".tar.gz", "wheel": ".whl", "checksum": ".asc,.txt"}
+ for field, accept in extensions.items():
+ widget = form_class.base_fields[field].widget
+ widget.attrs.setdefault("accept", accept)
+ return form_class
+
@admin.display(
description="status",
ordering="status",
)
def show_status(self, obj):
return obj.get_status_display()
+
+ @admin.display(
+ boolean=True,
+ description="Published?",
+ ordering="is_active",
+ )
+ def show_is_published(self, obj):
+ return obj.is_published
diff --git a/releases/migrations/0005_release_artifacts.py b/releases/migrations/0005_release_artifacts.py
new file mode 100644
index 00000000..4ed2d575
--- /dev/null
+++ b/releases/migrations/0005_release_artifacts.py
@@ -0,0 +1,385 @@
+# Generated by Django 5.1.5 on 2025-03-11 10:29
+from functools import partial
+
+from django.db import migrations, models
+from django.db.models import Case, F, Value, When
+from django.db.models.functions import Cast, Concat
+
+import releases.models
+
+
+def default_artifact(suffix):
+ cast_to_char = partial(Cast, output_field=models.CharField())
+ return Concat(
+ Value("releases/"),
+ cast_to_char("major"),
+ Value("."),
+ cast_to_char("minor"),
+ Value("/Django-"),
+ F("version"),
+ Value(suffix),
+ )
+
+
+def populate_artifacts(apps, schema_editor):
+ """Populate tarball, wheel and checksum fields based on historical data.
+
+ The flag "is_active" should always be True for existing releases (defaults to False
+ for future releases).
+
+ """
+ Release = apps.get_model("releases", "Release")
+
+ Release.objects.filter(date__isnull=False).update(
+ is_active=True,
+ # releases/{major}.{minor}/Django-{version}.tar.gz
+ tarball=Case(
+ *(When(version=k, then=Value(v)) for k, v in VERSION_TO_TARBALL.items()),
+ default=default_artifact(".tar.gz")
+ ),
+ # releases/{major}.{minor}/Django-{version}-py3-none-any.whl
+ wheel=Case(
+ *(When(version=k, then=Value(v)) for k, v in VERSION_TO_WHEEL.items()),
+ default=default_artifact("-py3-none-any.whl")
+ ),
+ # pgp/Django-{version}.checksum.txt
+ checksum=Case(
+ *(When(version=k, then=Value(v)) for k, v in VERSION_TO_CHECKSUM.items()),
+ default=Concat(Value("pgp/Django-"), F("version"), Value(".checksum.txt"))
+ ),
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("releases", "0001_squashed_0004_make_release_date_nullable"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="release",
+ name="checksum",
+ field=models.FileField(
+ blank=True,
+ storage=releases.models.get_storage,
+ upload_to=releases.models.upload_to_checksum,
+ verbose_name="Signed checksum as a .asc file",
+ ),
+ ),
+ migrations.AddField(
+ model_name="release",
+ name="is_active",
+ field=models.BooleanField(
+ default=False,
+ help_text="Set this release as active. A release is considered active only if its date is today or in the past and this flag is enabled. Enable this flag when the release is available on PyPI.",
+ ),
+ ),
+ migrations.AddField(
+ model_name="release",
+ name="tarball",
+ field=models.FileField(
+ blank=True,
+ storage=releases.models.get_storage,
+ upload_to=releases.models.upload_to_artifact,
+ verbose_name="Tarball artifact as a .tar.gz file",
+ ),
+ ),
+ migrations.AddField(
+ model_name="release",
+ name="wheel",
+ field=models.FileField(
+ blank=True,
+ storage=releases.models.get_storage,
+ upload_to=releases.models.upload_to_artifact,
+ verbose_name="Wheel artifact as a .whl file",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="release",
+ name="is_lts",
+ field=models.BooleanField(
+ default=False,
+ help_text='Is this a release for an <abbr title="Long Term Support">LTS</abbr> Django version (e.g. 5.2a1, 5.2, 5.2.4)?',
+ verbose_name="Long Term Support",
+ ),
+ ),
+ migrations.RunPython(populate_artifacts, migrations.RunPython.noop),
+ ]
+
+
+# These are put at the end to avoid excessive scrolling when trying to read
+# the actual migration operations
+VERSION_TO_TARBALL = {
+ "1.0-alpha-2": "releases/1.0/Django-1.0-alpha_2.tar.gz",
+ "1.0-beta-1": "releases/1.0/Django-1.0-beta_1.tar.gz",
+ "1.0-beta-2": "releases/1.0/Django-1.0-beta_2.tar.gz",
+ "1.0.1": "releases/1.0.1/Django-1.0.1-final.tar.gz",
+ "1.0.1-beta-1": "releases/1.0.1/Django-1.0.1_beta_1.tar.gz",
+ "1.0.2": "releases/1.0.2/Django-1.0.2-final.tar.gz",
+ "1.0.3": "releases/1.0.3/Django-1.0.3.tar.gz",
+ "1.1.1": "releases/1.1.1/Django-1.1.1.tar.gz",
+ "1.4-beta-1": "releases/1.4/Django-1.4b1.tar.gz",
+ "1.4-rc-1": "releases/1.4/Django-1.4c1.tar.gz",
+ "1.4-rc-2": "releases/1.4/Django-1.4c2.tar.gz",
+ "1.7.b4": "releases/1.7/Django-1.7b4.tar.gz",
+ "5.2": "",
+}
+VERSION_TO_WHEEL = {
+ "0.90": "",
+ "0.91": "",
+ "0.91.1": "",
+ "0.91.2": "",
+ "0.91.3": "",
+ "0.95": "",
+ "0.95.1": "",
+ "0.95.2": "",
+ "0.95.3": "",
+ "0.95.4": "",
+ "0.96": "",
+ "0.96.1": "",
+ "0.96.2": "",
+ "0.96.3": "",
+ "0.96.4": "",
+ "0.96.5": "",
+ "1.0": "",
+ "1.0-alpha": "",
+ "1.0-alpha-2": "",
+ "1.0-beta-1": "",
+ "1.0-beta-2": "",
+ "1.0-rc_1": "",
+ "1.0.1": "",
+ "1.0.1-beta-1": "",
+ "1.0.2": "",
+ "1.0.3": "",
+ "1.0.4": "",
+ "1.1": "",
+ "1.1-alpha-1": "",
+ "1.1-beta-1": "",
+ "1.1-rc-1": "",
+ "1.1.1": "",
+ "1.1.2": "",
+ "1.1.3": "",
+ "1.1.4": "",
+ "1.10": "releases/1.10/Django-1.10-py2.py3-none-any.whl",
+ "1.10.1": "releases/1.10/Django-1.10.1-py2.py3-none-any.whl",
+ "1.10.2": "releases/1.10/Django-1.10.2-py2.py3-none-any.whl",
+ "1.10.3": "releases/1.10/Django-1.10.3-py2.py3-none-any.whl",
+ "1.10.4": "releases/1.10/Django-1.10.4-py2.py3-none-any.whl",
+ "1.10.5": "releases/1.10/Django-1.10.5-py2.py3-none-any.whl",
+ "1.10.6": "releases/1.10/Django-1.10.6-py2.py3-none-any.whl",
+ "1.10.7": "releases/1.10/Django-1.10.7-py2.py3-none-any.whl",
+ "1.10.8": "releases/1.10/Django-1.10.8-py2.py3-none-any.whl",
+ "1.10a1": "releases/1.10/Django-1.10a1-py2.py3-none-any.whl",
+ "1.10b1": "releases/1.10/Django-1.10b1-py2.py3-none-any.whl",
+ "1.10rc1": "releases/1.10/Django-1.10rc1-py2.py3-none-any.whl",
+ "1.11": "releases/1.11/Django-1.11-py2.py3-none-any.whl",
+ "1.11.1": "releases/1.11/Django-1.11.1-py2.py3-none-any.whl",
+ "1.11.10": "releases/1.11/Django-1.11.10-py2.py3-none-any.whl",
+ "1.11.11": "releases/1.11/Django-1.11.11-py2.py3-none-any.whl",
+ "1.11.12": "releases/1.11/Django-1.11.12-py2.py3-none-any.whl",
+ "1.11.13": "releases/1.11/Django-1.11.13-py2.py3-none-any.whl",
+ "1.11.14": "releases/1.11/Django-1.11.14-py2.py3-none-any.whl",
+ "1.11.15": "releases/1.11/Django-1.11.15-py2.py3-none-any.whl",
+ "1.11.16": "releases/1.11/Django-1.11.16-py2.py3-none-any.whl",
+ "1.11.17": "releases/1.11/Django-1.11.17-py2.py3-none-any.whl",
+ "1.11.18": "releases/1.11/Django-1.11.18-py2.py3-none-any.whl",
+ "1.11.19": "releases/1.11/Django-1.11.19-py2.py3-none-any.whl",
+ "1.11.2": "releases/1.11/Django-1.11.2-py2.py3-none-any.whl",
+ "1.11.20": "releases/1.11/Django-1.11.20-py2.py3-none-any.whl",
+ "1.11.21": "releases/1.11/Django-1.11.21-py2.py3-none-any.whl",
+ "1.11.22": "releases/1.11/Django-1.11.22-py2.py3-none-any.whl",
+ "1.11.23": "releases/1.11/Django-1.11.23-py2.py3-none-any.whl",
+ "1.11.24": "releases/1.11/Django-1.11.24-py2.py3-none-any.whl",
+ "1.11.25": "releases/1.11/Django-1.11.25-py2.py3-none-any.whl",
+ "1.11.26": "releases/1.11/Django-1.11.26-py2.py3-none-any.whl",
+ "1.11.27": "releases/1.11/Django-1.11.27-py2.py3-none-any.whl",
+ "1.11.28": "releases/1.11/Django-1.11.28-py2.py3-none-any.whl",
+ "1.11.29": "releases/1.11/Django-1.11.29-py2.py3-none-any.whl",
+ "1.11.3": "releases/1.11/Django-1.11.3-py2.py3-none-any.whl",
+ "1.11.4": "releases/1.11/Django-1.11.4-py2.py3-none-any.whl",
+ "1.11.5": "releases/1.11/Django-1.11.5-py2.py3-none-any.whl",
+ "1.11.6": "releases/1.11/Django-1.11.6-py2.py3-none-any.whl",
+ "1.11.7": "releases/1.11/Django-1.11.7-py2.py3-none-any.whl",
+ "1.11.8": "releases/1.11/Django-1.11.8-py2.py3-none-any.whl",
+ "1.11.9": "releases/1.11/Django-1.11.9-py2.py3-none-any.whl",
+ "1.11a1": "releases/1.11/Django-1.11a1-py2.py3-none-any.whl",
+ "1.11b1": "releases/1.11/Django-1.11b1-py2.py3-none-any.whl",
+ "1.11rc1": "releases/1.11/Django-1.11rc1-py2.py3-none-any.whl",
+ "1.2": "",
+ "1.2-alpha-1": "",
+ "1.2-beta-1": "",
+ "1.2-rc-1": "",
+ "1.2.1": "",
+ "1.2.2": "",
+ "1.2.3": "",
+ "1.2.4": "",
+ "1.2.5": "",
+ "1.2.6": "",
+ "1.2.7": "",
+ "1.3": "",
+ "1.3-alpha-1": "",
+ "1.3-beta-1": "",
+ "1.3-rc-1": "",
+ "1.3.1": "",
+ "1.3.2": "",
+ "1.3.3": "",
+ "1.3.4": "",
+ "1.3.5": "",
+ "1.3.6": "",
+ "1.3.7": "",
+ "1.4": "",
+ "1.4-alpha-1": "",
+ "1.4-beta-1": "",
+ "1.4-rc-1": "",
+ "1.4-rc-2": "",
+ "1.4.1": "",
+ "1.4.10": "",
+ "1.4.11": "",
+ "1.4.12": "",
+ "1.4.13": "releases/1.4/Django-1.4.13-py2-none-any.whl",
+ "1.4.14": "",
+ "1.4.15": "",
+ "1.4.16": "",
+ "1.4.17": "",
+ "1.4.18": "",
+ "1.4.19": "",
+ "1.4.2": "",
+ "1.4.20": "",
+ "1.4.21": "",
+ "1.4.22": "",
+ "1.4.3": "",
+ "1.4.4": "",
+ "1.4.5": "",
+ "1.4.6": "",
+ "1.4.7": "",
+ "1.4.8": "",
+ "1.4.9": "",
+ "1.5": "",
+ "1.5.1": "",
+ "1.5.10": "",
+ "1.5.11": "",
+ "1.5.12": "releases/1.5/Django-1.5.12-py2.py3-none-any.whl",
+ "1.5.2": "releases/1.5/Django-1.5.2-py2.py3-none-any.whl",
+ "1.5.3": "",
+ "1.5.4": "",
+ "1.5.5": "",
+ "1.5.6": "",
+ "1.5.7": "",
+ "1.5.8": "releases/1.5/Django-1.5.8-py2.py3-none-any.whl",
+ "1.5.9": "",
+ "1.5a1": "",
+ "1.5b1": "",
+ "1.5b2": "",
+ "1.5c1": "",
+ "1.5c2": "",
+ "1.6": "releases/1.6/Django-1.6-py2.py3-none-any.whl",
+ "1.6.1": "releases/1.6/Django-1.6.1-py2.py3-none-any.whl",
+ "1.6.10": "releases/1.6/Django-1.6.10-py2.py3-none-any.whl",
+ "1.6.11": "releases/1.6/Django-1.6.11-py2.py3-none-any.whl",
+ "1.6.2": "releases/1.6/Django-1.6.2-py2.py3-none-any.whl",
+ "1.6.3": "releases/1.6/Django-1.6.3-py2.py3-none-any.whl",
+ "1.6.4": "releases/1.6/Django-1.6.4-py2.py3-none-any.whl",
+ "1.6.5": "releases/1.6/Django-1.6.5-py2.py3-none-any.whl",
+ "1.6.6": "releases/1.6/Django-1.6.6-py2.py3-none-any.whl",
+ "1.6.7": "releases/1.6/Django-1.6.7-py2.py3-none-any.whl",
+ "1.6.8": "releases/1.6/Django-1.6.8-py2.py3-none-any.whl",
+ "1.6.9": "releases/1.6/Django-1.6.9-py2.py3-none-any.whl",
+ "1.6a1": "releases/1.6/Django-1.6a1-py27-none-any.whl",
+ "1.6b1": "",
+ "1.6b2": "releases/1.6/Django-1.6b2-py2.py3-none-any.whl",
+ "1.6b3": "",
+ "1.6b4": "",
+ "1.6c1": "",
+ "1.7": "releases/1.7/Django-1.7-py2.py3-none-any.whl",
+ "1.7.1": "releases/1.7/Django-1.7.1-py2.py3-none-any.whl",
+ "1.7.10": "releases/1.7/Django-1.7.10-py2.py3-none-any.whl",
+ "1.7.11": "releases/1.7/Django-1.7.11-py2.py3-none-any.whl",
+ "1.7.2": "releases/1.7/Django-1.7.2-py2.py3-none-any.whl",
+ "1.7.3": "releases/1.7/Django-1.7.3-py2.py3-none-any.whl",
+ "1.7.4": "releases/1.7/Django-1.7.4-py2.py3-none-any.whl",
+ "1.7.5": "releases/1.7/Django-1.7.5-py2.py3-none-any.whl",
+ "1.7.6": "releases/1.7/Django-1.7.6-py2.py3-none-any.whl",
+ "1.7.7": "releases/1.7/Django-1.7.7-py2.py3-none-any.whl",
+ "1.7.8": "releases/1.7/Django-1.7.8-py2.py3-none-any.whl",
+ "1.7.9": "releases/1.7/Django-1.7.9-py2.py3-none-any.whl",
+ "1.7.b4": "releases/1.7/Django-1.7b4-py2.py3-none-any.whl",
+ "1.7a1": "releases/1.7/Django-1.7a1-py2.py3-none-any.whl",
+ "1.7a2": "releases/1.7/Django-1.7a2-py2.py3-none-any.whl",
+ "1.7b1": "releases/1.7/Django-1.7b1-py2.py3-none-any.whl",
+ "1.7b2": "releases/1.7/Django-1.7b2-py2.py3-none-any.whl",
+ "1.7b3": "releases/1.7/Django-1.7b3-py2.py3-none-any.whl",
+ "1.7c1": "",
+ "1.7c2": "",
+ "1.7c3": "",
+ "1.8": "releases/1.8/Django-1.8-py2.py3-none-any.whl",
+ "1.8.1": "releases/1.8/Django-1.8.1-py2.py3-none-any.whl",
+ "1.8.10": "releases/1.8/Django-1.8.10-py2.py3-none-any.whl",
+ "1.8.11": "releases/1.8/Django-1.8.11-py2.py3-none-any.whl",
+ "1.8.12": "releases/1.8/Django-1.8.12-py2.py3-none-any.whl",
+ "1.8.13": "releases/1.8/Django-1.8.13-py2.py3-none-any.whl",
+ "1.8.14": "releases/1.8/Django-1.8.14-py2.py3-none-any.whl",
+ "1.8.15": "releases/1.8/Django-1.8.15-py2.py3-none-any.whl",
+ "1.8.16": "releases/1.8/Django-1.8.16-py2.py3-none-any.whl",
+ "1.8.17": "releases/1.8/Django-1.8.17-py2.py3-none-any.whl",
+ "1.8.18": "releases/1.8/Django-1.8.18-py2.py3-none-any.whl",
+ "1.8.19": "releases/1.8/Django-1.8.19-py2.py3-none-any.whl",
+ "1.8.2": "releases/1.8/Django-1.8.2-py2.py3-none-any.whl",
+ "1.8.3": "releases/1.8/Django-1.8.3-py2.py3-none-any.whl",
+ "1.8.4": "releases/1.8/Django-1.8.4-py2.py3-none-any.whl",
+ "1.8.5": "releases/1.8/Django-1.8.5-py2.py3-none-any.whl",
+ "1.8.6": "releases/1.8/Django-1.8.6-py2.py3-none-any.whl",
+ "1.8.7": "releases/1.8/Django-1.8.7-py2.py3-none-any.whl",
+ "1.8.8": "releases/1.8/Django-1.8.8-py2.py3-none-any.whl",
+ "1.8.9": "releases/1.8/Django-1.8.9-py2.py3-none-any.whl",
+ "1.8a1": "releases/1.8/Django-1.8a1-py2.py3-none-any.whl",
+ "1.8b1": "releases/1.8/Django-1.8b1-py2.py3-none-any.whl",
+ "1.8b2": "releases/1.8/Django-1.8b2-py2.py3-none-any.whl",
+ "1.8c1": "releases/1.8/Django-1.8c1-py2.py3-none-any.whl",
+ "1.9": "releases/1.9/Django-1.9-py2.py3-none-any.whl",
+ "1.9.1": "releases/1.9/Django-1.9.1-py2.py3-none-any.whl",
+ "1.9.10": "releases/1.9/Django-1.9.10-py2.py3-none-any.whl",
+ "1.9.11": "releases/1.9/Django-1.9.11-py2.py3-none-any.whl",
+ "1.9.12": "releases/1.9/Django-1.9.12-py2.py3-none-any.whl",
+ "1.9.13": "releases/1.9/Django-1.9.13-py2.py3-none-any.whl",
+ "1.9.2": "releases/1.9/Django-1.9.2-py2.py3-none-any.whl",
+ "1.9.3": "releases/1.9/Django-1.9.3-py2.py3-none-any.whl",
+ "1.9.4": "releases/1.9/Django-1.9.4-py2.py3-none-any.whl",
+ "1.9.5": "releases/1.9/Django-1.9.5-py2.py3-none-any.whl",
+ "1.9.6": "releases/1.9/Django-1.9.6-py2.py3-none-any.whl",
+ "1.9.7": "releases/1.9/Django-1.9.7-py2.py3-none-any.whl",
+ "1.9.8": "releases/1.9/Django-1.9.8-py2.py3-none-any.whl",
+ "1.9.9": "releases/1.9/Django-1.9.9-py2.py3-none-any.whl",
+ "1.9a1": "releases/1.9/Django-1.9a1-py2.py3-none-any.whl",
+ "1.9b1": "releases/1.9/Django-1.9b1-py2.py3-none-any.whl",
+ "1.9rc1": "releases/1.9/Django-1.9rc1-py2.py3-none-any.whl",
+ "1.9rc2": "releases/1.9/Django-1.9rc2-py2.py3-none-any.whl",
+ "5.2": "",
+}
+VERSION_TO_CHECKSUM = {
+ "0.90": "",
+ "0.91": "",
+ "0.91.1": "",
+ "0.91.2": "",
+ "0.91.3": "",
+ "0.95": "",
+ "0.95.1": "",
+ "0.95.2": "",
+ "0.95.3": "",
+ "0.95.4": "",
+ "0.96": "",
+ "0.96.1": "",
+ "0.96.2": "",
+ "0.96.3": "",
+ "1.0-alpha": "",
+ "1.0-alpha-2": "",
+ "1.0-beta-1": "",
+ "1.0-beta-2": "",
+ "1.0.1": "pgp/Django-1.0.1-final.checksum.txt",
+ "1.0.2": "pgp/Django-1.0.2-final.checksum.txt",
+ "1.4-beta-1": "pgp/Django-1.4b1.checksum.txt",
+ "1.4-rc-1": "pgp/Django-1.4c1.checksum.txt",
+ "1.4-rc-2": "pgp/Django-1.4c2.checksum.txt",
+ "1.7.b4": "pgp/Django-1.7b4.checksum.txt",
+ "5.2": "",
+}
diff --git a/releases/models.py b/releases/models.py
index d381cc50..39a964fa 100644
--- a/releases/models.py
+++ b/releases/models.py
@@ -1,7 +1,12 @@
import datetime
+import re
+from pathlib import Path
from django.conf import settings
from django.core.cache import cache
+from django.core.exceptions import ValidationError
+from django.core.files.storage import FileSystemStorage
+from django.core.validators import RegexValidator
from django.db import models
from django.utils.functional import cached_property
from django.utils.version import get_complete_version, get_main_version
@@ -32,9 +37,11 @@ def get_version(version=None):
class ReleaseManager(models.Manager):
- def active(self, at=None):
+ def published(self, at=None):
"""
- List of active releases at a given date (today by default).
+ List of published releases at a given date (today by default).
+
+ A published release has a suitable publication date and is active.
The resulting queryset is sorted by decreasing version number.
@@ -47,7 +54,7 @@ class ReleaseManager(models.Manager):
# .exclude(eol_date__lte=at) includes releases where eol_date IS NULL
# because a version without an end of life date is still supported.
return (
- self.filter(major__gte=1, date__lte=at)
+ self.filter(major__gte=1, date__lte=at, is_active=True)
.exclude(eol_date__lte=at)
.order_by("-major", "-minor", "-micro", "-status")
)
@@ -56,7 +63,7 @@ class ReleaseManager(models.Manager):
"""
List of supported final releases.
"""
- return self.active(at).filter(status="f")
+ return self.published(at).filter(status="f")
def unsupported(self, at=None):
"""
@@ -115,7 +122,7 @@ class ReleaseManager(models.Manager):
"""
Preview release or None if there isn't a preview release currently.
"""
- return self.active(at).exclude(status="f").first()
+ return self.published(at).exclude(status="f").first()
def current_version(self):
current_version = cache.get(Release.DEFAULT_CACHE_KEY, None)
@@ -133,6 +140,26 @@ class ReleaseManager(models.Manager):
return current_version
+def get_storage():
+ """
+ Return a FileSystemStorage that allows file name overwrites.
+
+ The actual file name of release artifacts (tarball, wheel, ...) should not
+ be modified on upload (i.e. no prefix should be added).
+ """
+ return FileSystemStorage(allow_overwrite=True)
+
+
+def upload_to_artifact(release, filename):
+ major, minor = release.version_tuple[:2]
+ return f"releases/{major}.{minor}/{filename}"
+
+
+def upload_to_checksum(release, filename):
+ version = get_version(release.version_tuple)
+ return f"pgp/Django-{version}.checksum.txt"
+
+
class Release(models.Model):
DEFAULT_CACHE_KEY = "%s_django_version" % settings.CACHE_MIDDLEWARE_KEY_PREFIX
STATUS_CHOICES = (
@@ -149,6 +176,14 @@ class Release(models.Model):
}
version = models.CharField(max_length=16, primary_key=True)
+ is_active = models.BooleanField(
+ help_text=(
+ "Set this release as active. A release is considered active only "
+ "if its date is today or in the past and this flag is enabled. "
+ "Enable this flag when the release is available on PyPI."
+ ),
+ default=False,
+ )
date = models.DateField(
"Release date",
null=True,
@@ -172,8 +207,33 @@ class Release(models.Model):
micro = models.PositiveSmallIntegerField(editable=False)
status = models.CharField(max_length=1, choices=STATUS_CHOICES, editable=False)
iteration = models.PositiveSmallIntegerField(editable=False)
-
- is_lts = models.BooleanField("Long term support release", default=False)
+ is_lts = models.BooleanField(
+ "Long Term Support",
+ help_text=(
+ 'Is this a release for an <abbr title="Long Term Support">LTS</abbr> Django '
+ "version (e.g. 5.2a1, 5.2, 5.2.4)?"
+ ),
+ default=False,
+ )
+ # Artifacts.
+ tarball = models.FileField(
+ "Tarball artifact as a .tar.gz file",
+ storage=get_storage,
+ upload_to=upload_to_artifact,
+ blank=True,
+ )
+ wheel = models.FileField(
+ "Wheel artifact as a .whl file",
+ storage=get_storage,
+ upload_to=upload_to_artifact,
+ blank=True,
+ )
+ checksum = models.FileField(
+ "Signed checksum as a .asc file",
+ storage=get_storage,
+ upload_to=upload_to_checksum,
+ blank=True,
+ )
objects = ReleaseManager()
@@ -183,7 +243,7 @@ class Release(models.Model):
cache.delete(self.DEFAULT_CACHE_KEY)
super().save(*args, **kwargs)
# Each micro release EOLs the previous one in the same series.
- if self.status == "f" and self.micro > 0:
+ if self.status == "f" and self.micro > 0 and self.is_active:
(
type(self)
.objects.filter(
@@ -195,6 +255,14 @@ class Release(models.Model):
def __str__(self):
return self.version
+ @property
+ def is_published(self):
+ return (
+ self.is_active
+ and self.date is not None
+ and self.date <= datetime.date.today()
+ )
+
@cached_property
def version_tuple(self):
"""Return a tuple in the format of django.VERSION."""
@@ -212,109 +280,36 @@ class Release(models.Model):
version.append(0)
return tuple(version)
- def get_redirect_url(self, kind):
- directory = "%d.%d" % self.version_tuple[:2]
- # Django gained PEP 386 numbering in 1.4b1.
- if self.version_tuple >= (1, 4, 0, "beta", 0):
- actual_version = get_version(self.version_tuple)
- else:
- actual_version = self.version
-
- if kind == "tarball":
- pattern = "%(media)sreleases/%(directory)s/Django-%(version)s.tar.gz"
+ def clean(self):
+ if self.is_published and not self.tarball:
+ raise ValidationError(
+ {"tarball": "This field is required when the release is active."}
+ )
- elif kind == "checksum":
- if self.version_tuple[:3] >= (1, 0, 4):
- pattern = "%(media)spgp/Django-%(version)s.checksum.txt"
- else:
- raise ValueError("No checksum for this version")
- else:
- raise ValueError("Unknown file")
+ if (self.tarball or self.wheel) and not self.checksum:
+ raise ValidationError(
+ {
+ "checksum": (
+ "This field is required when an artifact has been uploaded."
+ )
+ }
+ )
- return pattern % {
- "media": settings.MEDIA_URL,
- "directory": directory,
- "version": actual_version,
- "major": self.version_tuple[0],
- "minor": self.version_tuple[1],
- }
+ if self.tarball:
+ try:
+ self.validate_artifact_name(self.tarball.name, suffix=".tar.gz")
+ except ValidationError as e:
+ raise ValidationError({"tarball": e})
+ if self.wheel:
+ try:
+ self.validate_artifact_name(self.wheel.name, suffix="-py3-none-any.whl")
+ except ValidationError as e:
+ raise ValidationError({"wheel": e})
-def create_releases_up_to_1_5():
- if Release.objects.exists():
- raise Exception("Releases already exist, aborting.")
- versions = [ # extracted from the redirects table
- "0.90",
- "0.91",
- "0.91.1",
- "0.91.2",
- "0.91.3",
- "0.95",
- "0.95.1",
- "0.95.2",
- "0.95.3",
- "0.95.4",
- "0.96",
- "0.96.1",
- "0.96.2",
- "0.96.3",
- "0.96.4",
- "0.96.5",
- "1.0-alpha",
- "1.0-alpha-2",
- "1.0-beta-1",
- "1.0-beta-2",
- "1.0-rc_1",
- "1.0",
- "1.0.1-beta-1",
- "1.0.1",
- "1.0.2",
- "1.0.3",
- "1.0.4",
- "1.1-rc-1",
- "1.1.1",
- "1.1.2",
- "1.1.3",
- "1.1.4",
- "1.1",
- "1.2-alpha-1",
- "1.2-beta-1",
- "1.2-rc-1",
- "1.2",
- "1.2.1",
- "1.2.2",
- "1.2.3",
- "1.2.4",
- "1.2.5",
- "1.2.6",
- "1.2.7",
- "1.3-alpha-1",
- "1.3-beta-1",
- "1.3-rc-1",
- "1.3",
- "1.3.1",
- "1.3.2",
- "1.3.3",
- "1.3.4",
- "1.3.5",
- "1.3.6",
- "1.3.7",
- "1.4-alpha-1",
- "1.4-beta-1",
- "1.4-rc-1",
- "1.4-rc-2",
- "1.4",
- "1.4.1",
- "1.4.2",
- "1.4.3",
- "1.4.4",
- "1.4.5",
- "1.5a1",
- "1.5b1",
- "1.5b2",
- "1.5c1",
- "1.5c2",
- "1.5",
- ]
- for version in versions:
- Release.objects.create(version=version)
+ def validate_artifact_name(self, name, suffix):
+ name = Path(name).name # strip any folder name if present
+ version = get_version(self.version_tuple)
+ regex = f"^[Dd]jango-{re.escape(version)}{re.escape(suffix)}$"
+ message = f"Filename {name} does not match pattern {regex}."
+ return RegexValidator(regex, message=message, code="invalid_name")(name)
diff --git a/releases/tests.py b/releases/tests.py
index 9796e879..c6206948 100644
--- a/releases/tests.py
+++ b/releases/tests.py
@@ -1,35 +1,20 @@
import datetime
+import re
-from django.contrib.redirects.models import Redirect
-from django.test import TestCase, override_settings
+from django.contrib import admin
+from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
+from django.test import SimpleTestCase, TestCase, override_settings
from django.urls import reverse
from django.utils.safestring import SafeString
from members.models import MEMBERSHIP_LEVELS, PLATINUM_MEMBERSHIP, CorporateMember
-from .models import Release, create_releases_up_to_1_5
+from .models import Release, upload_to_artifact, upload_to_checksum
from .templatetags.date_format import isodate
from .templatetags.release_notes import get_latest_micro_release, release_notes
-class LegacyURLsTests(TestCase):
- fixtures = ["redirects-downloads"] # provided by the legacy app
-
- def test_legacy_redirects(self):
- # Save list of redirects, then wipe them
- redirects = list(Redirect.objects.values_list("old_path", "new_path"))
- Redirect.objects.all().delete()
- # Ensure the releases app faithfully reproduces the redirects
- create_releases_up_to_1_5()
- for old_path, new_path in redirects:
- response = self.client.get(old_path, follow=False)
- location = response.get("Location", "")
- if location.startswith("http://testserver"):
- location = location[17:]
- self.assertEqual(location, new_path)
- self.assertEqual(response.status_code, 301)
-
-
class TestTemplateTags(TestCase):
def test_get_latest_micro_release(self):
Release.objects.create(major=1, minor=8, micro=0, is_lts=True, version="1.8")
@@ -85,36 +70,59 @@ class TestReleaseManager(TestCase):
day = datetime.timedelta(1)
Release.objects.create(
version="1.4",
+ is_active=True,
is_lts=True,
date=today - 450 * day,
eol_date=today + 50 * day,
)
Release.objects.create(
- version="1.5", date=today - 350 * day, eol_date=today - 150 * day
+ version="1.5",
+ is_active=True,
+ date=today - 350 * day,
+ eol_date=today - 150 * day,
)
Release.objects.create(
- version="1.6", date=today - 250 * day, eol_date=today - 50 * day
+ version="1.6",
+ is_active=True,
+ date=today - 250 * day,
+ eol_date=today - 50 * day,
)
Release.objects.create(
- version="1.7", date=today - 150 * day, eol_date=today + 50 * day
+ version="1.7",
+ is_active=True,
+ date=today - 150 * day,
+ eol_date=today + 50 * day,
)
Release.objects.create(
- version="1.8a1", date=today - 80 * day, eol_date=today - 65 * day
+ version="1.8a1",
+ is_active=True,
+ date=today - 80 * day,
+ eol_date=today - 65 * day,
)
Release.objects.create(
version="1.8b1",
is_lts=True,
+ is_active=True,
date=today - 65 * day,
eol_date=today - 50 * day,
)
Release.objects.create(
- version="1.8", is_lts=True, date=today - 50 * day, eol_date=today
+ version="1.8",
+ is_lts=True,
+ is_active=True,
+ date=today - 50 * day,
+ eol_date=today,
+ )
+ Release.objects.create(
+ version="1.8.1", is_active=True, is_lts=True, date=today, eol_date=None
+ )
+ Release.objects.create(version="1.9", is_active=True, date=None, eol_date=None)
+ Release.objects.create(
+ version="1.10", is_active=False, date=today, eol_date=None
)
- Release.objects.create(version="1.8.1", is_lts=True, date=today, eol_date=None)
- Release.objects.create(version="1.9", date=None, eol_date=None)
- def test_active(self):
- active_versions = Release.objects.active().values_list("version", flat=True)
+ def test_published(self):
+ active_versions = Release.objects.published().values_list("version", flat=True)
self.assertEqual(list(active_versions), ["1.8.1", "1.7", "1.4"])
def test_supported(self):
@@ -152,21 +160,333 @@ class TestReleaseManager(TestCase):
def test_preview(self):
self.assertEqual(Release.objects.preview(), None)
Release.objects.create(
- version="1.9b2", date=datetime.date.today(), eol_date=None
+ version="1.9b2", is_active=True, date=datetime.date.today(), eol_date=None
)
self.assertEqual(Release.objects.preview().version, "1.9b2")
+class ReleaseTestCase(TestCase):
+ def test_is_published(self):
+ today = datetime.date.today()
+ future = today + datetime.timedelta(days=1)
+ past = today - datetime.timedelta(days=1)
+ cases = [
+ ({"date": None, "is_active": True}, False),
+ ({"date": None, "is_active": False}, False),
+ ({"date": today, "is_active": True}, True),
+ ({"date": today, "is_active": False}, False),
+ ({"date": past, "is_active": True}, True),
+ ({"date": past, "is_active": False}, False),
+ ({"date": future, "is_active": True}, False),
+ ({"date": future, "is_active": False}, False),
+ ]
+ for i, (params, expected) in enumerate(cases):
+ with self.subTest(**params, saved=False):
+ release = Release(version="1.0", **params)
+ self.assertIs(release.is_published, expected)
+ with self.subTest(**params, saved=True):
+ release = Release.objects.create(version=f"{i}.0", **params)
+ self.assertIs(release.is_published, expected)
+
+ def test_save_sets_eol_date(self):
+ today = datetime.date.today()
+ future = today + datetime.timedelta(days=1)
+ past = today - datetime.timedelta(days=1)
+ cases = [
+ ({"date": None, "is_active": True}, None),
+ ({"date": None, "is_active": False}, None),
+ ({"date": today, "is_active": True}, today),
+ ({"date": today, "is_active": False}, None),
+ ({"date": past, "is_active": True}, past),
+ ({"date": past, "is_active": False}, None),
+ ({"date": future, "is_active": True}, future),
+ ({"date": future, "is_active": False}, None),
+ ]
+ for i, (params, expected_eol_date) in enumerate(cases):
+ previous = Release.objects.create(version=f"{i}.1.1")
+ release = Release(version=f"{i}.1.2")
+ for k, v in params.items():
+ setattr(release, k, v)
+ release.save()
+ previous.refresh_from_db()
+ with self.subTest(**params):
+ self.assertEqual(previous.eol_date, expected_eol_date)
+
+
+class ReleaseUploadToTestCase(SimpleTestCase):
+ def test_upload_to_artifact(self):
+ for version, filename, expected in [
+ ("5.2", "django-5.2.tar.gz", "releases/5.2/django-5.2.tar.gz"),
+ ("5.2", "django-5.2.tar.xz", "releases/5.2/django-5.2.tar.xz"),
+ ("5.2", "Django-5.2.tar.gz", "releases/5.2/Django-5.2.tar.gz"),
+ ("5.2", "DJANGO-5.2.tar.gz", "releases/5.2/DJANGO-5.2.tar.gz"),
+ ("5.2.1", "django-5.2.1.tar.gz", "releases/5.2/django-5.2.1.tar.gz"),
+ ("5.2a1", "django-5.2a1.tar.gz", "releases/5.2/django-5.2a1.tar.gz"),
+ ("5.2b2", "django-5.2b2.tar.gz", "releases/5.2/django-5.2b2.tar.gz"),
+ ("5.2rc3", "django-5.2rc3.tar.gz", "releases/5.2/django-5.2rc3.tar.gz"),
+ ("5.2", "django-5.2-py3-none.whl", "releases/5.2/django-5.2-py3-none.whl"),
+ ("5.2", "Django-5.2-py3-none.whl", "releases/5.2/Django-5.2-py3-none.whl"),
+ ("5.2", "DJANGO-5.2-py3-none.whl", "releases/5.2/DJANGO-5.2-py3-none.whl"),
+ (
+ "5.2.1",
+ "django-5.2.1-py3-none.whl",
+ "releases/5.2/django-5.2.1-py3-none.whl",
+ ),
+ (
+ "5.2a1",
+ "django-5.2a1-py3-none.whl",
+ "releases/5.2/django-5.2a1-py3-none.whl",
+ ),
+ (
+ "5.2b2",
+ "django-5.2b2-py3-none.whl",
+ "releases/5.2/django-5.2b2-py3-none.whl",
+ ),
+ ]:
+ with self.subTest(version=version, filename=filename):
+ self.assertEqual(
+ upload_to_artifact(Release(version=version), filename=filename),
+ expected,
+ )
+
+ def test_upload_to_checksum(self):
+ for version, expected in [
+ ("5.2", "pgp/Django-5.2.checksum.txt"),
+ ("5.2.1", "pgp/Django-5.2.1.checksum.txt"),
+ ("5.2a1", "pgp/Django-5.2a1.checksum.txt"),
+ ("5.2b2", "pgp/Django-5.2b2.checksum.txt"),
+ ]:
+ with self.subTest(version=version):
+ self.assertEqual(
+ # filename should not matter
+ upload_to_checksum(Release(version=version), filename=None),
+ expected,
+ )
+
+
+class ReleaseAdminFormTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.form_class = admin.site.get_model_admin(Release).get_form(request=None)
+
+ def test_non_published_releases_tarball_not_required(self):
+ today = datetime.date.today()
+ future = today + datetime.timedelta(days=1)
+ past = today - datetime.timedelta(days=1)
+ cases = [
+ ({"date": None, "is_active": True}, False),
+ ({"date": None, "is_active": False}, False),
+ ({"date": today, "is_active": True}, True),
+ ({"date": today, "is_active": False}, False),
+ ({"date": past, "is_active": True}, True),
+ ({"date": past, "is_active": False}, False),
+ ({"date": future, "is_active": True}, False),
+ ({"date": future, "is_active": False}, False),
+ ]
+ for params, tarball_required in cases:
+ form = self.form_class({"version": "1.0", **params})
+ with self.subTest(**params):
+ self.assertIs(form.is_valid(), not tarball_required, form.errors)
+
+ def test_published_release_tarball_required(self):
+ form = self.form_class(
+ {"version": "1.0", "date": "2008-09-03", "is_active": True}
+ )
+ self.assertFalse(form.is_valid())
+ self.assertFormError(
+ form,
+ "tarball",
+ "This field is required when the release is active.",
+ )
+
+ def test_checksum_required_if_tarball_provided(self):
+ form = self.form_class(
+ data={"version": "1.0", "date": None},
+ files={"tarball": ContentFile(b".", name="django-1.0.tar.gz")},
+ )
+ self.assertFormError(
+ form,
+ "checksum",
+ "This field is required when an artifact has been uploaded.",
+ )
+
+ def test_checksum_required_if_wheel_provided(self):
+ form = self.form_class(
+ data={"version": "1.0", "date": None},
+ files={"wheel": ContentFile(b".", name="django-1.0-py3-none-any.whl")},
+ )
+ self.assertFormError(
+ form,
+ "checksum",
+ "This field is required when an artifact has been uploaded.",
+ )
+
+ def test_artifact_filename_validation_valid(self):
+ for artifact, version, filename in [
+ ("tarball", "1.0", "django-1.0.tar.gz"),
+ ("tarball", "1.0", "Django-1.0.tar.gz"),
+ ("tarball", "1.10", "django-1.10.tar.gz"),
+ ("tarball", "1.2.3", "django-1.2.3.tar.gz"),
+ ("tarball", "1.0a1", "django-1.0a1.tar.gz"),
+ ("tarball", "1.0b1", "django-1.0b1.tar.gz"),
+ ("tarball", "1.0rc1", "django-1.0rc1.tar.gz"),
+ ("wheel", "1.0", "django-1.0-py3-none-any.whl"),
+ ("wheel", "1.0", "Django-1.0-py3-none-any.whl"),
+ ("wheel", "1.10", "django-1.10-py3-none-any.whl"),
+ ("wheel", "1.2.3", "django-1.2.3-py3-none-any.whl"),
+ ("wheel", "1.0a1", "django-1.0a1-py3-none-any.whl"),
+ ("wheel", "1.0b1", "django-1.0b1-py3-none-any.whl"),
+ ("wheel", "1.0rc1", "django-1.0rc1-py3-none-any.whl"),
+ ]:
+ form = self.form_class(
+ data={"version": version},
+ files={
+ artifact: ContentFile(b".", name=filename),
+ "checksum": ContentFile(b".", name="checksum.txt"),
+ },
+ )
+ with self.subTest(version=version, filename=filename):
+ self.assertFormError(form, artifact, [])
+
+ def test_artifact_filename_validation_invalid(self):
+ for artifact, version, filename in [
+ ("tarball", "1.0", "django-1.2.tar.gz"),
+ ("tarball", "1.0", "django-1.0.1.tar.gz"),
+ ("tarball", "1.0.1", "django-1.0.tar.gz"),
+ ("tarball", "1.0a1", "django-1.0.tar.gz"),
+ ("tarball", "1.0", "django-1.0-py3-none-any.tar.gz"),
+ ("tarball", "1.0", "django-1.0-py3-none-any.whl"),
+ ("tarball", "1.0", "django-1.0.tar.xz"),
+ ("wheel", "1.0", "django-1.2-py3-none-any.whl"),
+ ("wheel", "1.0", "django-1.0.1-py3-none-any.whl"),
+ ("wheel", "1.0.1", "django-1.0-py3-none-any.whl"),
+ ("wheel", "1.0a1", "django-1.0-py3-none-any.whl"),
+ ("wheel", "1.0", "django-1.0.whl"),
+ ("wheel", "1.0", "django-1.0.tar.gz"),
+ ]:
+ form = self.form_class(
+ data={"version": version, "date": None},
+ files={
+ artifact: ContentFile(b".", name=filename),
+ "checksum": ContentFile(b".", name="doesntmatter.txt"),
+ },
+ )
+ if artifact == "tarball":
+ pattern = rf"^[Dd]jango-{re.escape(version)}\.tar\.gz$"
+ else:
+ pattern = rf"^[Dd]jango-{re.escape(version)}\-py3\-none\-any\.whl$"
+ error = f"Filename {filename} does not match pattern {pattern}."
+ with self.subTest(version=version, filename=filename):
+ self.assertFormError(form, artifact, error)
+
+ def test_artifact_name_validation_with_full_path(self):
+ release = Release(
+ version="1.0",
+ checksum="checksu.txt",
+ tarball="releases/1.0/django-1.0.tar.gz",
+ )
+ try:
+ release.full_clean()
+ except ValidationError as e:
+ self.fail(f"Unexpected validation error {e}")
+
+ def test_artifact_file_inputs_have_extension_hint(self):
+ form = self.form_class(auto_id=None) # auto_id=None makes testing easier
+ self.assertHTMLEqual(
+ form["tarball"].as_widget(),
+ '<input type="file" name="tarball" accept=".tar.gz">',
+ )
+ self.assertHTMLEqual(
+ form["wheel"].as_widget(), '<input type="file" name="wheel" accept=".whl">'
+ )
+ self.assertHTMLEqual(
+ form["checksum"].as_widget(),
+ '<input type="file" name="checksum" accept=".asc,.txt">',
+ )
+
+ def test_file_upload_renames_correctly(self):
+ data = {"version": "1.2.3"}
+ files = {
+ # The content of the files doesn't matter
+ "tarball": ContentFile(b".", name="django-1.2.3.tar.gz"),
+ "wheel": ContentFile(b".", name="django-1.2.3-py3-none-any.whl"),
+ "checksum": ContentFile(b".", name="some-random-name.checksum.txt"),
+ }
+ form = self.form_class(data=data, files=files)
+ self.assertTrue(form.is_valid(), form.errors.as_json())
+ release = form.save()
+ self.assertEqual(release.tarball.name, "releases/1.2/django-1.2.3.tar.gz")
+ self.assertEqual(
+ release.wheel.name, "releases/1.2/django-1.2.3-py3-none-any.whl"
+ )
+ self.assertEqual(release.checksum.name, "pgp/Django-1.2.3.checksum.txt")
+
+
+class RedirectViewTestCase(TestCase):
+ def test_redirect(self):
+ Release.objects.create(
+ version="1.0",
+ is_active=True,
+ tarball="test.tar.gz",
+ wheel="test.whl",
+ checksum="test.checksum.txt",
+ )
+
+ for kind, url in [
+ ("tarball", "/m/test.tar.gz"),
+ ("wheel", "/m/test.whl"),
+ ("checksum", "/m/test.checksum.txt"),
+ ]:
+ response = self.client.get(f"/download/1.0/{kind}/")
+ with self.subTest(kind=kind):
+ self.assertRedirects(response, url, 301, fetch_redirect_response=False)
+
+ def test_redirect_is_not_published(self):
+ today = datetime.date.today()
+ future = today + datetime.timedelta(days=1)
+ past = today - datetime.timedelta(days=1)
+ cases = [
+ ({"date": None, "is_active": True}, 301),
+ ({"date": None, "is_active": False}, 301),
+ ({"date": today, "is_active": True}, 301),
+ ({"date": today, "is_active": False}, 301),
+ ({"date": past, "is_active": True}, 301),
+ ({"date": past, "is_active": False}, 301),
+ ({"date": future, "is_active": True}, 301),
+ ({"date": future, "is_active": False}, 301),
+ ]
+ for i, (params, status_code) in enumerate(cases):
+ Release.objects.create(
+ version=f"{i}.0",
+ tarball="test.tar.gz",
+ wheel="test.whl",
+ checksum="test.checksum.txt",
+ **params,
+ )
+ for kind in ["tarball", "wheel", "checksum"]:
+ response = self.client.get(f"/download/{i}.0/{kind}/")
+ with self.subTest(kind=kind, **params):
+ self.assertEqual(response.status_code, status_code)
+
+
class CorporateMembersTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.today = today = datetime.date.today()
day = datetime.timedelta(1)
Release.objects.create(
- version="1.7", date=today - 150 * day, eol_date=today + 50 * day
+ version="1.7",
+ is_active=True,
+ date=today - 150 * day,
+ eol_date=today + 50 * day,
)
Release.objects.create(
- version="1.8", is_lts=True, date=today - 50 * day, eol_date=None
+ version="1.8",
+ is_active=True,
+ is_lts=True,
+ date=today - 50 * day,
+ eol_date=None,
)
def make_member(self, level, level_name):
diff --git a/releases/urls.py b/releases/urls.py
index a8f3f538..3e1feb59 100644
--- a/releases/urls.py
+++ b/releases/urls.py
@@ -5,6 +5,6 @@ from .views import index, redirect
urlpatterns = [
path("", index, name="download"),
re_path(
- "^([0-9a-z_.-]+)/(tarball|checksum|egg)/$", redirect, name="download-redirect"
+ "^([0-9a-z_.-]+)/(tarball|wheel|checksum)/$", redirect, name="download-redirect"
),
]
diff --git a/releases/views.py b/releases/views.py
index fe7019a0..73dcad68 100644
--- a/releases/views.py
+++ b/releases/views.py
@@ -40,8 +40,8 @@ def index(request):
def redirect(request, version, kind):
release = get_object_or_404(Release, version=version)
- try:
- redirect_url = release.get_redirect_url(kind)
- except ValueError:
+
+ if not (artifact := getattr(release, kind, None)):
raise Http404
- return HttpResponsePermanentRedirect(redirect_url)
+
+ return HttpResponsePermanentRedirect(artifact.url)