Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support for bundling #33

Merged
merged 15 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .docker/Procfile
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
web: python manage.py runserver 0.0.0.0:8000
scheduler: python manage.py scheduler
frontend: npm run start:reload
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ default_language_version:
repos:
# Python linting and formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.7.1' # keep version in sync with pyproject.toml
rev: 'v0.7.4' # keep version in sync with pyproject.toml
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
Expand Down
2 changes: 2 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ disable=raw-checker-failed,
too-many-ancestors,
too-few-public-methods,
missing-class-docstring,
missing-function-docstring,
fixme

# note:
Expand All @@ -445,6 +446,7 @@ disable=raw-checker-failed,
# - too-many-ancestors: because of our and Wagtail's use of mixins
# - too-few-public-methods: because of Django Meta classes
# - missing-class-docstring: mostly because of Django classes.
# - missing-function-docstring: because we can't disable it for trivial functions.
# - fixme: because we want to leave TODO notes for future features.

# Enable the message, report, category or checker with the given id(s). You can
Expand Down
4 changes: 3 additions & 1 deletion cms/analysis/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from wagtail.search import index

from cms.analysis.blocks import AnalysisStoryBlock
from cms.bundles.models import BundledPageMixin
from cms.core.blocks import HeadlineFiguresBlock
from cms.core.fields import StreamField
from cms.core.models import BasePage
Expand Down Expand Up @@ -75,7 +76,7 @@ def previous_releases(self, request: "HttpRequest") -> "TemplateResponse":
return response


class AnalysisPage(BasePage): # type: ignore[django-manager-missing]
class AnalysisPage(BundledPageMixin, BasePage): # type: ignore[django-manager-missing]
"""The analysis page model."""

parent_page_types: ClassVar[list[str]] = ["AnalysisSeries"]
Expand Down Expand Up @@ -124,6 +125,7 @@ class AnalysisPage(BasePage): # type: ignore[django-manager-missing]
show_cite_this_page = models.BooleanField(default=True)

