Decouple release reports from releases (#1737)

This commit is contained in:
daveoconnor
2025-07-08 16:09:01 -07:00
committed by GitHub
parent 0b146ce199
commit 301aaeac31
11 changed files with 308 additions and 147 deletions

View File

@@ -334,7 +334,7 @@ For this to work `SLACK_BOT_API` must be set in the `.env` file.
| Options | Format | Description | | Options | Format | Description |
|----------------|--------|----------------------------------------------------------------------------------------------------------------------| |----------------|--------|----------------------------------------------------------------------------------------------------------------------|
| `--start_date` | date | If passed, retrieves data from the start date supplied, d-m-y, default 20-11-1998 (the start of the data in mailman) | | `--start_date` | date | If passed, retrieves data from the start date supplied, d-m-y, default 1998-11-20 (the start of the data in mailman) |
| `--end_date` | date | If passed, If passed, retrieves data until the start date supplied, d-m-y, default today | | `--end_date` | date | If passed, If passed, retrieves data until the start date supplied, d-m-y, default today |
## `link_contributors_to_users` ## `link_contributors_to_users`

View File

@@ -16,7 +16,7 @@ from reports.generation import (
get_new_subscribers_stats, get_new_subscribers_stats,
) )
from slack.models import Channel, SlackActivityBucket, SlackUser from slack.models import Channel, SlackActivityBucket, SlackUser
from versions.models import Version from versions.models import Version, ReportConfiguration
from .models import ( from .models import (
Commit, Commit,
CommitAuthor, CommitAuthor,
@@ -232,8 +232,8 @@ class CreateReportForm(CreateReportFullForm):
html_template_name = "admin/release_report_detail.html" html_template_name = "admin/release_report_detail.html"
version = ModelChoiceField( report_configuration = ModelChoiceField(
queryset=Version.objects.minor_versions().order_by("-version_array") queryset=ReportConfiguration.objects.order_by("-version")
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -255,14 +255,12 @@ class CreateReportForm(CreateReportFullForm):
self.cleaned_data["library_8"], self.cleaned_data["library_8"],
] ]
lib_string = ",".join(str(x.id) if x else "" for x in chosen_libraries) lib_string = ",".join(str(x.id) if x else "" for x in chosen_libraries)
version = self.cleaned_data["version"] report_configuration = self.cleaned_data["report_configuration"]
return f"release-report-{lib_string}-{version.name}" return f"release-report-{lib_string}-{report_configuration.version}"
def _get_top_contributors_for_version(self): def _get_top_contributors_for_version(self, version):
return ( return (
CommitAuthor.objects.filter( CommitAuthor.objects.filter(commit__library_version__version=version)
commit__library_version__version=self.cleaned_data["version"]
)
.annotate( .annotate(
commit_count=Count( commit_count=Count(
"commit", "commit",
@@ -277,30 +275,32 @@ class CreateReportForm(CreateReportFullForm):
def _get_library_queryset_by_version( def _get_library_queryset_by_version(
self, version: Version, annotate_commit_count=False self, version: Version, annotate_commit_count=False
): ):
qs = self.library_queryset.filter( qs = self.library_queryset.none()
library_version=LibraryVersion.objects.filter( if version:
library=OuterRef("id"), version=version qs = self.library_queryset.filter(
)[:1], library_version=LibraryVersion.objects.filter(
) library=OuterRef("id"), version=version
)[:1],
)
if annotate_commit_count: if annotate_commit_count:
qs = qs.annotate(commit_count=Count("library_version__commit")) qs = qs.annotate(commit_count=Count("library_version__commit"))
return qs return qs
def _get_top_libraries_for_version(self): def _get_top_libraries_for_version(self, version):
library_qs = self._get_library_queryset_by_version( library_qs = self._get_library_queryset_by_version(
self.cleaned_data["version"], annotate_commit_count=True version, annotate_commit_count=True
) )
return library_qs.order_by("-commit_count") return library_qs.order_by("-commit_count")
def _get_libraries_by_name(self): def _get_libraries_by_name(self, version):
library_qs = self._get_library_queryset_by_version( library_qs = self._get_library_queryset_by_version(
self.cleaned_data["version"], annotate_commit_count=True version, annotate_commit_count=True
) )
return library_qs.order_by("name") return library_qs.order_by("name")
def _get_libraries_by_quality(self): def _get_libraries_by_quality(self, version):
# returns "great", "good", and "standard" libraries in that order # returns "great", "good", and "standard" libraries in that order
library_qs = self._get_library_queryset_by_version(self.cleaned_data["version"]) library_qs = self._get_library_queryset_by_version(version)
return list( return list(
chain( chain(
library_qs.filter(graphic__isnull=False), library_qs.filter(graphic__isnull=False),
@@ -309,17 +309,16 @@ class CreateReportForm(CreateReportFullForm):
) )
) )
def _get_library_version_counts(self, libraries, library_order): def _get_library_version_counts(self, library_order, version):
library_qs = self._get_library_queryset_by_version( library_qs = self._get_library_queryset_by_version(
self.cleaned_data["version"], annotate_commit_count=True version, annotate_commit_count=True
) )
return sorted( return sorted(
list(library_qs.values("commit_count", "id")), list(library_qs.values("commit_count", "id")),
key=lambda x: library_order.index(x["id"]), key=lambda x: library_order.index(x["id"]),
) )
def _global_new_contributors(self, library_version): def _global_new_contributors(self, version):
version = self.cleaned_data["version"]
version_lt = list( version_lt = list(
Version.objects.minor_versions() Version.objects.minor_versions()
.filter(version_array__lt=version.cleaned_version_parts_int) .filter(version_array__lt=version.cleaned_version_parts_int)
@@ -343,8 +342,7 @@ class CreateReportForm(CreateReportFullForm):
return set(version_author_ids) - set(prior_version_author_ids) return set(version_author_ids) - set(prior_version_author_ids)
def _count_new_contributors(self, libraries, library_order): def _count_new_contributors(self, libraries, library_order, version):
version = self.cleaned_data["version"]
version_lt = list( version_lt = list(
Version.objects.minor_versions() Version.objects.minor_versions()
.filter(version_array__lt=version.cleaned_version_parts_int) .filter(version_array__lt=version.cleaned_version_parts_int)
@@ -382,12 +380,12 @@ class CreateReportForm(CreateReportFullForm):
key=lambda x: library_order.index(x["id"]), key=lambda x: library_order.index(x["id"]),
) )
def _count_issues(self, libraries, library_order, version): def _count_issues(self, libraries, library_order, version, prior_version):
data = { data = {
x["library_id"]: x x["library_id"]: x
for x in Issue.objects.count_opened_closed_during_release(version).filter( for x in Issue.objects.count_opened_closed_during_release(
library_id__in=[x.id for x in libraries] version, prior_version
) ).filter(library_id__in=[x.id for x in libraries])
} }
ret = [] ret = []
for lib_id in library_order: for lib_id in library_order:
@@ -397,14 +395,14 @@ class CreateReportForm(CreateReportFullForm):
ret.append({"opened": 0, "closed": 0, "library_id": lib_id}) ret.append({"opened": 0, "closed": 0, "library_id": lib_id})
return ret return ret
def _count_commit_contributors_totals(self, version): def _count_commit_contributors_totals(self, version, prior_version):
"""Get a count of contributors for this release, and a count of """Get a count of contributors for this release, and a count of
new contributors. new contributors.
""" """
version_lt = list( version_lt = list(
Version.objects.minor_versions() Version.objects.minor_versions()
.filter(version_array__lt=version.cleaned_version_parts_int) .filter(version_array__lte=prior_version.cleaned_version_parts_int)
.values_list("id", flat=True) .values_list("id", flat=True)
) )
version_lte = version_lt + [version.id] version_lte = version_lt + [version.id]
@@ -439,13 +437,13 @@ class CreateReportForm(CreateReportFullForm):
this_release_count = qs["this_release_count"] this_release_count = qs["this_release_count"]
return this_release_count, new_count return this_release_count, new_count
def _get_top_contributors_for_library_version(self, library_order): def _get_top_contributors_for_library_version(self, library_order, version):
top_contributors_release = [] top_contributors_release = []
for library_id in library_order: for library_id in library_order:
top_contributors_release.append( top_contributors_release.append(
CommitAuthor.objects.filter( CommitAuthor.objects.filter(
commit__library_version=LibraryVersion.objects.get( commit__library_version=LibraryVersion.objects.get(
version=self.cleaned_data["version"], library_id=library_id version=version, library_id=library_id
) )
) )
.annotate(commit_count=Count("commit")) .annotate(commit_count=Count("commit"))
@@ -453,10 +451,10 @@ class CreateReportForm(CreateReportFullForm):
) )
return top_contributors_release return top_contributors_release
def _count_mailinglist_contributors(self, version): def _count_mailinglist_contributors(self, version, prior_version):
version_lt = list( version_lt = list(
Version.objects.minor_versions() Version.objects.minor_versions()
.filter(version_array__lt=version.cleaned_version_parts_int) .filter(version_array__lte=prior_version.cleaned_version_parts_int)
.values_list("id", flat=True) .values_list("id", flat=True)
) )
version_lte = version_lt + [version.id] version_lte = version_lt + [version.id]
@@ -620,7 +618,9 @@ class CreateReportForm(CreateReportFullForm):
): ):
"""Get slack stats for specific channels, or all channels.""" """Get slack stats for specific channels, or all channels."""
start = prior_version.release_date start = prior_version.release_date
end = version.release_date - timedelta(days=1) end = date.today()
if version.release_date:
end = version.release_date - timedelta(days=1)
# count of all messages in the date range # count of all messages in the date range
q = Q(day__range=[start, end]) q = Q(day__range=[start, end])
if channels: if channels:
@@ -671,7 +671,15 @@ class CreateReportForm(CreateReportFullForm):
return diffs return diffs
def get_stats(self): def get_stats(self):
version = self.cleaned_data["version"] report_configuration = self.cleaned_data["report_configuration"]
version = Version.objects.filter(name=report_configuration.version).first()
prior_version = None
if not version:
# if the version is not set then the user has chosen a report configuration
# that's not matching a live version, so we use the most recent version
version = Version.objects.filter(name="master").first()
prior_version = Version.objects.most_recent()
downloads = { downloads = {
k: list(v) k: list(v)
@@ -680,12 +688,14 @@ class CreateReportForm(CreateReportFullForm):
key=attrgetter("operating_system"), key=attrgetter("operating_system"),
) )
} }
prior_version = (
Version.objects.minor_versions() if not prior_version:
.filter(version_array__lt=version.cleaned_version_parts_int) prior_version = (
.order_by("-version_array") Version.objects.minor_versions()
.first() .filter(version_array__lt=version.cleaned_version_parts_int)
) .order_by("-version_array")
.first()
)
commit_count = Commit.objects.filter( commit_count = Commit.objects.filter(
library_version__version__name__lte=version.name, library_version__version__name__lte=version.name,
@@ -696,8 +706,8 @@ class CreateReportForm(CreateReportFullForm):
library_version__library__in=self.library_queryset, library_version__library__in=self.library_queryset,
).count() ).count()
top_libraries_for_version = self._get_top_libraries_for_version() top_libraries_for_version = self._get_top_libraries_for_version(version)
top_libraries_by_name = self._get_libraries_by_name() top_libraries_by_name = self._get_libraries_by_name(version)
library_order = self._get_library_order(top_libraries_by_name) library_order = self._get_library_order(top_libraries_by_name)
libraries = Library.objects.filter(id__in=library_order).order_by( libraries = Library.objects.filter(id__in=library_order).order_by(
Case( Case(
@@ -719,10 +729,10 @@ class CreateReportForm(CreateReportFullForm):
for item in zip( for item in zip(
libraries, libraries,
self._get_library_full_counts(libraries, library_order), self._get_library_full_counts(libraries, library_order),
self._get_library_version_counts(libraries, library_order), self._get_library_version_counts(library_order, version),
self._get_top_contributors_for_library_version(library_order), self._get_top_contributors_for_library_version(library_order, version),
self._count_new_contributors(libraries, library_order), self._count_new_contributors(libraries, library_order, version),
self._count_issues(libraries, library_order, version), self._count_issues(libraries, library_order, version, prior_version),
self._get_library_versions(library_order, version), self._get_library_versions(library_order, version),
self._get_dependency_data(library_order, version), self._get_dependency_data(library_order, version),
) )
@@ -730,7 +740,7 @@ class CreateReportForm(CreateReportFullForm):
library_data = [ library_data = [
x for x in library_data if x["version_count"]["commit_count"] > 0 x for x in library_data if x["version_count"]["commit_count"] > 0
] ]
top_contributors = self._get_top_contributors_for_version() top_contributors = self._get_top_contributors_for_version(version)
# total messages sent during this release (version) # total messages sent during this release (version)
total_mailinglist_count = EmailData.objects.filter(version=version).aggregate( total_mailinglist_count = EmailData.objects.filter(version=version).aggregate(
total=Sum("count") total=Sum("count")
@@ -743,11 +753,11 @@ class CreateReportForm(CreateReportFullForm):
( (
mailinglist_contributor_release_count, mailinglist_contributor_release_count,
mailinglist_contributor_new_count, mailinglist_contributor_new_count,
) = self._count_mailinglist_contributors(version) ) = self._count_mailinglist_contributors(version, prior_version)
( (
commit_contributors_release_count, commit_contributors_release_count,
commit_contributors_new_count, commit_contributors_new_count,
) = self._count_commit_contributors_totals(version) ) = self._count_commit_contributors_totals(version, prior_version)
library_count = LibraryVersion.objects.filter( library_count = LibraryVersion.objects.filter(
version=version, version=version,
library__in=self.library_queryset, library__in=self.library_queryset,
@@ -775,22 +785,35 @@ class CreateReportForm(CreateReportFullForm):
slack_channels = batched( slack_channels = batched(
Channel.objects.filter(name__istartswith="boost").order_by("name"), 10 Channel.objects.filter(name__istartswith="boost").order_by("name"), 10
) )
committee_members = version.financial_committee_members.all() committee_members = report_configuration.financial_committee_members.all()
mailinglist_post_stats = get_mailing_list_post_stats( mailinglist_post_stats = get_mailing_list_post_stats(
prior_version.release_date, version.release_date prior_version.release_date, version.release_date or date.today()
) )
new_subscribers_stats = get_new_subscribers_stats( new_subscribers_stats = get_new_subscribers_stats(
prior_version.release_date, version.release_date prior_version.release_date, version.release_date or date.today()
) )
library_index_library_data = [] library_index_library_data = []
for library in self._get_libraries_by_quality(): for library in self._get_libraries_by_quality(version):
library_index_library_data.append( library_index_library_data.append(
( (
library, library,
library in [lib["library"] for lib in library_data], library in [lib["library"] for lib in library_data],
) )
) )
wordcloud_base64, wordcloud_top_words = generate_wordcloud(version) wordcloud_base64, wordcloud_top_words = generate_wordcloud(
version, prior_version
)
opened_issues_count = (
Issue.objects.filter(library__in=self.library_queryset)
.opened_during_release(version, prior_version)
.count()
)
closed_issues_count = (
Issue.objects.filter(library__in=self.library_queryset)
.closed_during_release(version, prior_version)
.count()
)
return { return {
"committee_members": committee_members, "committee_members": committee_members,
@@ -799,17 +822,10 @@ class CreateReportForm(CreateReportFullForm):
"wordcloud_base64": wordcloud_base64, "wordcloud_base64": wordcloud_base64,
"wordcloud_frequencies": wordcloud_top_words, "wordcloud_frequencies": wordcloud_top_words,
"version": version, "version": version,
"report_configuration": report_configuration,
"prior_version": prior_version, "prior_version": prior_version,
"opened_issues_count": Issue.objects.filter( "opened_issues_count": opened_issues_count,
library__in=self.library_queryset "closed_issues_count": closed_issues_count,
)
.opened_during_release(version)
.count(),
"closed_issues_count": Issue.objects.filter(
library__in=self.library_queryset
)
.closed_during_release(version)
.count(),
"mailinglist_counts": mailinglist_counts, "mailinglist_counts": mailinglist_counts,
"mailinglist_total": total_mailinglist_count or 0, "mailinglist_total": total_mailinglist_count or 0,
"mailinglist_contributor_release_count": mailinglist_contributor_release_count, # noqa: E501 "mailinglist_contributor_release_count": mailinglist_contributor_release_count, # noqa: E501

View File

@@ -111,11 +111,15 @@ def get_commit_data_for_repo_versions(key, min_version=""):
if not is_clone_successful: if not is_clone_successful:
logger.error(f"Clone failed for {library.key}. {message=} {error=}") logger.error(f"Clone failed for {library.key}. {message=} {error=}")
return return
versions = [""] + list( versions = (
Version.objects.minor_versions() [""]
.filter(library_version__library__key=library.key) + list(
.order_by("version_array") Version.objects.minor_versions()
.values_list("name", flat=True) .filter(library_version__library__key=library.key)
.order_by("version_array")
.values_list("name", flat=True)
)
+ ["master"]
) )
for a, b in zip(versions, versions[1:]): for a, b in zip(versions, versions[1:]):
if a < min_version and b < min_version: if a < min_version and b < min_version:
@@ -489,17 +493,29 @@ class LibraryUpdater:
) )
CommitAuthorEmail.objects.create(email=commit.email, author=author) CommitAuthorEmail.objects.create(email=commit.email, author=author)
authors[commit.email] = author authors[commit.email] = author
return Commit(
author=author, try:
library_version=library_versions[commit.version], library_version = library_versions[commit.version]
sha=commit.sha, return Commit(
message=commit.message, author=author,
committed_at=commit.committed_at, library_version=library_version,
is_merge=commit.is_merge, sha=commit.sha,
) message=commit.message,
committed_at=commit.committed_at,
is_merge=commit.is_merge,
)
except KeyError:
logger.error(f"KeyError {commit.version=}")
return None
def handle_version_diff_stat(diff: VersionDiffStat): def handle_version_diff_stat(diff: VersionDiffStat):
lv = library_versions[diff.version] try:
lv = library_versions[diff.version]
except KeyError:
# we iterate over all library versions, but for libraries that
# haven't had updates in a release one may not exist for master/develop
return None
lv.insertions = diff.insertions lv.insertions = diff.insertions
lv.deletions = diff.deletions lv.deletions = diff.deletions
lv.files_changed = diff.files_changed lv.files_changed = diff.files_changed
@@ -509,10 +525,14 @@ class LibraryUpdater:
for item in get_commit_data_for_repo_versions(library.key, min_version): for item in get_commit_data_for_repo_versions(library.key, min_version):
match item: match item:
case ParsedCommit(): case ParsedCommit():
commits_handled += 1 commit_item = handle_commit(item)
commits.append(handle_commit(item)) if commit_item:
commits.append(commit_item)
commits_handled += 1
case VersionDiffStat(): case VersionDiffStat():
library_version_updates.append(handle_version_diff_stat(item)) lv_update = handle_version_diff_stat(item)
if lv_update:
library_version_updates.append(lv_update)
case _: case _:
assert_never() assert_never()

View File

@@ -1,46 +1,36 @@
from datetime import date
from django.db import models from django.db import models
from django.db.models import Q, Count from django.db.models import Q, Count
from versions.models import Version
class IssueQuerySet(models.QuerySet): class IssueQuerySet(models.QuerySet):
def closed_during_release(self, version): def closed_during_release(self, version, prior_version):
"""Get the issues that were closed during a specific version. """Get the issues that were closed during a specific version.
Uses the release dates of the version and the prior version and queries for Uses the release dates of the version and the prior version and queries for
issues closed in that timeframe. issues closed in that timeframe.
""" """
prior_release = ( release_date = version.release_date
Version.objects.minor_versions() if version.name == "master":
.filter(release_date__lt=version.release_date) release_date = date.today()
.order_by("-release_date")
.first()
)
if not prior_release:
return self.none()
return self.filter( return self.filter(
closed__gte=prior_release.release_date, closed__lt=version.release_date closed__gte=prior_version.release_date, closed__lt=release_date
) )
def opened_during_release(self, version): def opened_during_release(self, version, prior_version):
"""Get the issues that were created during a specific version release. """Get the issues that were created during a specific version release.
Uses the release dates of the version and the prior version and queries for Uses the release dates of the version and the prior version and queries for
issues created in that timeframe. issues created in that timeframe.
""" """
prior_release = ( release_date = version.release_date
Version.objects.minor_versions() if version.name == "master":
.filter(release_date__lt=version.release_date) release_date = date.today()
.order_by("-release_date")
.first()
)
if not prior_release:
return self.none()
return self.filter( return self.filter(
created__gte=prior_release.release_date, created__lt=version.release_date created__gte=prior_version.release_date, created__lt=release_date
) )
@@ -48,35 +38,32 @@ class IssueManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return IssueQuerySet(self.model, using=self._db) return IssueQuerySet(self.model, using=self._db)
def closed_during_release(self, version): def closed_during_release(self, version, prior_version):
return self.get_queryset().closed_during_release(version) return self.get_queryset().closed_during_release(version, prior_version)
def opened_during_release(self, version): def opened_during_release(self, version, prior_version):
return self.get_queryset().opened_during_release(version) return self.get_queryset().opened_during_release(version, prior_version)
def count_opened_closed_during_release(self, version): def count_opened_closed_during_release(self, version, prior_version):
if version is None:
return self.get_queryset().none()
qs = self.get_queryset() qs = self.get_queryset()
prior_release = ( release_date = version.release_date
Version.objects.minor_versions() if version.name == "master":
.filter(release_date__lt=version.release_date) release_date = date.today()
.order_by("-release_date")
.first()
)
if not prior_release:
return qs.none()
return qs.values("library_id").annotate( return qs.values("library_id").annotate(
opened=Count( opened=Count(
"id", "id",
filter=Q( filter=Q(
created__gte=prior_release.release_date, created__gte=prior_version.release_date,
created__lt=version.release_date, created__lt=release_date,
), ),
), ),
closed=Count( closed=Count(
"id", "id",
filter=Q( filter=Q(
closed__gte=prior_release.release_date, closed__gte=prior_version.release_date,
closed__lt=version.release_date, closed__lt=release_date,
), ),
), ),
) )

View File

@@ -1,3 +1,4 @@
from datetime import date
from itertools import pairwise from itertools import pairwise
import djclick as click import djclick as click
import psycopg2 import psycopg2
@@ -57,9 +58,10 @@ def create_emaildata(conn: Connection):
versions = Version.objects.minor_versions().order_by("version_array") versions = Version.objects.minor_versions().order_by("version_array")
columns = ["email", "name", "count"] columns = ["email", "name", "count"]
versions = list(versions) + [Version.objects.get(name="master")]
for a, b in pairwise(versions): for a, b in pairwise(versions):
start = a.release_date start = a.release_date
end = b.release_date end = b.release_date or date.today()
if not (start and end): if not (start and end):
raise ValueError("All x.x.0 versions must have a release date.") raise ValueError("All x.x.0 versions must have a release date.")
with conn.cursor(name=f"emaildata_sync_{b.name}") as cursor: with conn.cursor(name=f"emaildata_sync_{b.name}") as cursor:

View File

@@ -2,7 +2,7 @@ import base64
import io import io
import logging import logging
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
import psycopg2 import psycopg2
from django.conf import settings from django.conf import settings
@@ -21,7 +21,9 @@ from versions.models import Version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def generate_wordcloud(version: Version) -> tuple[str | None, list]: def generate_wordcloud(
version: Version, prior_version: Version
) -> tuple[str | None, list]:
"""Generates a wordcloud png and returns it as a base64 string and word frequencies. """Generates a wordcloud png and returns it as a base64 string and word frequencies.
Returns: Returns:
@@ -42,7 +44,7 @@ def generate_wordcloud(version: Version) -> tuple[str | None, list]:
font_path=font_full_path, font_path=font_full_path,
) )
word_frequencies = {} word_frequencies = {}
for content in get_mail_content(version): for content in get_mail_content(version, prior_version):
for key, val in wc.process_text(content).items(): for key, val in wc.process_text(content).items():
if len(key) < 2: if len(key) < 2:
continue continue
@@ -104,13 +106,7 @@ def grey_color_func(*args, **kwargs):
return "hsl(0, 0%%, %d%%)" % random.randint(10, 80) return "hsl(0, 0%%, %d%%)" % random.randint(10, 80)
def get_mail_content(version: Version): def get_mail_content(version: Version, prior_version: Version):
prior_version = (
Version.objects.minor_versions()
.filter(version_array__lt=version.cleaned_version_parts_int)
.order_by("-release_date")
.first()
)
if not prior_version or not settings.HYPERKITTY_DATABASE_NAME: if not prior_version or not settings.HYPERKITTY_DATABASE_NAME:
return [] return []
conn = psycopg2.connect(settings.HYPERKITTY_DATABASE_URL) conn = psycopg2.connect(settings.HYPERKITTY_DATABASE_URL)
@@ -120,7 +116,10 @@ def get_mail_content(version: Version):
SELECT content FROM hyperkitty_email SELECT content FROM hyperkitty_email
WHERE date >= %(start)s AND date < %(end)s; WHERE date >= %(start)s AND date < %(end)s;
""", """,
{"start": prior_version.release_date, "end": version.release_date}, {
"start": prior_version.release_date,
"end": version.release_date or date.today(),
},
) )
for [content] in cursor: for [content] in cursor:
yield content yield content

View File

@@ -41,6 +41,7 @@ body {
</style> </style>
{% endblock css %} {% endblock css %}
{% block content %} {% block content %}
{% now "F j, Y" as today %}
{% with bg_color='' %} {% with bg_color='' %}
<div> <div>
<div class="pdf-page grid grid-cols-2 gap-x-4 items-center justify-items-center {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}')"> <div class="pdf-page grid grid-cols-2 gap-x-4 items-center justify-items-center {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}')">
@@ -51,12 +52,13 @@ body {
class="mt-[3px]" class="mt-[3px]"
style="width:3.3rem; margin-right:.5rem;" src="{% static 'img/Boost_Symbol_Transparent.svg' %}" style="width:3.3rem; margin-right:.5rem;" src="{% static 'img/Boost_Symbol_Transparent.svg' %}"
> >
<span class="font-bold">Boost</span>&nbsp;<span class="text-[2.75rem] self-end mb-[2px]">{{ version.display_name }}</span> <span class="font-bold">Boost</span>&nbsp;<span class="text-[2.75rem] self-end mb-[2px]">{{ report_configuration.display_name }}</span>
</h1> </h1>
<div class="flex gap-x-12 link-icons my-4 text-2xl justify-between"> <div class="flex gap-x-12 link-icons my-4 text-2xl justify-between">
{% include "includes/_social_icon_links.html" %} {% include "includes/_social_icon_links.html" %}
</div> </div>
<div><span class="font-bold">{{ commit_count|intcomma }}</span> commit{{ commit_count|pluralize }} up through {{ version.display_name }}</div> <div class="mb-2" class="font-bold">{% firstof version.release_date today %}</div>
<div><span class="font-bold">{{ commit_count|intcomma }}</span> commit{{ commit_count|pluralize }} up through {{ report_configuration.display_name }}</div>
<div><span class="font-bold">{{ lines_added|intcomma }}</span> line{{ lines_added|pluralize }} added, <span class="font-bold">{{ lines_removed|intcomma }}</span> line{{ lines_removed|pluralize }} removed</div> <div><span class="font-bold">{{ lines_added|intcomma }}</span> line{{ lines_added|pluralize }} added, <span class="font-bold">{{ lines_removed|intcomma }}</span> line{{ lines_removed|pluralize }} removed</div>
<div><span class="font-bold">{{ version_commit_count|intcomma }}</span> new commit{{ version_commit_count|pluralize }} in all library repositories</div> <div><span class="font-bold">{{ version_commit_count|intcomma }}</span> new commit{{ version_commit_count|pluralize }} in all library repositories</div>
<div><span class="font-bold">{{ commit_contributors_release_count }}</span> commit contributors, <span class="font-bold">{{ commit_contributors_new_count }}</span> new</div> <div><span class="font-bold">{{ commit_contributors_release_count }}</span> commit contributors, <span class="font-bold">{{ commit_contributors_new_count }}</span> new</div>
@@ -102,10 +104,10 @@ body {
</div> </div>
<div class="flex flex-col h-full justify-between"> <div class="flex flex-col h-full justify-between">
{% if version.release_report_cover_image and version.release_report_cover_image.url %} {% if report_configuration.release_report_cover_image and report_configuration.release_report_cover_image.url %}
<img <img
class="max-h-[60%]" class="max-h-[60%]"
src="{{ version.release_report_cover_image.url|strip_query_string }}" src="{{ report_configuration.release_report_cover_image.url|strip_query_string }}"
alt="release report cover image" alt="release report cover image"
> >
{% endif %} {% endif %}
@@ -151,7 +153,7 @@ body {
</div> </div>
</div> </div>
{% if version.sponsor_message %} {% if report_configuration.sponsor_message %}
<div id="fiscal_committee_page" class="pdf-page !p-8 {{ bg_color }} bg-gray-200 relative"> <div id="fiscal_committee_page" class="pdf-page !p-8 {{ bg_color }} bg-gray-200 relative">
<h2 class="mt-0">From the Fiscal Sponsorship Committee</h2> <h2 class="mt-0">From the Fiscal Sponsorship Committee</h2>
<div class="flex flex-col w-full h-[85%] sponsor-message relative"> <div class="flex flex-col w-full h-[85%] sponsor-message relative">
@@ -169,7 +171,7 @@ body {
d="M 28.068153,0 H 471.93339 c 17.18711,0 28.0676,10.63409 28.0674,26.12903 l -8e-4,56.64968 c 0,16.63468 -9.6004,26.12907 -28.0674,26.12907 -144.5008,3.63566 -356.11545,0.75984 -445.23187,0 -15.3824,0 -26.70095,-10.79719 -26.70074,-26.12907 l 7.8e-4,-56.64968 c 1.8e-4,-13.27403 12.64918,-26.12903 28.06737,-26.12903 z" d="M 28.068153,0 H 471.93339 c 17.18711,0 28.0676,10.63409 28.0674,26.12903 l -8e-4,56.64968 c 0,16.63468 -9.6004,26.12907 -28.0674,26.12907 -144.5008,3.63566 -356.11545,0.75984 -445.23187,0 -15.3824,0 -26.70095,-10.79719 -26.70074,-26.12907 l 7.8e-4,-56.64968 c 1.8e-4,-13.27403 12.64918,-26.12903 28.06737,-26.12903 z"
/> />
</svg> </svg>
<div class="sponsor_message_copy dynamic-text p-[30px] mr-16 absolute z-20 max-h-[340px]">{{ version.sponsor_message|safe }}</div> <div class="sponsor_message_copy dynamic-text p-[30px] mr-16 absolute z-20 max-h-[340px]">{{ report_configuration.sponsor_message|safe }}</div>
<div class="committee_members flex flex-wrap mt-2 text-sm text-center absolute" style=""> <div class="committee_members flex flex-wrap mt-2 text-sm text-center absolute" style="">
{% for user in committee_members|dictsort:"display_name" %} {% for user in committee_members|dictsort:"display_name" %}
<figure class="w-32 m-2"> <figure class="w-32 m-2">
@@ -247,12 +249,12 @@ body {
{% else %} {% else %}
no no
{% endif %} {% endif %}
mailing list post{{ mailinglist_total|pluralize }} in version&nbsp;{{ version.display_name }} and mailing list post{{ mailinglist_total|pluralize }} in version&nbsp;{{ report_configuration.display_name }} and
<span class="font-bold">{{ mailinglist_contributor_release_count }}</span> <span class="font-bold">{{ mailinglist_contributor_release_count }}</span>
poster{{ mailinglist_contributor_release_count|pluralize }} poster{{ mailinglist_contributor_release_count|pluralize }}
in this version. (<span class="font-bold">{{ mailinglist_contributor_new_count }}</span> New) in this version. (<span class="font-bold">{{ mailinglist_contributor_new_count }}</span> New)
</div> </div>
<div class="text-center my-2">Weekly mailing list posts from {{prior_version.release_date}} to {{version.release_date}} on the Boost Developers mailing list.</div> <div class="text-center my-2">Weekly mailing list posts from {{prior_version.release_date}} to {% firstof version.release_date today %} on the Boost Developers mailing list.</div>
<div id="release_post_stats"></div> <div id="release_post_stats"></div>
</div> </div>
</div> </div>
@@ -261,7 +263,7 @@ body {
<div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');"> <div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');">
<div class="flex flex-col h-full mx-auto w-full"> <div class="flex flex-col h-full mx-auto w-full">
<h2 class="mx-auto mb-10">Mailing List New Subscribers</h2> <h2 class="mx-auto mb-10">Mailing List New Subscribers</h2>
<div class="text-center my-2">Mailing list new subscribers from from {{prior_version.release_date}} to {{version.release_date}} on the Boost Developers mailing list.</div> <div class="text-center my-2">Mailing list new subscribers from from {{prior_version.release_date}} to {% firstof version.release_date today %} on the Boost Developers mailing list.</div>
<div id="subscriptions_stats"></div> <div id="subscriptions_stats"></div>
</div> </div>
@@ -403,7 +405,7 @@ body {
{{ item.version_count.commit_count|pluralize:"was,were" }} {{ item.version_count.commit_count|pluralize:"was,were" }}
<span class="font-bold">{{ item.version_count.commit_count }}</span> <span class="font-bold">{{ item.version_count.commit_count }}</span>
commit{{ item.version_count.commit_count|pluralize }} commit{{ item.version_count.commit_count|pluralize }}
in release {{ version.display_name }} in release {{ report_configuration.display_name }}
</div> </div>
{% with insertions=item.library_version.insertions deletions=item.library_version.deletions %} {% with insertions=item.library_version.insertions deletions=item.library_version.deletions %}
<div> <div>

View File

@@ -99,3 +99,9 @@ class ReviewResultAdmin(admin.ModelAdmin):
def get_queryset(self, request: HttpRequest) -> QuerySet: def get_queryset(self, request: HttpRequest) -> QuerySet:
return super().get_queryset(request).select_related("review") return super().get_queryset(request).select_related("review")
@admin.register(models.ReportConfiguration)
class ReportConfigurationAdmin(admin.ModelAdmin):
list_display = ["version"]
filter_horizontal = ["financial_committee_members"]

View File

@@ -0,0 +1,58 @@
# Generated by Django 4.2.16 on 2025-07-03 21:09
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("versions", "0018_version_financial_committee_members"),
]
operations = [
migrations.CreateModel(
name="ReportConfiguration",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"version",
models.CharField(
help_text="The version name this report configuration is for.",
max_length=256,
),
),
(
"release_report_cover_image",
models.ImageField(
blank=True, null=True, upload_to="release_report_cover/"
),
),
(
"sponsor_message",
models.TextField(
blank=True,
default="",
help_text='Message to show in release reports on the "Fiscal Sponsorship Committee" page.',
),
),
(
"financial_committee_members",
models.ManyToManyField(
blank=True,
help_text="Financial Committee members who are responsible for this release.",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.2.16 on 2025-07-03 21:12
from django.db import migrations
def copy_report_configuration(apps, schema_editor):
ReportConfiguration = apps.get_model("versions", "ReportConfiguration")
Version = apps.get_model("versions", "Version")
version_data = [ {
"name": vd.name,
"release_report_cover_image": vd.release_report_cover_image,
"sponsor_message": vd.sponsor_message,
"financial_committee_members": list(vd.financial_committee_members.values_list("id", flat=True)),
} for vd in Version.objects.all().prefetch_related("financial_committee_members")]
for vd in version_data:
configuration = ReportConfiguration.objects.create(
version=vd["name"],
release_report_cover_image=vd["release_report_cover_image"],
sponsor_message=vd["sponsor_message"],
)
configuration.financial_committee_members.set(vd["financial_committee_members"])
def drop_report_configuration(apps, schema_editor):
ReportConfiguration = apps.get_model("versions", "ReportConfiguration")
output = ReportConfiguration.objects.all().delete()
print(f"\nDeleted {output}...")
class Migration(migrations.Migration):
dependencies = [
("versions", "0019_reportconfiguration"),
]
operations = [migrations.RunPython(copy_report_configuration, drop_report_configuration)]

View File

@@ -282,3 +282,40 @@ class ReviewResult(models.Model):
) )
sibling_results.update(is_most_recent=False) sibling_results.update(is_most_recent=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ReportConfiguration(models.Model):
"""
Configuration for release reports. Used so these can be set in advance of a version
being generated and then used by that version's release report.
"""
version = models.CharField(
max_length=256,
null=False,
blank=False,
help_text="The version name for this report configuration. e.g. boost-1.75.0",
unique=True,
)
release_report_cover_image = models.ImageField(
null=True,
blank=True,
upload_to="release_report_cover/",
)
sponsor_message = models.TextField(
default="",
blank=True,
help_text='Message to show in release reports on the "Fiscal Sponsorship Committee" page.', # noqa: E501
)
financial_committee_members = models.ManyToManyField(
User,
blank=True,
help_text="Financial Committee members who are responsible for this release.",
)
@cached_property
def display_name(self):
return self.version.replace("boost-", "")
def __str__(self):
return self.version