Add guarded content draft and publication services

This commit is contained in:
2026-09-27 09:49:30 +03:30
parent 3bc9de2367
commit b8e79c950a
5 changed files with 517 additions and 0 deletions

241
apps/content/services.py Normal file
View File

@@ -0,0 +1,241 @@
"""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 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.")
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