Release Report Enhancements (#1750) (#1992)

This commit is contained in:
daveoconnor
2025-11-04 14:14:20 -08:00
committed by GitHub
parent 2c0bafe35f
commit c980879935
23 changed files with 544 additions and 34 deletions

View File

@@ -157,10 +157,14 @@ Update database values in settings to use the same host, user, password, and the
run `django-admin migrate --pythonpath example_project --settings settings` run `django-admin migrate --pythonpath example_project --settings settings`
Give your ssh key to Sam so he can add it to the boost.cpp.al server, and then download the mailman db archive and cp the sql to the docker container Give your ssh key to Sam so he can add it to the boost.cpp.al server, and then download the mailman db archive and cp the sql to the docker container
Create a database in your postgres instance called `hyperkitty_db`, then:
```shell ```shell
scp {user}@staging-db1.boost.cpp.al:/tmp/lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql.gz . scp {user}@staging-db1.boost.cpp.al:/tmp/lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql.gz .
docker cp lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql website-v2-web-1:/lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql docker cp lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql website-v2-web-1:/lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql
docker exec -it website-v2-web-1 /bin/bash docker exec -it website-v2-web-1 /bin/bash
apt update && apt -y install postgresql
psql -U postgres -W hyperkitty_db < /lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql psql -U postgres -W hyperkitty_db < /lists_stage_web.staging-db1-2.2025-02-06-08-00-01.sql
``` ```

View File

@@ -91,6 +91,8 @@ services:
build: build:
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
args:
LOCAL_DEVELOPMENT: "true"
command: command:
- /bin/bash - /bin/bash
- -c - -c
@@ -113,8 +115,11 @@ services:
build: build:
context: . context: .
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
args:
LOCAL_DEVELOPMENT: "true"
command: [ "celery", "-A", "config", "beat", "--loglevel=debug" ] command: [ "celery", "-A", "config", "beat", "--loglevel=debug" ]
environment: environment:
LOCAL_DEVELOPMENT: "true"
DEBUG_TOOLBAR: "false" DEBUG_TOOLBAR: "false"
env_file: env_file:
- .env - .env

View File

@@ -44,7 +44,18 @@ RUN yarn build
# Final image. # Final image.
FROM python:3.13-slim AS release FROM python:3.13-slim AS release
RUN apt update && apt install -y git libpq-dev ruby ruby-dev && rm -rf /var/lib/apt/lists/* # Install system dependencies including Chromium
RUN apt update && apt install -y \
git \
libpq-dev \
ruby \
ruby-dev \
fonts-liberation \
fonts-noto \
fonts-noto-mono \
fonts-noto-color-emoji \
chromium \
&& rm -rf /var/lib/apt/lists/*
# Install Asciidoctor # Install Asciidoctor
RUN gem install asciidoctor asciidoctor-boost RUN gem install asciidoctor asciidoctor-boost
@@ -67,6 +78,9 @@ COPY --from=builder-js /code/static/css/styles.css /code/static/css/styles.css
WORKDIR /code WORKDIR /code
# Set environment variable for Playwright to use system Chromium
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
CMD ["gunicorn", "-c", "/code/gunicorn.conf.py", "config.wsgi"] CMD ["gunicorn", "-c", "/code/gunicorn.conf.py", "config.wsgi"]
ARG TAG ARG TAG

View File

@@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.core.files.storage import default_storage
from django.db import transaction from django.db import transaction
from django.db.models import F, Count, OuterRef, Window from django.db.models import F, Count, OuterRef, Window
from django.db.models.functions import RowNumber from django.db.models.functions import RowNumber
@@ -8,10 +9,13 @@ from django.urls import path, reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django import forms
from core.admin_filters import StaffUserCreatedByFilter
from libraries.forms import CreateReportForm, CreateReportFullForm from libraries.forms import CreateReportForm, CreateReportFullForm
from versions.models import Version from versions.models import Version
from versions.tasks import import_all_library_versions from versions.tasks import import_all_library_versions
from .filters import ReportConfigurationFilter
from .models import ( from .models import (
Category, Category,
Commit, Commit,
@@ -21,6 +25,7 @@ from .models import (
Library, Library,
LibraryVersion, LibraryVersion,
PullRequest, PullRequest,
ReleaseReport,
WordcloudMergeWord, WordcloudMergeWord,
) )
from .tasks import ( from .tasks import (
@@ -34,6 +39,7 @@ from .tasks import (
generate_release_report, generate_release_report,
synchronize_commit_author_user_data, synchronize_commit_author_user_data,
) )
from .utils import generate_release_report_filename
@admin.register(Commit) @admin.register(Commit)
@@ -177,7 +183,9 @@ class ReleaseReportView(TemplateView):
return context return context
def generate_report(self): def generate_report(self):
generate_release_report.delay(self.request.GET) generate_release_report.delay(
user_id=self.request.user.id, params=self.request.GET
)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
form = self.get_form() form = self.get_form()
@@ -440,3 +448,43 @@ class WordcloudMergeWordAdmin(admin.ModelAdmin):
}, },
), ),
] ]
class ReleaseReportAdminForm(forms.ModelForm):
class Meta:
model = ReleaseReport
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and not self.instance.published:
file_name = generate_release_report_filename(
self.instance.report_configuration.get_slug()
)
published_filename = f"{ReleaseReport.upload_dir}{file_name}"
if default_storage.exists(published_filename):
# we require users to intentionally manually delete existing reports
self.fields["published"].disabled = True
self.fields["published"].help_text = (
f"⚠️ A published '{file_name}' already exists. To prevent accidents "
"you must manually delete that file before publishing this report."
)
@admin.register(ReleaseReport)
class ReleaseReportAdmin(admin.ModelAdmin):
form = ReleaseReportAdminForm
list_display = ["__str__", "created_at", "published", "published_at"]
list_filter = ["published", ReportConfigurationFilter, StaffUserCreatedByFilter]
search_fields = ["file"]
readonly_fields = ["created_at", "created_by"]
ordering = ["-created_at"]
def has_add_permission(self, request):
return False
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -366,3 +366,4 @@ DEVELOP_RELEASE_URL_PATH_STR = "develop"
MASTER_RELEASE_URL_PATH_STR = "master" MASTER_RELEASE_URL_PATH_STR = "master"
VERSION_SLUG_PREFIX = "boost-" VERSION_SLUG_PREFIX = "boost-"
RELEASE_REPORT_SEARCH_TOP_COUNTRIES_LIMIT = 5 RELEASE_REPORT_SEARCH_TOP_COUNTRIES_LIMIT = 5
DOCKER_CONTAINER_URL_WEB = "http://web:8000"

22
libraries/filters.py Normal file
View File

@@ -0,0 +1,22 @@
from django.contrib import admin
from versions.models import ReportConfiguration
class ReportConfigurationFilter(admin.SimpleListFilter):
title = "report configuration"
parameter_name = "report_configuration"
def lookups(self, request, model_admin):
# get only ReportConfigurations that have associated ReleaseReports
configs = (
ReportConfiguration.objects.filter(releasereport__isnull=False)
.distinct()
.order_by("version")
)
return [(config.id, str(config)) for config in configs]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(report_configuration_id=self.value())
return queryset

View File

@@ -90,6 +90,11 @@ class CreateReportFullForm(Form):
initial=False, initial=False,
help_text="Force the page to be regenerated, do not use cache.", help_text="Force the page to be regenerated, do not use cache.",
) )
publish = BooleanField(
required=False,
initial=False,
help_text="Warning: overwrites existing published report, not reversible.",
)
@property @property
def cache_key(self): def cache_key(self):
@@ -205,13 +210,16 @@ class CreateReportFullForm(Form):
"library_count": self.library_queryset.count(), "library_count": self.library_queryset.count(),
} }
def cache_html(self): def cache_html(self, base_uri=None):
"""Render and cache the html for this report.""" """Render and cache the html for this report."""
# ensure we have "cleaned_data" # ensure we have "cleaned_data"
if not self.is_valid(): if not self.is_valid():
return "" return ""
try: try:
html = render_to_string(self.html_template_name, self.get_stats()) context = self.get_stats()
if base_uri:
context["base_uri"] = base_uri
html = render_to_string(self.html_template_name, context)
except FileNotFoundError as e: except FileNotFoundError as e:
html = ( html = (
f"An error occurred generating the report: {e}. To see the image " f"An error occurred generating the report: {e}. To see the image "

View File

@@ -17,11 +17,10 @@ from core.management.actions import (
ActionsManager, ActionsManager,
send_notification, send_notification,
) )
from libraries.forms import CreateReportForm from libraries.tasks import update_commits, generate_release_report
from libraries.tasks import update_commits
from reports.models import WebsiteStatReport from reports.models import WebsiteStatReport
from slack.management.commands.fetch_slack_activity import get_my_channels, locked from slack.management.commands.fetch_slack_activity import get_my_channels, locked
from versions.models import Version from versions.models import Version, ReportConfiguration
User = get_user_model() User = get_user_model()
@@ -30,8 +29,12 @@ class ReleaseTasksManager(ActionsManager):
latest_version: Version | None = None latest_version: Version | None = None
handled_commits: dict[str, int] = {} handled_commits: dict[str, int] = {}
def __init__(self, should_generate_report: bool = False): def __init__(
self, base_uri: str, user_id: int, should_generate_report: bool = False
):
self.base_uri = base_uri
self.should_generate_report = should_generate_report self.should_generate_report = should_generate_report
self.user_id = user_id
super().__init__() super().__init__()
def set_tasks(self): def set_tasks(self):
@@ -80,20 +83,32 @@ class ReleaseTasksManager(ActionsManager):
""" """
start_date = timezone.now() - timedelta(days=120) start_date = timezone.now() - timedelta(days=120)
date_string = start_date.strftime("%Y-%m-%d") date_string = start_date.strftime("%Y-%m-%d")
print(f"{date_string = }")
call_command("import_ml_counts", start_date=date_string) call_command("import_ml_counts", start_date=date_string)
def generate_report(self): def generate_report(self):
if not self.should_generate_report: if not self.should_generate_report:
self.add_progress_message("Skipped - report generation not requested") self.add_progress_message("Skipped - report generation not requested")
return return
form = CreateReportForm({"version": self.latest_version.id})
form.cache_html() report_configuration = ReportConfiguration.objects.get(
version=self.latest_version.name
)
generate_release_report.delay(
user_id=self.user_id,
params={"report_configuration": report_configuration.id, "publish": True},
base_uri=self.base_uri,
)
@locked(1138692) @locked(1138692)
def run_commands(progress: list[str], generate_report: bool = False): def run_commands(
manager = ReleaseTasksManager(should_generate_report=generate_report) progress: list[str], base_uri: str, user_id: int, generate_report: bool = False
):
manager = ReleaseTasksManager(
base_uri=base_uri,
should_generate_report=generate_report,
user_id=user_id,
)
manager.run_tasks() manager.run_tasks()
progress.extend(manager.progress_messages) progress.extend(manager.progress_messages)
return manager.handled_commits return manager.handled_commits
@@ -125,11 +140,16 @@ def bad_credentials() -> list[str]:
@click.command() @click.command()
@click.option(
"--base_uri",
is_flag=False,
help="The URI to which paths should be relative",
default=None,
)
@click.option( @click.option(
"--user_id", "--user_id",
is_flag=False, is_flag=False,
help="The ID of the user that started this task (For notification purposes)", help="The ID of the user that started this task (For notification purposes)",
default=None,
) )
@click.option( @click.option(
"--generate_report", "--generate_report",
@@ -137,11 +157,11 @@ def bad_credentials() -> list[str]:
help="Generate a report at the end of the command", help="Generate a report at the end of the command",
default=False, default=False,
) )
def command(user_id=None, generate_report=False): def command(user_id, base_uri=None, generate_report=False):
"""A long running chain of tasks to import and update library data.""" """A long running chain of tasks to import and update library data."""
start = timezone.now() start = timezone.now()
user = User.objects.filter(id=user_id).first() if user_id else None user = User.objects.filter(id=user_id).first()
progress = ["___Progress Messages___"] progress = ["___Progress Messages___"]
if missing_creds := bad_credentials(): if missing_creds := bad_credentials():
@@ -162,7 +182,7 @@ def command(user_id=None, generate_report=False):
) )
try: try:
handled_commits = run_commands(progress, generate_report) handled_commits = run_commands(progress, base_uri, generate_report, user_id)
end = timezone.now() end = timezone.now()
except Exception: except Exception:
error = traceback.format_exc() error = traceback.format_exc()

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.7 on 2025-10-27 22:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("libraries", "0034_strip_boost_from_documentation_urls"),
("versions", "0024_alter_versionfile_checksum_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ReleaseReport",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"file",
models.FileField(
blank=True, null=True, upload_to="release-reports/"
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("published", models.BooleanField(default=False)),
("published_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"report_configuration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="versions.reportconfiguration",
),
),
],
),
]

View File

@@ -7,6 +7,8 @@ from urllib.parse import urlparse
from django.core.cache import caches from django.core.cache import caches
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Sum from django.db.models import Sum
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -21,9 +23,14 @@ from core.asciidoc import convert_adoc_to_html
from core.validators import image_validator, max_file_size_validator from core.validators import image_validator, max_file_size_validator
from libraries.managers import IssueManager from libraries.managers import IssueManager
from mailing_list.models import EmailData from mailing_list.models import EmailData
from versions.models import ReportConfiguration
from .constants import LIBRARY_GITHUB_URL_OVERRIDES from .constants import LIBRARY_GITHUB_URL_OVERRIDES
from .utils import generate_random_string, write_content_to_tempfile from .utils import (
generate_random_string,
write_content_to_tempfile,
generate_release_report_filename,
)
class Category(models.Model): class Category(models.Model):
@@ -542,3 +549,62 @@ class WordcloudMergeWord(models.Model):
def __str__(self): def __str__(self):
return f"{self.from_word}->{self.to_word}" return f"{self.from_word}->{self.to_word}"
class ReleaseReport(models.Model):
upload_dir = "release-reports/"
file = models.FileField(upload_to=upload_dir, blank=True, null=True)
report_configuration = models.ForeignKey(
ReportConfiguration, on_delete=models.CASCADE
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True
)
created_at = models.DateTimeField(auto_now_add=True)
published = models.BooleanField(default=False)
published_at = models.DateTimeField(blank=True, null=True)
def __str__(self):
return f"{self.file.name.replace(self.upload_dir, "")}"
def rename_file_to(self, filename: str, allow_overwrite: bool = False):
"""Rename the file to use the version slug from report_configuration."""
from django.core.files.storage import default_storage
current_name = self.file.name
final_filename = f"{self._meta.get_field("file").upload_to}{filename}"
if current_name == final_filename:
return
if default_storage.exists(final_filename):
if not allow_overwrite:
raise ValueError(f"{final_filename} already exists")
default_storage.delete(final_filename)
with default_storage.open(current_name, "rb") as source:
default_storage.save(final_filename, source)
# delete the old file and update the reference
default_storage.delete(current_name)
self.file.name = final_filename
def save(self, allow_overwrite=False, *args, **kwargs):
super().save(*args, **kwargs)
is_being_published = self.published and not self.published_at
if is_being_published and self.file:
new_filename = generate_release_report_filename(
self.report_configuration.get_slug(), self.published
)
self.rename_file_to(new_filename, allow_overwrite)
self.published_at = timezone.now()
super().save(update_fields=["published_at", "file"])
# Signal handler to delete files when ReleaseReport is deleted
@receiver(pre_delete, sender=ReleaseReport)
def delete_release_report_files(sender, instance, **kwargs):
"""Delete file from storage when ReleaseReport is deleted."""
if instance.file:
instance.file.delete(save=False)

View File

@@ -10,15 +10,26 @@ from core.boostrenderer import get_content_from_s3
from core.htmlhelper import get_library_documentation_urls from core.htmlhelper import get_library_documentation_urls
from libraries.forms import CreateReportForm, CreateReportFullForm from libraries.forms import CreateReportForm, CreateReportFullForm
from libraries.github import LibraryUpdater from libraries.github import LibraryUpdater
from libraries.models import Library, LibraryVersion, CommitAuthorEmail, CommitAuthor from libraries.models import (
Library,
LibraryVersion,
CommitAuthorEmail,
CommitAuthor,
ReleaseReport,
)
from users.tasks import User from users.tasks import User
from versions.models import Version from versions.models import Version
from .constants import ( from .constants import (
LIBRARY_DOCS_EXCEPTIONS, LIBRARY_DOCS_EXCEPTIONS,
LIBRARY_DOCS_MISSING, LIBRARY_DOCS_MISSING,
VERSION_DOCS_MISSING, VERSION_DOCS_MISSING,
DOCKER_CONTAINER_URL_WEB,
)
from .utils import (
version_within_range,
update_base_tag,
generate_release_report_filename,
) )
from .utils import version_within_range
logger = structlog.getLogger(__name__) logger = structlog.getLogger(__name__)
@@ -230,10 +241,75 @@ def update_issues(clean=False):
@app.task @app.task
def generate_release_report(params): def generate_release_report(user_id: int, params: dict, base_uri: str = None):
"""Generate a release report asynchronously and save it in RenderedContent.""" """Generate a release report asynchronously and save it in RenderedContent."""
form = CreateReportForm(params) form = CreateReportForm(params)
form.cache_html() html = form.cache_html(base_uri=base_uri)
# override the base uri to reference the internal container for local dev
if settings.LOCAL_DEVELOPMENT:
html = update_base_tag(html, DOCKER_CONTAINER_URL_WEB)
release_report = ReleaseReport(
created_by_id=user_id,
report_configuration_id=params.get("report_configuration"),
)
release_report.save()
generate_release_report_pdf.delay(
release_report.pk, html=html, publish=params.get("publish")
)
@app.task(bind=True, time_limit=300, soft_time_limit=240)
def generate_release_report_pdf(
self, release_report_id: int, html: str, publish: bool = False
):
"""Generate a release report asynchronously and save it in PDF using Playwright."""
from playwright.sync_api import sync_playwright
from django.core.files.base import ContentFile
release_report = ReleaseReport.objects.get(pk=release_report_id)
logger.info(f"{release_report_id=}, task id: {self.request.id}")
try:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, executable_path="/usr/bin/chromium"
)
page = browser.new_page()
page.set_content(html, wait_until="networkidle")
# wait for fonts to be ready
page.evaluate("document.fonts.ready")
logger.info("Generating PDF")
page.emulate_media(media="print")
pdf_bytes = page.pdf(
format="Letter",
print_background=True,
prefer_css_page_size=True,
margin={
"top": "0.5in",
"right": "0.5in",
"bottom": "0.5in",
"left": "0.5in",
},
)
browser.close()
logger.info(f"PDF generated successfully, size: {len(pdf_bytes)} bytes")
# to start, we have the draft file, so it can be moved later into the
# final location by the ReleaseReport.save() process
filename = generate_release_report_filename(
release_report.report_configuration.get_slug(), published_format=False
)
release_report.file.save(filename, ContentFile(pdf_bytes), save=True)
if publish:
release_report.published = True
release_report.save(allow_overwrite=True)
logger.info(f"{release_report_id=} updated with PDF {filename=}")
except Exception as e:
logger.error(f"Failed to generate PDF: {e}", exc_info=True)
raise
@app.task @app.task
@@ -252,14 +328,15 @@ def update_library_version_dependencies(token=None):
@app.task @app.task
def release_tasks(user_id=None, generate_report=False): def release_tasks(base_uri, user_id=None, generate_report=False):
"""Call the release_tasks management command. """Call the release_tasks management command.
@param base_uri should be in the format https://domain.tld
If a user_id is given, that user will receive an email at the beginning If a user_id is given, that user will receive an email at the beginning
and at the end of the task. and at the end of the task.
""" """
command = ["release_tasks"] command = ["release_tasks", "--base_uri", base_uri]
if user_id: if user_id:
command.extend(["--user_id", user_id]) command.extend(["--user_id", user_id])
if generate_report: if generate_report:

View File

@@ -7,8 +7,10 @@ from libraries.utils import (
conditional_batched, conditional_batched,
decode_content, decode_content,
generate_fake_email, generate_fake_email,
generate_release_report_filename,
get_first_last_day_last_month, get_first_last_day_last_month,
parse_date, parse_date,
update_base_tag,
version_within_range, version_within_range,
write_content_to_tempfile, write_content_to_tempfile,
) )
@@ -282,3 +284,117 @@ def test_conditional_batched_invalid_n():
with pytest.raises(ValueError, match="n must be at least one"): with pytest.raises(ValueError, match="n must be at least one"):
list(conditional_batched(items, 0, lambda x: True)) list(conditional_batched(items, 0, lambda x: True))
@pytest.mark.parametrize(
"html, base_uri, expected",
[
# Test basic base tag replacement
(
'<html><head><base href="/old/path/"></head><body>content</body></html>',
"/new/path/",
'<html><head><base href="/new/path/"></head><body>content</body></html>',
),
# Test with different base tag format (no trailing slash)
(
'<base href="https://example.com/docs">',
"https://newsite.com/documentation",
'<base href="https://newsite.com/documentation">',
),
# Test multiple base tags (should replace all occurrences)
(
'<base href="/old1/"><base href="/old2/">',
"/new/",
'<base href="/new/"><base href="/new/">',
),
# Test with empty base URI
(
'<base href="/docs/">',
"",
'<base href="">',
),
# Test with complex HTML structure
(
"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<base href="/doc/libs/1_84_0/">
<title>Test</title>
</head>
<body>content</body>
</html>""",
"/doc/libs/latest/",
"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<base href="/doc/libs/latest/">
<title>Test</title>
</head>
<body>content</body>
</html>""",
),
],
)
def test_update_base_tag(html, base_uri, expected):
"""Test update_base_tag replaces base tag href correctly."""
result = update_base_tag(html, base_uri)
assert result == expected
def test_update_base_tag_no_base_tag():
"""Test update_base_tag when there is no base tag in the HTML."""
html = "<html><head><title>Test</title></head><body>content</body></html>"
base_uri = "/new/path/"
result = update_base_tag(html, base_uri)
# Should return the original HTML unchanged since there's no base tag to replace
assert result == html
@pytest.mark.parametrize(
"version_slug, published_format, expected_prefix, should_have_timestamp",
[
# Published format (no timestamp)
("boost-1-84-0", True, "release-report-boost-1-84-0.pdf", False),
("boost-1-85-0", True, "release-report-boost-1-85-0.pdf", False),
# Draft format (with timestamp)
("boost-1-84-0", False, "release-report-boost-1-84-0-", True),
("boost-1-85-0", False, "release-report-boost-1-85-0-", True),
],
)
def test_generate_release_report_filename(
version_slug, published_format, expected_prefix, should_have_timestamp
):
"""Test generate_release_report_filename generates correct filenames."""
result = generate_release_report_filename(version_slug, published_format)
assert result.startswith(expected_prefix)
assert result.endswith(".pdf")
if should_have_timestamp:
# timestamp should be in ISO format (contains 'T' and timezone info)
assert "T" in result
# should have the pattern: release-report-{slug}-{timestamp}.pdf
assert len(result.split("-")) >= 4 # release, report, slug, timestamp
else:
# published format should not have a timestamp
assert "T" not in result
# should be release-report-{slug}.pdf
assert result == expected_prefix
def test_generate_release_report_filename_timestamp_format():
"""Test that the timestamp in the filename is a valid ISO format."""
version_slug = "boost-1-84-0"
result = generate_release_report_filename(version_slug, published_format=False)
# extract the timestamp portion (between last dash and .pdf)
# format: release-report-boost-1-84-0-{timestamp}.pdf
timestamp_part = result.replace("release-report-boost-1-84-0-", "").replace(
".pdf", ""
)
# parse it as an ISO format datetime to ensure it's valid
try:
datetime.fromisoformat(timestamp_part)
except ValueError:
pytest.fail(f"Timestamp '{timestamp_part}' is not a valid ISO format")

View File

@@ -5,7 +5,7 @@ from itertools import islice
import structlog import structlog
import tempfile import tempfile
from datetime import datetime from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from dateutil.parser import ParserError, parse from dateutil.parser import ParserError, parse
@@ -353,3 +353,21 @@ def parse_boostdep_artifact(content: str):
"Some library versions were skipped during artifact parsing.", "Some library versions were skipped during artifact parsing.",
skipped_library_versions=skipped_library_versions, skipped_library_versions=skipped_library_versions,
) )
def update_base_tag(html: str, base_uri: str):
"""
Replace the base tag href with the new base_uri
"""
pattern = r'<base\s+href="[^"]*">'
replacement = f'<base href="{base_uri}">'
return re.sub(pattern, replacement, html)
def generate_release_report_filename(version_slug: str, published_format: bool = False):
filename_data = ["release-report", version_slug]
if not published_format:
filename_data.append(datetime.now(timezone.utc).isoformat())
filename = f"{"-".join(filename_data)}.pdf"
return filename

View File

@@ -1,3 +1,3 @@
-c requirements.txt -c requirements.txt
django-debug-toolbar django-debug-toolbar
pydevd-pycharm==252.26830.99 # pinned to appropriate version for current pycharm pydevd-pycharm==252.27397.106 # pinned to appropriate version for current pycharm

View File

@@ -10,7 +10,7 @@ django==5.2.7
# django-debug-toolbar # django-debug-toolbar
django-debug-toolbar==6.0.0 django-debug-toolbar==6.0.0
# via -r ./requirements-dev.in # via -r ./requirements-dev.in
pydevd-pycharm==252.26830.99 pydevd-pycharm==252.27397.106
# via -r ./requirements-dev.in # via -r ./requirements-dev.in
sqlparse==0.5.3 sqlparse==0.5.3
# via # via

View File

@@ -37,6 +37,7 @@ wordcloud
lxml lxml
algoliasearch algoliasearch
openai openai
playwright
# Logging # Logging
django-tracer django-tracer

View File

@@ -205,6 +205,7 @@ greenlet==3.2.4
# via # via
# -r ./requirements.in # -r ./requirements.in
# gevent # gevent
# playwright
gunicorn==23.0.0 gunicorn==23.0.0
# via -r ./requirements.in # via -r ./requirements.in
h11==0.16.0 h11==0.16.0
@@ -310,6 +311,8 @@ platformdirs==4.5.0
# via # via
# black # black
# virtualenv # virtualenv
playwright==1.55.0
# via -r ./requirements.in
pluggy==1.6.0 pluggy==1.6.0
# via # via
# pytest # pytest
@@ -344,6 +347,8 @@ pydantic==2.12.3
# openai # openai
pydantic-core==2.41.4 pydantic-core==2.41.4
# via pydantic # via pydantic
pyee==13.0.0
# via playwright
pygments==2.19.2 pygments==2.19.2
# via # via
# ipython # ipython
@@ -435,16 +440,14 @@ traitlets==5.14.3
# matplotlib-inline # matplotlib-inline
typing-extensions==4.15.0 typing-extensions==4.15.0
# via # via
# aiosignal
# anyio
# beautifulsoup4 # beautifulsoup4
# django-countries # django-countries
# ipython
# jwcrypto # jwcrypto
# minio # minio
# openai # openai
# pydantic # pydantic
# pydantic-core # pydantic-core
# pyee
# typing-inspection # typing-inspection
typing-inspection==0.4.2 typing-inspection==0.4.2
# via pydantic # via pydantic

View File

@@ -2,6 +2,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
{% if base_uri %}
<base href="{{ base_uri }}">
{% endif %}
<title> <title>
{% block title %}Boost{% endblock %} {% block title %}Boost{% endblock %}
</title> </title>

View File

@@ -474,9 +474,9 @@ ul.slack-channels li div a:hover,
{% with bg_list=bg_list_str|split:"," %} {% with bg_list=bg_list_str|split:"," %}
{% for batch in batched_library_data %} {% for batch in batched_library_data %}
{% with current_bg=bg_list|get_modulo_item:forloop.counter0 %} {% with current_bg=bg_list|get_modulo_item:forloop.counter0 %}
<div class="pdf-page flex flex-col {{ bg_color }}" id="library-{{item.library.display_name}}" style="background-image: url('{% static current_bg %}')"> <div class="pdf-page flex flex-col {{ bg_color }}" style="background-image: url('{% static current_bg %}')">
{% for item in batch %} {% for item in batch %}
<div class="grid grid-cols-3 gap-x-8 w-full p-4 h-1/2"> <div class="grid grid-cols-3 gap-x-8 w-full p-4 h-1/2" id="library-{{ item.library.display_name }}">
<div class="col-span-2 flex flex-col gap-y-4"> <div class="col-span-2 flex flex-col gap-y-4">
<div class="flex flex-col gap-y-4"> <div class="flex flex-col gap-y-4">
<h2 class="text-orange mb-1 mt-0">{{ item.library.display_name }}</h2> <h2 class="text-orange mb-1 mt-0">{{ item.library.display_name }}</h2>

View File

@@ -44,7 +44,11 @@ class VersionAdmin(admin.ModelAdmin):
return my_urls + urls return my_urls + urls
def release_tasks(self, request): def release_tasks(self, request):
release_tasks.delay(user_id=request.user.id, generate_report=True) release_tasks.delay(
base_uri=f"https://{request.get_host()}",
user_id=request.user.id,
generate_report=True,
)
self.message_user( self.message_user(
request, request,
"release_tasks has started, you will receive an email when the task finishes.", # noqa: E501 "release_tasks has started, you will receive an email when the task finishes.", # noqa: E501

View File

@@ -322,6 +322,11 @@ class ReportConfiguration(models.Model):
def display_name(self): def display_name(self):
return self.version.replace("boost-", "") return self.version.replace("boost-", "")
def get_slug(self):
# this output should always match the Version slug format
name = self.version.replace(".", " ").replace("boost_", "")
return slugify(name)[:50]
def __str__(self): def __str__(self):
return self.version return self.version

View File

@@ -129,3 +129,39 @@ def test_review_results():
pending_result.refresh_from_db() pending_result.refresh_from_db()
assert not pending_result.is_most_recent assert not pending_result.is_most_recent
@pytest.mark.parametrize(
"version_name",
[
"boost-1.75.0",
"boost-1.81.0",
"boost-1.82.0.beta1",
"boost_1.75.0",
"boost_1_75_0",
"develop",
"master",
"1.75.0",
],
)
def test_report_configuration_slug_matches_version_slug_format(version_name):
"""
Test that ReportConfiguration.get_slug() produces the same format as
Version.get_slug() for the same version name.
This ensures consistency between the two models' slug generation.
"""
# Create a Version with the version name
version = baker.prepare("versions.Version", name=version_name, slug=None)
version_slug = version.get_slug()
# Create a ReportConfiguration with the same version name
report_config = baker.prepare("versions.ReportConfiguration", version=version_name)
report_config_slug = report_config.get_slug()
# Assert that both slugs match
assert version_slug == report_config_slug, (
f"Slug mismatch for version name '{version_name}': "
f"Version.get_slug() = '{version_slug}', "
f"ReportConfiguration.get_slug() = '{report_config_slug}'"
)

View File

@@ -221,6 +221,10 @@ class ReportPreviewGenerateView(BoostVersionMixin, View):
version_name = version.name version_name = version.name
cache_key = f"release-report-,,,,,,,-{version_name}" cache_key = f"release-report-,,,,,,,-{version_name}"
RenderedContent.objects.filter(cache_key=cache_key).delete() RenderedContent.objects.filter(cache_key=cache_key).delete()
generate_release_report.delay({"version": version.id}) generate_release_report.delay(
user_id=request.user.id,
params={"version": version.id},
base_uri=f"https://{request.get_host()}",
)
messages.success(request, "Report generation queued.") messages.success(request, "Report generation queued.")
return redirect("release-report-preview", version_slug=version_name) return redirect("release-report-preview", version_slug=version_name)