Skip to content

Commit ca24abb

Browse files
committed
Extended Release model with an is_active flag to handle publication.
1 parent 99ac16f commit ca24abb

File tree

5 files changed

+147
-50
lines changed

5 files changed

+147
-50
lines changed

releases/admin.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
@admin.register(Release)
77
class ReleaseAdmin(admin.ModelAdmin):
88
fieldsets = [
9-
(None, {"fields": ["version", "is_lts"]}),
9+
(None, {"fields": ["version", "is_active", "is_lts"]}),
1010
("Dates", {"fields": ["date", "eol_date"]}),
1111
("Artifacts", {"fields": ["tarball", "wheel", "checksum"]}),
1212
]
1313
list_display = (
1414
"version",
15+
"show_is_published",
1516
"is_lts",
1617
"date",
1718
"eol_date",
@@ -30,3 +31,11 @@ class ReleaseAdmin(admin.ModelAdmin):
3031
)
3132
def show_status(self, obj):
3233
return obj.get_status_display()
34+
35+
@admin.display(
36+
boolean=True,
37+
description="Published?",
38+
ordering="is_active",
39+
)
40+
def show_is_published(self, obj):
41+
return obj.is_published

releases/migrations/0005_release_artifacts.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 5.1.5 on 2025-03-05 15:00
1+
# Generated by Django 5.1.5 on 2025-03-11 10:29
22
from functools import partial
33

44
from django.db import migrations, models
@@ -22,12 +22,16 @@ def default_artifact(suffix):
2222

2323