content_panels: ClassVar[list["Panel"]] = [
*BundledPageMixin.panels,
MultiFieldPanel(
[
TitleFieldPanel("title", help_text=_("Also known as the release edition. e.g. 'November 2024'.")),
Expand Down
Empty file added cms/bundles/__init__.py
Empty file.
43 changes: 43 additions & 0 deletions cms/bundles/admin_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import TYPE_CHECKING, Any

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

from .models import Bundle, BundledPageMixin
from .viewsets import BundleChooserWidget

if TYPE_CHECKING:
from wagtail.models import Page


class AddToBundleForm(forms.Form):
"""Administrative form used in the 'add to bundle' view."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
self.page_to_add: Page = kwargs.pop("page_to_add")

super().__init__(*args, **kwargs)

self.fields["bundle"] = forms.ModelChoiceField(
queryset=Bundle.objects.editable(),
widget=BundleChooserWidget(),
label=_("Bundle"),
help_text=_("Select a bundle for this page."),
)

def clean(self) -> None:
super().clean()

if not isinstance(self.page_to_add, BundledPageMixin):
# While this form is used the "add to bundle" view which already checks for this,
# it doesn't hurt to trust but verify.
raise ValidationError(_("Pages of this type cannot be added."))

bundle = self.cleaned_data.get("bundle")
if bundle and bundle.bundled_pages.filter(page=self.page_to_add).exists():
message = _("Page '%(page)s' is already in bundle '%(bundle)s'") % {
"page": self.page_to_add.get_admin_display_title(), # type: ignore[attr-defined]
"bundle": bundle,
}
raise ValidationError({"bundle": message})
8 changes: 8 additions & 0 deletions cms/bundles/admin_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.urls import path

from . import views

app_name = "bundles"
urlpatterns = [
path("add/<int:page_to_add_id>/", views.add_to_bundle, name="add_to_bundle"),
]
8 changes: 8 additions & 0 deletions cms/bundles/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.apps import AppConfig


class BundlesAppConfig(AppConfig):
"""The bundles app config."""

default_auto_field = "django.db.models.AutoField"
name = "cms.bundles"
18 changes: 18 additions & 0 deletions cms/bundles/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import models
from django.utils.translation import gettext_lazy as _


class BundleStatus(models.TextChoices):
"""The bundle statuses."""

PENDING = "PENDING", _("Pending")
IN_REVIEW = "IN_REVIEW", _("In Review")
APPROVED = "APPROVED", _("Approved")
RELEASED = "RELEASED", _("Released")


ACTIVE_BUNDLE_STATUSES = [BundleStatus.PENDING, BundleStatus.IN_REVIEW, BundleStatus.APPROVED]
ACTIVE_BUNDLE_STATUS_CHOICES = [
(BundleStatus[choice].value, BundleStatus[choice].label) for choice in ACTIVE_BUNDLE_STATUSES
]
EDITABLE_BUNDLE_STATUSES = [BundleStatus.PENDING, BundleStatus.IN_REVIEW]
98 changes: 98 additions & 0 deletions cms/bundles/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from typing import TYPE_CHECKING, Any

from django import forms
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.translation import gettext as _
from wagtail.admin.forms import WagtailAdminModelForm

from cms.bundles.enums import ACTIVE_BUNDLE_STATUS_CHOICES, EDITABLE_BUNDLE_STATUSES, BundleStatus

if TYPE_CHECKING:
from .models import Bundle


class BundleAdminForm(WagtailAdminModelForm):
"""The Bundle admin form used in the add/edit interface."""

instance: "Bundle"

def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Helps the form initialisation.

- Hides the "Released" status choice as that happens on publish
- disabled/hide the approved at/by fields
"""
super().__init__(*args, **kwargs)
# hide the "Released" status choice
if self.instance.status in EDITABLE_BUNDLE_STATUSES:
self.fields["status"].choices = ACTIVE_BUNDLE_STATUS_CHOICES
elif self.instance.status == BundleStatus.APPROVED.value:
for field_name in self.fields:
if field_name != "status":
self.fields[field_name].disabled = True

# fully hide and disable the approved_at/by fields to prevent form tampering
self.fields["approved_at"].disabled = True
self.fields["approved_at"].widget = forms.HiddenInput()
self.fields["approved_by"].disabled = True
self.fields["approved_by"].widget = forms.HiddenInput()

self.original_status = self.instance.status

def _validate_bundled_pages(self) -> None:
"""Validates and tidies up related pages.

- if we have an empty page reference, remove it form the form data
- ensure the selected page is not in another active bundle.
"""
for idx, form in enumerate(self.formsets["bundled_pages"].forms):
if not form.is_valid():
continue

page = form.clean().get("page")
if page is None:
# tidy up in case the page reference is empty
self.formsets["bundled_pages"].forms[idx].cleaned_data["DELETE"] = True
else:
page = page.specific
if page.in_active_bundle and page.active_bundle != self.instance and not form.cleaned_data["DELETE"]:
raise ValidationError(
_("'%(page)s' is already in an active bundle (%(bundle)s)")
% {
"page": page,
"bundle": page.active_bundle,
}
)

def clean(self) -> dict[str, Any] | None:
"""Validates the form.

- the bundle cannot be self-approved. That is, someone other than the bundle creator must approve it.
- tidies up/ populates approved at/by
"""
cleaned_data: dict[str, Any] = super().clean()

self._validate_bundled_pages()

status = cleaned_data["status"]
if self.instance.status != status:
# the status has changed, let's check
if status == BundleStatus.APPROVED:
if self.instance.created_by_id == self.for_user.pk:
cleaned_data["status"] = self.instance.status
self.add_error("status", ValidationError("You cannot self-approve your own bundle!"))
else:
# the approver is different from the creator, so let's populate the relevant fields.
cleaned_data["approved_at"] = timezone.now()
cleaned_data["approved_by"] = self.for_user
elif self.instance.status == BundleStatus.APPROVED:
cleaned_data["approved_at"] = None
cleaned_data["approved_by"] = None

if self.cleaned_data["release_calendar_page"] and self.cleaned_data["publication_date"]:
error = _("You must choose either a Release Calendar page or a Publication date, not both.")
self.add_error("release_calendar_page", error)
self.add_error("publication_date", error)

return cleaned_data
Empty file.
Empty file.
121 changes: 121 additions & 0 deletions cms/bundles/management/commands/publish_bundles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import logging
import time
import uuid
from typing import TYPE_CHECKING, Any

from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from wagtail.log_actions import log

from cms.bundles.enums import BundleStatus
from cms.bundles.models import Bundle
from cms.bundles.notifications import notify_slack_of_publication_start, notify_slack_of_publish_end
from cms.release_calendar.enums import ReleaseStatus

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from django.core.management.base import CommandParser


class Command(BaseCommand):
"""The management command class for bundled publishing."""

base_url: str = ""

def add_arguments(self, parser: "CommandParser") -> None:
parser.add_argument(
"--dry-run",
action="store_true",
dest="dry_run",
default=False,
help="Dry run -- don't change anything.",
)

def _update_related_release_calendar_page(self, bundle: Bundle) -> None:
"""Updates the release calendar page related to the bundle with the pages in the bundle."""
content = []
pages = []
for page in bundle.get_bundled_pages():
pages.append(
{
"id": uuid.uuid4(),
"type": "item",
"value": {"page": page.pk, "title": "", "description": "", "external_url": ""},
}
)
if pages:
content.append({"type": "release_content", "value": {"title": "Publications", "links": pages}})

page = bundle.release_calendar_page
page.content = content
page.status = ReleaseStatus.PUBLISHED
revision = page.save_revision(log_action=True)
revision.publish()

# TODO: revisit after discussion.
@transaction.atomic
def handle_bundle(self, bundle: Bundle) -> None:
"""Manages the bundle publication.

- published related pages
- updates the release calendar entry
"""
# only provide a URL if we can generate a full one
inspect_url = self.base_url + reverse("bundle:inspect", args=(bundle.pk,)) if self.base_url else None

logger.info("Publishing bundle=%d", bundle.id)
start_time = time.time()
notify_slack_of_publication_start(bundle, url=inspect_url)
for page in bundle.get_bundled_pages():
if (revision := page.scheduled_revision) is None:
continue
# just run publish for the revision -- since the approved go
# live datetime is before now it will make the object live
revision.publish(log_action="wagtail.publish.scheduled")

# update the related release calendar and publish
if bundle.release_calendar_page_id:
self._update_related_release_calendar_page(bundle)

bundle.status = BundleStatus.RELEASED
bundle.save()
publish_duration = time.time() - start_time
logger.info("Published bundle=%d duration=%.3fms", bundle.id, publish_duration * 1000)

notify_slack_of_publish_end(bundle, publish_duration, url=inspect_url)

log(action="wagtail.publish.scheduled", instance=bundle)

def handle(self, *args: Any, **options: dict[str, Any]) -> None:
dry_run = False
if options["dry_run"]:
self.stdout.write("Will do a dry run.")
dry_run = True

self.base_url = getattr(settings, "WAGTAILADMIN_BASE_URL", "")

bundles_to_publish = Bundle.objects.filter(status=BundleStatus.APPROVED, release_date__lte=timezone.now())
if dry_run:
self.stdout.write("\n---------------------------------")
if bundles_to_publish:
self.stdout.write("Bundles to be published:")
for bundle in bundles_to_publish:
self.stdout.write(f"- {bundle.name}")
bundled_pages = [
f"{page.get_admin_display_title()} ({page.__class__.__name__})"
for page in bundle.get_bundled_pages().specific()
]
self.stdout.write(f' Pages: {"\n\t ".join(bundled_pages)}')

else:
self.stdout.write("No bundles to go live.")
else:
for bundle in bundles_to_publish:
try:
self.handle_bundle(bundle)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Publish failed bundle=%d", bundle.id)
Loading
Loading