Skip to content

Commit 6663a42

Browse files
committed
Added pdf generation for release reports/do it all (#1750)
1 parent a8f57fe commit 6663a42

File tree

18 files changed

+531
-30
lines changed

18 files changed

+531
-30
lines changed

docker/Dockerfile

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,18 @@ RUN yarn build
4444
# Final image.
4545
FROM python:3.13-slim AS release
4646

47-
RUN apt update && apt install -y git libpq-dev ruby ruby-dev && rm -rf /var/lib/apt/lists/*
47+
# Install system dependencies including Chromium
48+
RUN apt update && apt install -y \
49+
git \
50+
libpq-dev \
51+
ruby \
52+
ruby-dev \
53+
fonts-liberation \
54+
fonts-noto \
55+
fonts-noto-mono \
56+
fonts-noto-color-emoji \
57+
chromium \
58+
&& rm -rf /var/lib/apt/lists/*
4859

4960
# Install Asciidoctor
5061
RUN gem install asciidoctor asciidoctor-boost
@@ -67,6 +78,9 @@ COPY --from=builder-js /code/static/css/styles.css /code/static/css/styles.css
6778

6879
WORKDIR /code
6980

81+
# Set environment variable for Playwright to use system Chromium
82+
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
83+
7084
CMD ["gunicorn", "-c", "/code/gunicorn.conf.py", "config.wsgi"]
7185

7286
ARG TAG

libraries/admin.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.contrib import admin
2+
from django.core.files.storage import default_storage
23
from django.db import transaction
34
from django.db.models import F, Count, OuterRef, Window
45
from django.db.models.functions import RowNumber
@@ -8,10 +9,13 @@
89
from django.utils.safestring import mark_safe
910
from django.shortcuts import redirect
1011
from django.views.generic import TemplateView
12+
from django import forms
1113

14+
from core.admin_filters import StaffUserCreatedByFilter
1215
from libraries.forms import CreateReportForm, CreateReportFullForm
1316
from versions.models import Version
1417
from versions.tasks import import_all_library_versions
18+
from .filters import ReportConfigurationFilter
1519
from .models import (
1620
Category,
1721
Commit,
@@ -21,6 +25,7 @@
2125
Library,
2226
LibraryVersion,
2327
PullRequest,
28+
ReleaseReport,
2429
WordcloudMergeWord,
2530
)
2631
from .tasks import (
@@ -34,6 +39,7 @@
3439
generate_release_report,
3540
synchronize_commit_author_user_data,
3641
)
42+
from .utils import generate_release_report_filename
3743

3844

3945
@admin.register(Commit)
@@ -177,7 +183,9 @@ def get_context_data(self, **kwargs):
177183
return context
178184

179185
def generate_report(self):
180-
generate_release_report.delay(self.request.GET)
186+
generate_release_report.delay(
187+
user_id=self.request.user.id, params=self.request.GET
188+
)
181189

182190
def get(self, request, *args, **kwargs):
183191
form = self.get_form()
@@ -440,3 +448,43 @@ class WordcloudMergeWordAdmin(admin.ModelAdmin):
440448
},
441449
),
442450
]
451+
452+
453+
class ReleaseReportAdminForm(forms.ModelForm):
454+
class Meta:
455+
model = ReleaseReport
456+
fields = "__all__"
457+
458+
def __init__(self, *args, **kwargs):
459+
super().__init__(*args, **kwargs)
460+
461+
if self.instance.pk and not self.instance.published:
462+
file_name = generate_release_report_filename(
463+
self.instance.report_configuration.get_slug()
464+
)
465+
published_filename = f"{ReleaseReport.upload_dir}{file_name}"
466+
if default_storage.exists(published_filename):
467+
# we require users to intentionally manually delete existing reports
468+
self.fields["published"].disabled = True
469+
self.fields["published"].help_text = (
470+
f"⚠️ A published '{file_name}' already exists. To prevent accidents "
471+
"you must manually delete that file before publishing this report."
472+
)
473+
474+
475+
@admin.register(ReleaseReport)
476+
class ReleaseReportAdmin(admin.ModelAdmin):
477+
form = ReleaseReportAdminForm
478+
list_display = ["__str__", "created_at", "published", "published_at"]
479+
list_filter = ["published", ReportConfigurationFilter, StaffUserCreatedByFilter]
480+
search_fields = ["file"]
481+
readonly_fields = ["created_at", "created_by"]
482+
ordering = ["-created_at"]
483+
484+
def has_add_permission(self, request):
485+
return False
486+
487+
def save_model(self, request, obj, form, change):
488+
if not change:
489+
obj.created_by = request.user
490+
super().save_model(request, obj, form, change)

libraries/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,4 @@
366366
MASTER_RELEASE_URL_PATH_STR = "master"
367367
VERSION_SLUG_PREFIX = "boost-"
368368
RELEASE_REPORT_SEARCH_TOP_COUNTRIES_LIMIT = 5
369+
DOCKER_CONTAINER_URL_WEB = "http://web:8000"

libraries/filters.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from django.contrib import admin
2+
3+
from versions.models import ReportConfiguration
4+
5+
6+
class ReportConfigurationFilter(admin.SimpleListFilter):
7+
title = "report configuration"
8+
parameter_name = "report_configuration"
9+
10+
def lookups(self, request, model_admin):
11+
# get only ReportConfigurations that have associated ReleaseReports
12+
configs = (
13+
ReportConfiguration.objects.filter(releasereport__isnull=False)
14+
.distinct()
15+
.order_by("version")
16+
)
17+
return [(config.id, str(config)) for config in configs]
18+
19+
def queryset(self, request, queryset):
20+
if self.value():
21+
return queryset.filter(report_configuration_id=self.value())
22+
return queryset

libraries/forms.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ class CreateReportFullForm(Form):
9090
initial=False,
9191
help_text="Force the page to be regenerated, do not use cache.",
9292
)
93+
publish = BooleanField(
94+
required=False,
95+
initial=False,
96+
help_text="Warning: overwrites existing published report, not reversible.",
97+
)
9398

9499
@property
95100
def cache_key(self):
@@ -205,13 +210,16 @@ def get_stats(self):
205210
"library_count": self.library_queryset.count(),
206211
}
207212

208-
def cache_html(self):
213+
def cache_html(self, base_uri=None):
209214
"""Render and cache the html for this report."""
210215
# ensure we have "cleaned_data"
211216
if not self.is_valid():
212217
return ""
213218
try:
214-
html = render_to_string(self.html_template_name, self.get_stats())
219+
context = self.get_stats()
220+
if base_uri:
221+
context["base_uri"] = base_uri
222+
html = render_to_string(self.html_template_name, context)
215223
except FileNotFoundError as e:
216224
html = (
217225
f"An error occurred generating the report: {e}. To see the image "

libraries/management/commands/release_tasks.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@
1717
ActionsManager,
1818
send_notification,
1919
)
20-
from libraries.forms import CreateReportForm
21-
from libraries.tasks import update_commits
20+
from libraries.tasks import update_commits, generate_release_report
2221
from reports.models import WebsiteStatReport
2322
from slack.management.commands.fetch_slack_activity import get_my_channels, locked
24-
from versions.models import Version
23+
from versions.models import Version, ReportConfiguration
2524

2625
User = get_user_model()
2726

@@ -30,8 +29,12 @@ class ReleaseTasksManager(ActionsManager):
3029
latest_version: Version | None = None
3130
handled_commits: dict[str, int] = {}
3231

33-
def __init__(self, should_generate_report: bool = False):
32+
def __init__(
33+
self, base_uri: str, user_id: int, should_generate_report: bool = False
34+
):
35+
self.base_uri = base_uri
3436
self.should_generate_report = should_generate_report
37+
self.user_id = user_id
3538
super().__init__()
3639

3740
def set_tasks(self):
@@ -80,20 +83,32 @@ def import_ml_counts(self):
8083
"""
8184
start_date = timezone.now() - timedelta(days=120)
8285
date_string = start_date.strftime("%Y-%m-%d")
83-
print(f"{date_string = }")
8486
call_command("import_ml_counts", start_date=date_string)
8587

8688
def generate_report(self):
8789
if not self.should_generate_report:
8890
self.add_progress_message("Skipped - report generation not requested")
8991
return
90-
form = CreateReportForm({"version": self.latest_version.id})
91-
form.cache_html()
92+
93+
report_configuration = ReportConfiguration.objects.get(
94+
version=self.latest_version.name
95+
)
96+
generate_release_report.delay(
97+
user_id=self.user_id,
98+
params={"report_configuration": report_configuration.id, "publish": True},
99+
base_uri=self.base_uri,
100+
)
92101

93102

94103
@locked(1138692)
95-
def run_commands(progress: list[str], generate_report: bool = False):
96-
manager = ReleaseTasksManager(should_generate_report=generate_report)
104+
def run_commands(
105+
progress: list[str], base_uri: str, user_id: int, generate_report: bool = False
106+
):
107+
manager = ReleaseTasksManager(
108+
base_uri=base_uri,
109+
should_generate_report=generate_report,
110+
user_id=user_id,
111+
)
97112
manager.run_tasks()
98113
progress.extend(manager.progress_messages)
99114
return manager.handled_commits
@@ -125,23 +140,28 @@ def bad_credentials() -> list[str]:
125140

126141

127142
@click.command()
143+
@click.option(
144+
"--base_uri",
145+
is_flag=False,
146+
help="The URI to which paths should be relative",
147+
default=None,
148+
)
128149
@click.option(
129150
"--user_id",
130151
is_flag=False,
131152
help="The ID of the user that started this task (For notification purposes)",
132-
default=None,
133153
)
134154
@click.option(
135155
"--generate_report",
136156
is_flag=True,
137157
help="Generate a report at the end of the command",
138158
default=False,
139159
)
140-
def command(user_id=None, generate_report=False):
160+
def command(user_id, base_uri=None, generate_report=False):
141161
"""A long running chain of tasks to import and update library data."""
142162
start = timezone.now()
143163

144-
user = User.objects.filter(id=user_id).first() if user_id else None
164+
user = User.objects.filter(id=user_id).first()
145165

146166
progress = ["___Progress Messages___"]
147167
if missing_creds := bad_credentials():
@@ -162,7 +182,7 @@ def command(user_id=None, generate_report=False):
162182
)
163183

164184
try:
165-
handled_commits = run_commands(progress, generate_report)
185+
handled_commits = run_commands(progress, base_uri, generate_report, user_id)
166186
end = timezone.now()
167187
except Exception:
168188
error = traceback.format_exc()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 5.2.7 on 2025-10-27 22:52
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("libraries", "0034_strip_boost_from_documentation_urls"),
12+
("versions", "0024_alter_versionfile_checksum_and_more"),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="ReleaseReport",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
(
30+
"file",
31+
models.FileField(
32+
blank=True, null=True, upload_to="release-reports/"
33+
),
34+
),
35+
("created_at", models.DateTimeField(auto_now_add=True)),
36+
("published", models.BooleanField(default=False)),
37+
("published_at", models.DateTimeField(blank=True, null=True)),
38+
(
39+
"created_by",
40+
models.ForeignKey(
41+
null=True,
42+
on_delete=django.db.models.deletion.SET_NULL,
43+
to=settings.AUTH_USER_MODEL,
44+
),
45+
),
46+
(
47+
"report_configuration",
48+
models.ForeignKey(
49+
on_delete=django.db.models.deletion.CASCADE,
50+
to="versions.reportconfiguration",
51+
),
52+
),
53+
],
54+
),
55+
]

0 commit comments

Comments
 (0)