2424
def populate_artifacts(apps, schema_editor):
25-
"""
26-
Populate tarball, wheel and checksum fields based on historical data.
25+
"""Populate tarball, wheel and checksum fields based on historical data.
26+
27+
The flag "is_active" should always be True for existing releases (defaults to False
28+
for future releases).
29+
2730
"""
2831
Release = apps.get_model("releases", "Release")
2932

3033
Release.objects.update(
34+
is_active=True,
3135
# releases/{major}.{minor}/Django-{version}.tar.gz
3236
tarball=Case(
3337
*(When(version=k, then=Value(v)) for k, v in VERSION_TO_TARBALL.items()),
@@ -63,6 +67,14 @@ class Migration(migrations.Migration):
6367
verbose_name="Signed checksum as a .asc file",
6468
),
6569
),
70+
migrations.AddField(
71+
model_name="release",
72+
name="is_active",
73+
field=models.BooleanField(
74+
default=False,
75+
help_text="Set this release as active. A release is considered active only when its date is today or in the past, and this flag is enabled.",
76+
),
77+
),
6678
migrations.AddField(
6779
model_name="release",
6880
name="tarball",

releases/models.py

+45-37
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.core.validators import RegexValidator
1010
from django.db import models
1111
from django.utils.functional import cached_property
12+
from django.utils.timezone import now
1213
from django.utils.version import get_complete_version, get_main_version
1314

1415
from .utils import get_loose_version_tuple
@@ -174,6 +175,13 @@ class Release(models.Model):
174175
}
175176

176177
version = models.CharField(max_length=16, primary_key=True)
178+
is_active = models.BooleanField(
179+
help_text=(
180+
"Set this release as active. A release is considered active only "
181+
"when its date is today or in the past, and this flag is enabled."
182+
),
183+
default=False,
184+
)
177185
date = models.DateField(
178186
"Release date",
179187
null=True,
@@ -197,6 +205,12 @@ class Release(models.Model):
197205
micro = models.PositiveSmallIntegerField(editable=False)
198206
status = models.CharField(max_length=1, choices=STATUS_CHOICES, editable=False)
199207
iteration = models.PositiveSmallIntegerField(editable=False)
208+
is_lts = models.BooleanField(
209+
"Long Term Support",
210+
help_text='Is this an <abbr title="Long Term Support">LTS</abbr> release?',
211+
default=False,
212+
)
213+
# Artifacts.
200214
tarball = models.FileField(
201215
"Tarball artifact as a .tar.gz file",
202216
storage=get_storage,
@@ -215,46 +229,9 @@ class Release(models.Model):
215229
upload_to=upload_to_checksum,
216230
blank=True,
217231
)
218-
is_lts = models.BooleanField(
219-
"Long Term Support",
220-
help_text='Is this an <abbr title="Long Term Support">LTS</abbr> release?',
221-
default=False,
222-
)
223232

224233
objects = ReleaseManager()
225234

226-
def clean(self):
227-
if self.date is not None and not self.tarball:
228-
raise ValidationError(
229-
{
230-
"tarball": (
231-
"This field is required when the release is active "
232-
"by having a date"
233-
)
234-
}
235-
)
236-
237-
if (self.tarball or self.wheel) and not self.checksum:
238-
raise ValidationError(
239-
{
240-
"checksum": (
241-
"This field is required when an artifact has been uploaded"
242-
)
243-
}
244-
)
245-
246-
if self.tarball:
247-
try:
248-
self.validate_artifact_name(self.tarball.name, suffix=".tar.gz")
249-
except ValidationError as e:
250-
raise ValidationError({"tarball": e})
251-
252-
if self.wheel:
253-
try:
254-
self.validate_artifact_name(self.wheel.name, suffix="-py3-none-any.whl")
255-
except ValidationError as e:
256-
raise ValidationError({"wheel": e})
257-
258235
def save(self, *args, **kwargs):
259236
self.major, self.minor, self.micro, status, self.iteration = self.version_tuple
260237
self.status = self.STATUS_REVERSE[status]
@@ -273,6 +250,10 @@ def save(self, *args, **kwargs):
273250
def __str__(self):
274251
return self.version
275252

253+
@property
254+
def is_published(self):
255+
return self.is_active and self.date is not None and self.date <= now().date()
256+
276257
@cached_property
277258
def version_tuple(self):
278259
"""Return a tuple in the format of django.VERSION."""
@@ -290,6 +271,33 @@ def version_tuple(self):
290271
version.append(0)
291272
return tuple(version)
292273

274+
def clean(self):
275+
if self.is_published and not self.tarball:
276+
raise ValidationError(
277+
{"tarball": "This field is required when the release is active."}
278+
)
279+
280+
if (self.tarball or self.wheel) and not self.checksum:
281+
raise ValidationError(
282+
{
283+
"checksum": (
284+
"This field is required when an artifact has been uploaded."
285+
)
286+
}
287+
)
288+
289+
if self.tarball:
290+
try:
291+
self.validate_artifact_name(self.tarball.name, suffix=".tar.gz")
292+
except ValidationError as e:
293+
raise ValidationError({"tarball": e})
294+
295+
if self.wheel:
296+
try:
297+
self.validate_artifact_name(self.wheel.name, suffix="-py3-none-any.whl")
298+
except ValidationError as e:
299+
raise ValidationError({"wheel": e})
300+
293301
def validate_artifact_name(self, name, suffix):
294302
name = Path(name).name # strip any folder name if present
295303
version = get_version(self.version_tuple)

releases/tests.py

+76-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.test import SimpleTestCase, TestCase, override_settings
99
from django.urls import reverse
1010
from django.utils.safestring import SafeString
11+
from django.utils.timezone import now
1112

1213
from members.models import MEMBERSHIP_LEVELS, PLATINUM_MEMBERSHIP, CorporateMember
1314

@@ -143,6 +144,27 @@ def test_preview(self):
143144
self.assertEqual(Release.objects.preview().version, "1.9b2")
144145

145146

147+
class ReleaseTestCase(TestCase):
148+
def test_is_published(self):
149+
today = now().date()
150+
future = today + datetime.timedelta(days=1)
151+
past = today - datetime.timedelta(days=1)
152+
cases = [
153+
({"date": None, "is_active": True}, False),
154+
({"date": None, "is_active": False}, False),
155+
({"date": today, "is_active": True}, True),
156+
({"date": today, "is_active": False}, False),
157+
({"date": past, "is_active": True}, True),
158+
({"date": past, "is_active": False}, False),
159+
({"date": future, "is_active": True}, False),
160+
({"date": future, "is_active": False}, False),
161+
]
162+
for i, (params, expected) in enumerate(cases):
163+
release = Release.objects.create(version=f"{i}.0", **params)
164+
with self.subTest(**params):
165+
self.assertIs(release.is_published, expected)
166+
167+
146168
class ReleaseUploadToTestCase(SimpleTestCase):
147169
def test_upload_to_artifact(self):
148170
for version, filename, expected in [
@@ -201,15 +223,33 @@ def setUpClass(cls):
201223
cls.form_class = admin.site.get_model_admin(Release).get_form(request=None)
202224

203225
def test_non_published_releases_tarball_not_required(self):
204-
form = self.form_class({"version": "1.0", "date": None})
205-
self.assertTrue(form.is_valid(), form.errors)
226+
today = now().date()
227+
future = today + datetime.timedelta(days=1)
228+
past = today - datetime.timedelta(days=1)
229+
cases = [
230+
({"date": None, "is_active": True}, False),
231+
({"date": None, "is_active": False}, False),
232+
({"date": today, "is_active": True}, True),
233+
({"date": today, "is_active": False}, False),
234+
({"date": past, "is_active": True}, True),
235+
({"date": past, "is_active": False}, False),
236+
({"date": future, "is_active": True}, False),
237+
({"date": future, "is_active": False}, False),
238+
]
239+
for i, (params, tarball_required) in enumerate(cases):
240+
form = self.form_class({"version": f"{i}.0", **params})
241+
with self.subTest(**params):
242+
self.assertIs(form.is_valid(), not tarball_required, form.errors)
206243

207-
def test_published_releases_tarball_required(self):
208-
form = self.form_class({"version": "1.0", "date": "2008-09-03"})
244+
def test_published_release_tarball_required(self):
245+
form = self.form_class(
246+
{"version": "1.0", "date": "2008-09-03", "is_active": True}
247+
)
248+
self.assertFalse(form.is_valid())
209249
self.assertFormError(
210250
form,
211251
"tarball",
212-
"This field is required when the release is active by having a date",
252+
"This field is required when the release is active.",
213253
)
214254

215255
def test_checksum_required_if_tarball_provided(self):
@@ -220,7 +260,7 @@ def test_checksum_required_if_tarball_provided(self):
220260
self.assertFormError(
221261
form,
222262
"checksum",
223-
"This field is required when an artifact has been uploaded",
263+
"This field is required when an artifact has been uploaded.",
224264
)
225265

226266
def test_checksum_required_if_wheel_provided(self):
@@ -231,7 +271,7 @@ def test_checksum_required_if_wheel_provided(self):
231271
self.assertFormError(
232272
form,
233273
"checksum",
234-
"This field is required when an artifact has been uploaded",
274+
"This field is required when an artifact has been uploaded.",
235275
)
236276

237277
def test_artifact_filename_validation_valid(self):
@@ -340,6 +380,7 @@ class RedirectViewTestCase(TestCase):
340380
def test_redirect(self):
341381
Release.objects.create(
342382
version="1.0",
383+
is_active=True,
343384
tarball="test.tar.gz",
344385
wheel="test.whl",
345386
checksum="test.checksum.txt",
@@ -355,13 +396,40 @@ def test_redirect(self):
355396
self.assertRedirects(response, url, 301, fetch_redirect_response=False)
356397

357398
def test_redirect_404(self):
358-
Release.objects.create(version="1.0")
399+
Release.objects.create(version="1.0", is_active=True)
359400

360401
for kind in ["tarball", "wheel", "checksum"]:
361402
response = self.client.get(f"/download/1.0/{kind}/")
362403
with self.subTest(kind=kind):
363404
self.assertEqual(response.status_code, 404)
364405

406+
def test_redirect_is_not_published(self):
407+
today = now().date()
408+
future = today + datetime.timedelta(days=1)
409+
past = today - datetime.timedelta(days=1)
410+
cases = [
411+
({"date": None, "is_active": True}, 404),
412+
({"date": None, "is_active": False}, 404),
413+
({"date": today, "is_active": True}, 301),
414+
({"date": today, "is_active": False}, 404),
415+
({"date": past, "is_active": True}, 301),
416+
({"date": past, "is_active": False}, 404),
417+
({"date": future, "is_active": True}, 404),
418+
({"date": future, "is_active": False}, 404),
419+
]
420+
for i, (params, status_code) in enumerate(cases):
421+
release = Release.objects.create(
422+
version=f"{i}.0",
423+
tarball="test.tar.gz",
424+
wheel="test.whl",
425+
checksum="test.checksum.txt",
426+
**params,
427+
)
428+
for kind in ["tarball", "wheel", "checksum"]:
429+
response = self.client.get(f"/download/{i}.0/{kind}/")
430+
with self.subTest(kind=kind, **params):
431+
self.assertEqual(response.status_code, status_code)
432+
365433

366434
class CorporateMembersTestCase(TestCase):
367435
@classmethod

releases/views.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def index(request):
4141
def redirect(request, version, kind):
4242
release = get_object_or_404(Release, version=version)
4343

44-
if not (artifact := getattr(release, kind, None)):
44+
if not release.is_published or not (artifact := getattr(release, kind, None)):
4545
raise Http404
4646

4747
return HttpResponsePermanentRedirect(artifact.url)

0 commit comments

Comments
 (0)