246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""The only application path for changing editorial revisions and publication pointers."""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from django.core.exceptions import PermissionDenied, ValidationError
|
|
from django.db import transaction
|
|
from django.db.models import Max
|
|
from django.utils import timezone
|
|
|
|
from .models import ABOUT_KEYS, HOME_KEYS, EditorialPage, PageRevision, PageSection, PublicationAudit, SiteIdentity, SiteRevision
|
|
|
|
|
|
PAGE_FIELDS = ("title", "intro", "seo_title", "seo_description", "seo_image")
|
|
SITE_FIELDS = (
|
|
"official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt",
|
|
"navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image",
|
|
)
|
|
SECTION_FIELDS = (
|
|
"key", "section_type", "position", "visible", "heading", "body", "intro",
|
|
"all_link_label", "primary_action", "secondary_action", "entries", "image", "image_alt",
|
|
)
|
|
|
|
|
|
class PublishConflict(Exception):
|
|
"""The published pointer changed since the editor loaded it."""
|
|
|
|
|
|
def _allowed(actor, codename):
|
|
return bool(actor and actor.is_authenticated and actor.is_active and (
|
|
actor.is_superuser or (actor.is_staff and actor.has_perm(f"content.{codename}"))
|
|
))
|
|
|
|
|
|
def require_permission(actor, codename):
|
|
if not _allowed(actor, codename):
|
|
raise PermissionDenied("You do not have permission for this content action.")
|
|
|
|
|
|
def _fields(data, allowed):
|
|
if not isinstance(data, dict) or not set(data) <= set(allowed):
|
|
raise ValidationError("Unexpected editorial fields.")
|
|
return dict(data)
|
|
|
|
|
|
def _next_number(target):
|
|
return (target.revisions.aggregate(Max("number"))["number__max"] or 0) + 1
|
|
|
|
|
|
def _checksum(data):
|
|
encoded = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _field_values(instance, names):
|
|
return {field: getattr(instance, f"{field}_id") if field in {"logo", "seo_image", "image"} else getattr(instance, field) for field in names}
|
|
|
|
|
|
def _copy_fields(instance, names):
|
|
return {field: getattr(instance, field) for field in names}
|
|
|
|
|
|
def _page_checksum(revision):
|
|
sections = [_field_values(section, SECTION_FIELDS) for section in revision.sections.order_by("position")]
|
|
return _checksum({"page": _field_values(revision, PAGE_FIELDS), "sections": sections})
|
|
|
|
|
|
def _site_checksum(revision):
|
|
return _checksum(_field_values(revision, SITE_FIELDS))
|
|
|
|
|
|
def _check_media_approved(revision):
|
|
media = []
|
|
for field in ("logo", "seo_image"):
|
|
if hasattr(revision, field):
|
|
media.append(getattr(revision, field))
|
|
if isinstance(revision, PageRevision):
|
|
media.extend(section.image for section in revision.sections.all())
|
|
if any(asset is not None and not asset.approved for asset in media):
|
|
raise ValidationError("All published media must be approved.")
|
|
|
|
|
|
def _validate_page(revision):
|
|
revision.full_clean()
|
|
sections = list(revision.sections.order_by("position"))
|
|
for section in sections:
|
|
section.full_clean()
|
|
slug = revision.page.slug
|
|
keys = [section.key for section in sections]
|
|
if slug == "home" and keys != list(HOME_KEYS):
|
|
raise ValidationError("Home requires all four sections in the fixed order.")
|
|
if slug == "about" and (not keys or keys[0] != "identity" or keys[-1] != "contact" or set(keys) - set(ABOUT_KEYS)):
|
|
raise ValidationError("About requires identity first and contact last.")
|
|
if slug in {"events", "blog"} and sections:
|
|
raise ValidationError("Listing pages cannot have sections.")
|
|
_check_media_approved(revision)
|
|
|
|
|
|
def _validate_site(revision):
|
|
revision.full_clean()
|
|
_check_media_approved(revision)
|
|
|
|
|
|
@transaction.atomic
|
|
def save_page_draft(actor, page: EditorialPage, fields: dict, sections: list[dict], *, base_revision=None):
|
|
require_permission(actor, "change_content_draft")
|
|
page = EditorialPage.objects.select_for_update().get(pk=page.pk)
|
|
if base_revision is not None:
|
|
base_revision = PageRevision.objects.get(pk=base_revision.pk)
|
|
if base_revision.page_id != page.pk:
|
|
raise ValidationError("Base revision belongs to another page.")
|
|
if base_revision.pk != page.revisions.order_by("-number").values_list("pk", flat=True).first():
|
|
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
|
if not isinstance(sections, list):
|
|
raise ValidationError("Sections must be a list.")
|
|
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_fields(fields, PAGE_FIELDS))
|
|
revision.full_clean()
|
|
revision.save()
|
|
for data in sections:
|
|
section = PageSection(revision=revision, **_fields(data, SECTION_FIELDS))
|
|
section.full_clean()
|
|
section.save()
|
|
revision.content_checksum = _page_checksum(revision)
|
|
revision.save(update_fields=["content_checksum"])
|
|
PublicationAudit.objects.create(actor=actor, page=page, from_revision=base_revision.number if base_revision else None, to_revision=revision.number, operation="update_draft" if base_revision else "create_draft")
|
|
return revision
|
|
|
|
|
|
@transaction.atomic
|
|
def save_site_draft(actor, site: SiteIdentity, fields: dict, *, base_revision=None):
|
|
require_permission(actor, "change_content_draft")
|
|
site = SiteIdentity.objects.select_for_update().get(pk=site.pk)
|
|
if base_revision is not None:
|
|
base_revision = SiteRevision.objects.get(pk=base_revision.pk)
|
|
if base_revision.site_id != site.pk:
|
|
raise ValidationError("Base revision belongs to another site.")
|
|
if base_revision.pk != site.revisions.order_by("-number").values_list("pk", flat=True).first():
|
|
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
|
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_fields(fields, SITE_FIELDS))
|
|
revision.full_clean()
|
|
revision.content_checksum = _site_checksum(revision)
|
|
revision.save()
|
|
PublicationAudit.objects.create(actor=actor, site=site, from_revision=base_revision.number if base_revision else None, to_revision=revision.number, operation="update_draft" if base_revision else "create_draft")
|
|
return revision
|
|
|
|
|
|
def _check_expected(target, expected_published_id):
|
|
if target.published_revision_id != expected_published_id:
|
|
raise PublishConflict("Published content changed. Reload before publishing.")
|
|
|
|
|
|
@transaction.atomic
|
|
def publish_page(actor, draft: PageRevision, *, expected_published_id):
|
|
require_permission(actor, "publish_content")
|
|
page = EditorialPage.objects.select_for_update().get(pk=draft.page_id)
|
|
_check_expected(page, expected_published_id)
|
|
revision = PageRevision.objects.get(pk=draft.pk, page=page)
|
|
if revision.status != "draft":
|
|
raise ValidationError("Only a draft can be published.")
|
|
_validate_page(revision)
|
|
if revision.content_checksum != _page_checksum(revision):
|
|
raise ValidationError("Draft content changed outside the content service.")
|
|
revision.status = "published"
|
|
revision.published_by = actor
|
|
revision.published_at = timezone.now()
|
|
revision.save(update_fields=["status", "published_by", "published_at"])
|
|
if not EditorialPage.objects.filter(pk=page.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
|
raise PublishConflict("Published content changed. Reload before publishing.")
|
|
PublicationAudit.objects.create(actor=actor, page=page, from_revision=page.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first(), to_revision=revision.number, operation="publish")
|
|
return revision
|
|
|
|
|
|
@transaction.atomic
|
|
def publish_site(actor, draft: SiteRevision, *, expected_published_id):
|
|
require_permission(actor, "publish_content")
|
|
site = SiteIdentity.objects.select_for_update().get(pk=draft.site_id)
|
|
_check_expected(site, expected_published_id)
|
|
revision = SiteRevision.objects.get(pk=draft.pk, site=site)
|
|
if revision.status != "draft":
|
|
raise ValidationError("Only a draft can be published.")
|
|
_validate_site(revision)
|
|
if revision.content_checksum != _site_checksum(revision):
|
|
raise ValidationError("Draft content changed outside the content service.")
|
|
revision.status = "published"
|
|
revision.published_by = actor
|
|
revision.published_at = timezone.now()
|
|
revision.save(update_fields=["status", "published_by", "published_at"])
|
|
if not SiteIdentity.objects.filter(pk=site.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
|
raise PublishConflict("Published content changed. Reload before publishing.")
|
|
PublicationAudit.objects.create(actor=actor, site=site, from_revision=site.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first(), to_revision=revision.number, operation="publish")
|
|
return revision
|
|
|
|
|
|
@transaction.atomic
|
|
def restore_page(actor, page: EditorialPage, source: PageRevision, *, expected_published_id):
|
|
require_permission(actor, "restore_content")
|
|
page = EditorialPage.objects.select_for_update().get(pk=page.pk)
|
|
_check_expected(page, expected_published_id)
|
|
source = PageRevision.objects.get(pk=source.pk, page=page)
|
|
if source.status != "published":
|
|
raise ValidationError("Only a published revision can be restored.")
|
|
if source.content_checksum != _page_checksum(source):
|
|
raise ValidationError("Published content checksum does not match.")
|
|
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_copy_fields(source, PAGE_FIELDS))
|
|
revision.full_clean()
|
|
revision.save()
|
|
for original in source.sections.all():
|
|
section = PageSection(revision=revision, **_copy_fields(original, SECTION_FIELDS))
|
|
section.full_clean()
|
|
section.save()
|
|
_validate_page(revision)
|
|
revision.content_checksum = _page_checksum(revision)
|
|
revision.status = "published"
|
|
revision.published_by = actor
|
|
revision.published_at = timezone.now()
|
|
revision.save(update_fields=["content_checksum", "status", "published_by", "published_at"])
|
|
if not EditorialPage.objects.filter(pk=page.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
|
raise PublishConflict("Published content changed. Reload before restoring.")
|
|
previous = page.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first()
|
|
PublicationAudit.objects.create(actor=actor, page=page, from_revision=previous, to_revision=revision.number, operation="restore")
|
|
return revision
|
|
|
|
|
|
@transaction.atomic
|
|
def restore_site(actor, site: SiteIdentity, source: SiteRevision, *, expected_published_id):
|
|
require_permission(actor, "restore_content")
|
|
site = SiteIdentity.objects.select_for_update().get(pk=site.pk)
|
|
_check_expected(site, expected_published_id)
|
|
source = SiteRevision.objects.get(pk=source.pk, site=site)
|
|
if source.status != "published":
|
|
raise ValidationError("Only a published revision can be restored.")
|
|
if source.content_checksum != _site_checksum(source):
|
|
raise ValidationError("Published content checksum does not match.")
|
|
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_copy_fields(source, SITE_FIELDS))
|
|
_validate_site(revision)
|
|
revision.content_checksum = _site_checksum(revision)
|
|
revision.status = "published"
|
|
revision.published_by = actor
|
|
revision.published_at = timezone.now()
|
|
revision.save()
|
|
if not SiteIdentity.objects.filter(pk=site.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
|
raise PublishConflict("Published content changed. Reload before restoring.")
|
|
previous = site.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first()
|
|
PublicationAudit.objects.create(actor=actor, site=site, from_revision=previous, to_revision=revision.number, operation="restore")
|
|
return revision
|