Skip to content

Commit 0a0d111

Browse files
authored
Release report tweaks (#2001) (#2016)
Merging to test one unified PR
1 parent 776d834 commit 0a0d111

File tree

17 files changed

+312
-68
lines changed

17 files changed

+312
-68
lines changed

config/celery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def setup_periodic_tasks(sender, **kwargs):
9898
# Update data required for release report. Executes Saturday evenings.
9999
sender.add_periodic_task(
100100
crontab(day_of_week="sat", hour=20, minute=3),
101-
app.signature("libraries.tasks.release_tasks", generate_report=True),
101+
app.signature("libraries.tasks.release_tasks", generate_report=False),
102102
)
103103

104104
# Update users' profile photos from GitHub. Executes daily at 3:30 AM.

config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@
427427
]
428428

429429

430+
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "http"
430431
if not LOCAL_DEVELOPMENT:
431432
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
432433
SECURE_PROXY_SSL_HEADER = (

docs/release_reports.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
1. Ask Sam for a copy of the "subscribe" data.
77
2. In the Django admin interface go to "Subscription datas" under "MAILING_LIST".
88
3. At the top of the page click on the "IMPORT 'SUBSCRIBE' DATA" button.
9-
2. To update the mailing list counts, if you haven't already run the "DO IT ALL" button:
10-
1. Go to "Versions" under "VERSIONS" in the admin interface
11-
2. At the top of the page click on the "DO IT ALL" button.
9+
2. To update the mailing list counts, if you haven't already run the "GET RELEASE REPORT DATA" button:
10+
1. Go to "Release Reports" under "VERSIONS" in the admin interface
11+
2. At the top of the page click on the "GET RELEASE REPORT DATA" button.
1212

1313
## Report Creation
1414

libraries/admin.py

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import structlog
12
from django.conf import settings
2-
from django.contrib import admin
3-
from django.core.files.storage import default_storage
3+
from django.contrib import admin, messages
4+
from django.core.exceptions import ValidationError
45
from django.db import transaction
56
from django.db.models import F, Count, OuterRef, Window
67
from django.db.models.functions import RowNumber
@@ -43,6 +44,9 @@
4344
from .utils import generate_release_report_filename
4445

4546

47+
logger = structlog.get_logger()
48+
49+
4650
@admin.register(Commit)
4751
class CommitAdmin(admin.ModelAdmin):
4852
list_display = ["library_version", "sha", "author"]
@@ -184,16 +188,39 @@ def get_context_data(self, **kwargs):
184188
return context
185189

186190
def generate_report(self):
187-
base_scheme = "http" if settings.LOCAL_DEVELOPMENT else "https"
191+
uri = f"{settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL}://{self.request.get_host()}"
188192
generate_release_report.delay(
189193
user_id=self.request.user.id,
190194
params=self.request.GET,
191-
base_uri=f"{base_scheme}://{self.request.get_host()}",
195+
base_uri=uri,
192196
)
193197

198+
def locked_publish_check(self):
199+
form = self.get_form()
200+
form.is_valid()
201+
publish = form.cleaned_data["publish"]
202+
report_configuration = form.cleaned_data["report_configuration"]
203+
if publish and ReleaseReport.latest_published_locked(report_configuration):
204+
msg = (
205+
f"A release report already exists with locked status for "
206+
f"{report_configuration.display_name}. Delete or unlock the most "
207+
f"recent report."
208+
)
209+
raise ValueError(msg)
210+
194211
def get(self, request, *args, **kwargs):
195212
form = self.get_form()
196213
if form.is_valid():
214+
try:
215+
self.locked_publish_check()
216+
except ValueError as e:
217+
messages.error(request, str(e))
218+
return TemplateResponse(
219+
request,
220+
self.form_template,
221+
self.get_context_data(),
222+
)
223+
197224
if form.cleaned_data["no_cache"]:
198225
params = request.GET.copy()
199226
form.cache_clear()
@@ -462,28 +489,92 @@ class Meta:
462489
def __init__(self, *args, **kwargs):
463490
super().__init__(*args, **kwargs)
464491

465-
if self.instance.pk and not self.instance.published:
466-
file_name = generate_release_report_filename(
467-
self.instance.report_configuration.get_slug()
492+
if not self.is_publish_editable():
493+
# we require users to intentionally manually delete existing reports
494+
self.fields["published"].disabled = True
495+
self.fields["published"].help_text = (
496+
"⚠️ A published PDF already exists for this Report Configuration. See "
497+
'"Publishing" notes at the top of this page.'
468498
)
469-
published_filename = f"{ReleaseReport.upload_dir}{file_name}"
470-
if default_storage.exists(published_filename):
471-
# we require users to intentionally manually delete existing reports
472-
self.fields["published"].disabled = True
473-
self.fields["published"].help_text = (
474-
f"⚠️ A published '{file_name}' already exists. To prevent accidents "
475-
"you must manually delete that file before publishing this report."
499+
500+
def is_publish_editable(self) -> bool:
501+
# in play here are currently published and previously published rows because of
502+
# filename collision risk.
503+
if self.instance.published:
504+
return True
505+
506+
published_filename = generate_release_report_filename(
507+
version_slug=self.instance.report_configuration.get_slug(),
508+
published_format=True,
509+
)
510+
reports = ReleaseReport.objects.filter(
511+
report_configuration=self.instance.report_configuration,
512+
file=f"{ReleaseReport.upload_dir}{published_filename}",
513+
)
514+
515+
if reports.count() == 0 or reports.latest("created_at") == self.instance:
516+
return True
517+
518+
return False
519+
520+
def clean(self):
521+
cleaned_data = super().clean()
522+
if not self.is_publish_editable():
523+
raise ValidationError("This file is not publishable.")
524+
if cleaned_data.get("published"):
525+
report_configuration = cleaned_data.get("report_configuration")
526+
if ReleaseReport.latest_published_locked(
527+
report_configuration, self.instance
528+
):
529+
raise ValidationError(
530+
f"A release report already exists with locked status for "
531+
f"{report_configuration.display_name}. Delete or unlock the most "
532+
f"recent report."
476533
)
477534

535+
return cleaned_data
536+
478537

479538
@admin.register(ReleaseReport)
480539
class ReleaseReportAdmin(admin.ModelAdmin):
481540
form = ReleaseReportAdminForm
482-
list_display = ["__str__", "created_at", "published", "published_at"]
483-
list_filter = ["published", ReportConfigurationFilter, StaffUserCreatedByFilter]
541+
list_display = ["__str__", "created_at", "published", "published_at", "locked"]
542+
list_filter = [
543+
"published",
544+
"locked",
545+
ReportConfigurationFilter,
546+
StaffUserCreatedByFilter,
547+
]
484548
search_fields = ["file"]
485549
readonly_fields = ["created_at", "created_by"]
486550
ordering = ["-created_at"]
551+
change_list_template = "admin/releasereport_change_list.html"
552+
change_form_template = "admin/releasereport_change_form.html"
553+
554+
def get_urls(self):
555+
urls = super().get_urls()
556+
my_urls = [
557+
path(
558+
"release_tasks/",
559+
self.admin_site.admin_view(self.release_tasks),
560+
name="release_tasks",
561+
),
562+
]
563+
return my_urls + urls
564+
565+
def release_tasks(self, request):
566+
from libraries.tasks import release_tasks
567+
568+
release_tasks.delay(
569+
base_uri=f"{settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL}://{request.get_host()}",
570+
user_id=request.user.id,
571+
generate_report=False,
572+
)
573+
self.message_user(
574+
request,
575+
"release_tasks has started, you will receive an email when the task finishes.", # noqa: E501
576+
)
577+
return HttpResponseRedirect("../")
487578

488579
def has_add_permission(self, request):
489580
return False
@@ -492,3 +583,25 @@ def save_model(self, request, obj, form, change):
492583
if not change:
493584
obj.created_by = request.user
494585
super().save_model(request, obj, form, change)
586+
587+
@staticmethod
588+
def clear_other_report_files(release_report: ReleaseReport):
589+
if release_report.file:
590+
other_reports = ReleaseReport.objects.filter(
591+
file=release_report.file.name
592+
).exclude(pk=release_report.pk)
593+
594+
if other_reports.exists():
595+
release_report.file = None
596+
release_report.save()
597+
598+
def delete_model(self, request, obj):
599+
# check if another report uses the same file
600+
self.clear_other_report_files(obj)
601+
super().delete_model(request, obj)
602+
603+
def delete_queryset(self, request, queryset):
604+
# clear file reference, prevents deletion of the file if it's linked elsewhere
605+
for obj in queryset:
606+
self.clear_other_report_files(obj)
607+
super().delete_queryset(request, queryset)

libraries/forms.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,13 +244,25 @@ class CreateReportForm(CreateReportFullForm):
244244
"""Form for creating a report for a specific release."""
245245

246246
html_template_name = "admin/release_report_detail.html"
247-
248-
report_configuration = ModelChoiceField(
249-
queryset=ReportConfiguration.objects.order_by("-version")
250-
)
247+
# queryset will be set in __init__
248+
report_configuration = ModelChoiceField(queryset=ReportConfiguration.objects.none())
251249

252250
def __init__(self, *args, **kwargs):
253251
super().__init__(*args, **kwargs)
252+
# we want to allow master, develop, the latest release, the latest beta, along
253+
# with any report configuration matching no Version, exclude all others.
254+
exclusion_versions = []
255+
if betas := Version.objects.filter(beta=True).order_by("-release_date")[1:]:
256+
exclusion_versions += betas
257+
if older_releases := Version.objects.filter(
258+
active=True, full_release=True
259+
).order_by("-release_date")[1:]:
260+
exclusion_versions += older_releases
261+
qs = ReportConfiguration.objects.exclude(
262+
version__in=[v.name for v in exclusion_versions]
263+
).order_by("-version")
264+
265+
self.fields["report_configuration"].queryset = qs
254266
self.fields["library_1"].help_text = (
255267
"If none are selected, all libraries will be selected."
256268
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.7 on 2025-11-11 22:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("libraries", "0035_releasereport"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="releasereport",
15+
name="locked",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Can't be overwritten during release report publish. Blocks task-based publishing.",
19+
),
20+
),
21+
]

libraries/models.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import re
23
import uuid
34
from datetime import timedelta
@@ -565,6 +566,10 @@ class ReleaseReport(models.Model):
565566

566567
published = models.BooleanField(default=False)
567568
published_at = models.DateTimeField(blank=True, null=True)
569+
locked = models.BooleanField(
570+
default=False,
571+
help_text="Can't be overwritten during release report publish. Blocks task-based publishing.",
572+
)
568573

569574
def __str__(self):
570575
return f"{self.file.name.replace(self.upload_dir, "")}"
@@ -589,17 +594,69 @@ def rename_file_to(self, filename: str, allow_overwrite: bool = False):
589594
default_storage.delete(current_name)
590595
self.file.name = final_filename
591596

592-
def save(self, allow_overwrite=False, *args, **kwargs):
593-
super().save(*args, **kwargs)
597+
def get_media_file(self):
598+
return os.sep.join(
599+
[
600+
settings.MEDIA_URL.rstrip("/"),
601+
self.file.name,
602+
]
603+
)
594604

605+
@staticmethod
606+
def latest_published_locked(
607+
report_configuration: ReportConfiguration,
608+
release_report_exclusion=None,
609+
) -> bool:
610+
release_reports_qs = ReleaseReport.objects.filter(
611+
report_configuration__version=report_configuration.version,
612+
published=True,
613+
)
614+
if release_report_exclusion:
615+
release_reports_qs = release_reports_qs.exclude(
616+
pk=release_report_exclusion.id
617+
)
618+
if release_reports_qs:
619+
return release_reports_qs.first().locked
620+
return False
621+
622+
def unpublish_previous_reports(self):
623+
for r in ReleaseReport.objects.filter(
624+
report_configuration__version=self.report_configuration.version,
625+
published=True,
626+
).exclude(pk=self.id):
627+
r.published = False
628+
r.save()
629+
630+
def save(self, allow_published_overwrite=False, *args, **kwargs):
631+
"""
632+
Args:
633+
allow_published_overwrite (bool): If True, allows overwriting of published
634+
reports (locked checks still apply)
635+
*args: Additional positional arguments passed to the superclass save method
636+
**kwargs: Additional keyword arguments passed to the superclass save method
637+
638+
Raises:
639+
ValueError: Raised if there is an existing locked release report for the configuration, preventing publication
640+
of another one without resolving the conflict.
641+
"""
595642
is_being_published = self.published and not self.published_at
643+
if not is_being_published:
644+
super().save(*args, **kwargs)
596645
if is_being_published and self.file:
646+
if ReleaseReport.latest_published_locked(self.report_configuration, self):
647+
msg = (
648+
f"A release report already exists with locked status for "
649+
f"{self.report_configuration.display_name}. Delete or unlock the "
650+
f"most recent report."
651+
)
652+
raise ValueError(msg)
653+
self.unpublish_previous_reports()
597654
new_filename = generate_release_report_filename(
598655
self.report_configuration.get_slug(), self.published
599656
)
600-
self.rename_file_to(new_filename, allow_overwrite)
657+
self.rename_file_to(new_filename, allow_published_overwrite)
601658
self.published_at = timezone.now()
602-
super().save(update_fields=["published_at", "file"])
659+
super().save()
603660

604661

605662
# Signal handler to delete files when ReleaseReport is deleted

libraries/tasks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,9 @@ def generate_release_report_pdf(
316316
release_report.file.save(filename, ContentFile(pdf_bytes), save=True)
317317
if publish:
318318
release_report.published = True
319-
release_report.save(allow_overwrite=True)
320-
logger.info(f"{release_report_id=} updated with PDF {filename=}")
321-
319+
release_report.save(allow_published_overwrite=True)
320+
except ValueError as e:
321+
logger.error(f"Failed to publish release: {e}")
322322
except Exception as e:
323323
logger.error(f"Failed to generate PDF: {e}", exc_info=True)
324324
raise

libraries/utils.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,5 @@ def generate_release_report_filename(version_slug: str, published_format: bool =
368368
filename_data = ["release-report", version_slug]
369369
if not published_format:
370370
filename_data.append(datetime.now(timezone.utc).isoformat())
371-
372-
filename = f"{"-".join(filename_data)}.pdf"
371+
filename = f"{'-'.join(filename_data)}.pdf"
373372
return filename

0 commit comments

Comments
 (0)