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`
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
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 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
```

View File

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

View File

@@ -44,7 +44,18 @@ RUN yarn build
# Final image.
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
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
# Set environment variable for Playwright to use system Chromium
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin
CMD ["gunicorn", "-c", "/code/gunicorn.conf.py", "config.wsgi"]
ARG TAG

View File

@@ -1,4 +1,5 @@
from django.contrib import admin
from django.core.files.storage import default_storage
from django.db import transaction
from django.db.models import F, Count, OuterRef, Window
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.shortcuts import redirect
from django.views.generic import TemplateView
from django import forms
from core.admin_filters import StaffUserCreatedByFilter
from libraries.forms import CreateReportForm, CreateReportFullForm
from versions.models import Version
from versions.tasks import import_all_library_versions
from .filters import ReportConfigurationFilter
from .models import (
Category,
Commit,
@@ -21,6 +25,7 @@ from .models import (
Library,
LibraryVersion,
PullRequest,
ReleaseReport,
WordcloudMergeWord,
)
from .tasks import (
@@ -34,6 +39,7 @@ from .tasks import (
generate_release_report,
synchronize_commit_author_user_data,
)
from .utils import generate_release_report_filename
@admin.register(Commit)
@@ -177,7 +183,9 @@ class ReleaseReportView(TemplateView):
return context
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):
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"
VERSION_SLUG_PREFIX = "boost-"
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,
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
def cache_key(self):
@@ -205,13 +210,16 @@ class CreateReportFullForm(Form):
"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."""
# ensure we have "cleaned_data"
if not self.is_valid():
return ""
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:
html = (
f"An error occurred generating the report: {e}. To see the image "

View File

@@ -17,11 +17,10 @@ from core.management.actions import (
ActionsManager,
send_notification,
)
from libraries.forms import CreateReportForm
from libraries.tasks import update_commits
from libraries.tasks import update_commits, generate_release_report
from reports.models import WebsiteStatReport
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()
@@ -30,8 +29,12 @@ class ReleaseTasksManager(ActionsManager):
latest_version: Version | None = None
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.user_id = user_id
super().__init__()
def set_tasks(self):
@@ -80,20 +83,32 @@ class ReleaseTasksManager(ActionsManager):
"""
start_date = timezone.now() - timedelta(days=120)
date_string = start_date.strftime("%Y-%m-%d")
print(f"{date_string = }")
call_command("import_ml_counts", start_date=date_string)
def generate_report(self):
if not self.should_generate_report:
self.add_progress_message("Skipped - report generation not requested")
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)
def run_commands(progress: list[str], generate_report: bool = False):
manager = ReleaseTasksManager(should_generate_report=generate_report)
def run_commands(
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()
progress.extend(manager.progress_messages)
return manager.handled_commits
@@ -125,11 +140,16 @@ def bad_credentials() -> list[str]:
@click.command()
@click.option(
"--base_uri",
is_flag=False,
help="The URI to which paths should be relative",
default=None,
)
@click.option(
"--user_id",
is_flag=False,
help="The ID of the user that started this task (For notification purposes)",
default=None,
)
@click.option(
"--generate_report",
@@ -137,11 +157,11 @@ def bad_credentials() -> list[str]:
help="Generate a report at the end of the command",
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."""
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___"]
if missing_creds := bad_credentials():
@@ -162,7 +182,7 @@ def command(user_id=None, generate_report=False):
)
try:
handled_commits = run_commands(progress, generate_report)
handled_commits = run_commands(progress, base_uri, generate_report, user_id)
end = timezone.now()
except Exception:
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.db import models, transaction
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.utils import timezone
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 libraries.managers import IssueManager
from mailing_list.models import EmailData
from versions.models import ReportConfiguration
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):
@@ -542,3 +549,62 @@ class WordcloudMergeWord(models.Model):
def __str__(self):
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 libraries.forms import CreateReportForm, CreateReportFullForm
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 versions.models import Version
from .constants import (
LIBRARY_DOCS_EXCEPTIONS,
LIBRARY_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__)
@@ -230,10 +241,75 @@ def update_issues(clean=False):
@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."""
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
@@ -252,14 +328,15 @@ def update_library_version_dependencies(token=None):
@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.
@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
and at the end of the task.
"""
command = ["release_tasks"]
command = ["release_tasks", "--base_uri", base_uri]
if user_id:
command.extend(["--user_id", user_id])
if generate_report:

View File

@@ -7,8 +7,10 @@ from libraries.utils import (
conditional_batched,
decode_content,
generate_fake_email,
generate_release_report_filename,
get_first_last_day_last_month,
parse_date,
update_base_tag,
version_within_range,
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"):
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 tempfile
from datetime import datetime
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from dateutil.parser import ParserError, parse
@@ -353,3 +353,21 @@ def parse_boostdep_artifact(content: str):
"Some library versions were skipped during artifact parsing.",
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
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==6.0.0
# via -r ./requirements-dev.in
pydevd-pycharm==252.26830.99
pydevd-pycharm==252.27397.106
# via -r ./requirements-dev.in
sqlparse==0.5.3
# via

View File

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

View File

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

View File

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

View File

@@ -474,9 +474,9 @@ ul.slack-channels li div a:hover,
{% with bg_list=bg_list_str|split:"," %}
{% for batch in batched_library_data %}
{% 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 %}
<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="flex flex-col gap-y-4">
<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
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(
request,
"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):
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):
return self.version

View File

@@ -129,3 +129,39 @@ def test_review_results():
pending_result.refresh_from_db()
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
cache_key = f"release-report-,,,,,,,-{version_name}"
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.")
return redirect("release-report-preview", version_slug=version_name)