Replace content publishing workflow with direct Unfold editing

This commit is contained in:
2026-09-27 11:09:25 +03:30
parent 148b7038a9
commit f6b4ef7dd8
14 changed files with 680 additions and 1232 deletions

View File

@@ -1,279 +1,78 @@
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import HttpResponseBadRequest, HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.contrib import admin
from unfold.admin import ModelAdmin, TabularInline
from .forms import ABOUT_KEYS, CONTACT_KINDS, EntryFormSet, PageDraftForm, SectionForm, SiteDraftForm
from .models import EditorialPage, MediaAsset, PageRevision, SiteIdentity, SiteRevision
from .services import PublishConflict, publish_page, publish_site, require_permission, restore_page, restore_site, save_page_draft, save_site_draft
from .current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent
def _expected_id(request):
value = request.POST.get("expected_published_id", "")
if not value:
return None
try:
return int(value)
except ValueError:
raise ValidationError("Invalid published revision token.")
class FixedContentAdmin(ModelAdmin):
"""The page records are installed by migration; editors only change them."""
class ContentAdmin(admin.ModelAdmin):
list_per_page = 20
def has_view_permission(self, request, obj=None):
user = request.user
return bool(user.is_active and user.is_staff and (user.is_superuser or any(user.has_perm(f"content.{codename}") for codename in ("change_content_draft", "preview_content_draft", "publish_content"))))
def has_change_permission(self, request, obj=None):
user = request.user
return bool(user.is_active and user.is_staff and (user.is_superuser or user.has_perm("content.change_content_draft")))
def has_add_permission(self, request):
return self.has_change_permission(request)
return False
def has_delete_permission(self, request, obj=None):
return False
def get_model_perms(self, request):
return {"view": self.has_view_permission(request), "add": self.has_add_permission(request)}
def _render(self, request, template, **context):
return TemplateResponse(request, template, {**self.admin_site.each_context(request), "opts": self.model._meta, **context})
@admin.register(SiteContent)
class SiteContentAdmin(FixedContentAdmin):
fieldsets = (
("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}),
("Navigation labels", {"fields": ("nav_home", "nav_about", "nav_events", "nav_blog")}),
("Contact and footer", {"fields": ("email", "phone", "telegram_url", "instagram_url", "website_url", "address", "footer_copy")}),
("SEO defaults", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@admin.register(EditorialPage)
class EditorialPageAdmin(ContentAdmin):
list_display = ("slug", "page_kind", "published_number")
fields = ("slug", "page_kind")
@admin.display(description="Published revision")
def published_number(self, obj):
return obj.published_revision.number if obj.published_revision_id else "None"
def save_model(self, request, obj, form, change):
obj.full_clean()
super().save_model(request, obj, form, change)
def get_urls(self):
urls = [
path("<int:object_id>/draft/", self.admin_site.admin_view(self.draft_view), name="content_editorialpage_draft"),
path("<int:object_id>/revisions/<int:revision_id>/preview/", self.admin_site.admin_view(self.preview_view), name="content_editorialpage_preview"),
path("<int:object_id>/revisions/<int:revision_id>/publish/", self.admin_site.admin_view(self.publish_view), name="content_editorialpage_publish"),
path("<int:object_id>/revisions/<int:revision_id>/restore/", self.admin_site.admin_view(self.restore_view), name="content_editorialpage_restore"),
]
return urls + super().get_urls()
def change_view(self, request, object_id, form_url="", extra_context=None):
if request.method != "GET":
return HttpResponseNotAllowed(["GET"])
if not self.has_view_permission(request):
raise PermissionDenied
page = get_object_or_404(EditorialPage, pk=object_id)
return self._render(request, "admin/content/dashboard.html", title=f"{page.slug} content", target=page, is_page=True,
drafts=page.revisions.filter(status="draft").order_by("-number")[:10],
history=page.revisions.filter(status="published").order_by("-number")[:10],
can_edit=self.has_change_permission(request),
can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"),
can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content"))
def draft_view(self, request, object_id):
require_permission(request.user, "change_content_draft")
page = get_object_or_404(EditorialPage, pk=object_id)
base = page.revisions.order_by("-number").first()
stale = request.method == "POST" and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "")
keys = ("hero", "featured_events", "featured_posts", "participation") if page.slug == "home" else ABOUT_KEYS if page.slug == "about" else ()
existing = {section.key: section for section in base.sections.all()} if base else {}
bound = request.method == "POST"
form = PageDraftForm(request.POST if bound else None, initial={field: getattr(base, field) for field in ("title", "intro", "seo_title", "seo_description", "seo_image")} if base else None)
sections = []
valid = form.is_valid() if bound else False
for key in keys:
section = existing.get(key)
section_form = SectionForm(request.POST if bound else None, key=key, slug=page.slug, section=section, prefix=f"section_{key}")
entry_set = None
if page.slug == "about" and key not in {"identity", "contact"}:
entry_set = EntryFormSet(request.POST if bound else None, prefix=f"entries_{key}", initial=section.entries if section else None)
if bound:
valid = section_form.is_valid() and valid
if entry_set:
valid = entry_set.is_valid() and valid
sections.append((key, section_form, entry_set))
if bound and stale:
form.add_error(None, "A newer revision exists. Reload the draft before editing.")
if bound and valid and not stale:
payload = []
try:
for key, section_form, entry_set in sections:
entries = [
{"title": item["title"], "text": item["text"]}
for item in (entry_set.cleaned_data if entry_set else [])
if item and not item.get("DELETE") and item.get("title")
]
if page.slug == "about" and key not in {"identity", "contact"} and not section_form.cleaned_data["heading"] and not entries:
continue
payload.append(section_form.payload(entries))
if page.slug == "about":
contact = next(item for item in payload if item["key"] == "contact")
contact["position"] = max((item["position"] for item in payload if item["key"] != "contact"), default=0) + 1
revision = save_page_draft(request.user, page, form.payload(), payload, base_revision=base)
except (ValidationError, PublishConflict) as exc:
form.add_error(None, str(exc))
else:
messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.")
return redirect("admin:content_editorialpage_change", page.pk)
return self._render(request, "admin/content/draft.html", title=f"Edit {page.slug} draft", target=page,
is_page=True, form=form, sections=sections, base=base)
def preview_view(self, request, object_id, revision_id):
require_permission(request.user, "preview_content_draft")
revision = get_object_or_404(PageRevision.objects.prefetch_related("sections"), pk=revision_id, page_id=object_id, status="draft")
response = self._render(request, "admin/content/preview.html", title=f"Draft {revision.number} preview", revision=revision,
is_page=True, sections=revision.sections.order_by("position"))
response["Cache-Control"] = "private, no-store"
response["X-Robots-Tag"] = "noindex, nofollow"
return response
def publish_view(self, request, object_id, revision_id):
require_permission(request.user, "publish_content")
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
revision = get_object_or_404(PageRevision, pk=revision_id, page_id=object_id)
try:
published = publish_page(request.user, revision, expected_published_id=_expected_id(request))
except (ValidationError, PublishConflict) as exc:
return HttpResponseBadRequest(str(exc))
messages.success(request, f"Published revision {published.number}.")
return redirect("admin:content_editorialpage_change", object_id)
def restore_view(self, request, object_id, revision_id):
require_permission(request.user, "restore_content")
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
page = get_object_or_404(EditorialPage, pk=object_id)
source = get_object_or_404(PageRevision, pk=revision_id, page=page)
try:
restored = restore_page(request.user, page, source, expected_published_id=_expected_id(request))
except (ValidationError, PublishConflict) as exc:
return HttpResponseBadRequest(str(exc))
messages.success(request, f"Restored as revision {restored.number}.")
return redirect("admin:content_editorialpage_change", object_id)
@admin.register(HomeContent)
class HomeContentAdmin(FixedContentAdmin):
fieldsets = (
("Page introduction", {"fields": ("title", "intro")}),
("Hero", {"fields": ("hero_heading", "hero_body", "hero_image", "hero_image_alt", "hero_action_label")}),
("Featured events", {"fields": ("featured_events_heading", "featured_events_intro", "featured_events_link_label")}),
("Featured blog posts", {"fields": ("featured_posts_heading", "featured_posts_intro", "featured_posts_link_label")}),
("Participation", {"fields": ("participation_heading", "participation_body", "participation_image", "participation_image_alt", "participation_action_label")}),
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@admin.register(SiteIdentity)
class SiteIdentityAdmin(ContentAdmin):
list_display = ("__str__", "published_number")
class AboutItemInline(TabularInline):
model = AboutItem
extra = 0
fields = ("kind", "title", "text", "position")
@admin.display(description="Published revision")
def published_number(self, obj):
return obj.published_revision.number if obj.published_revision_id else "None"
def has_add_permission(self, request):
return super().has_add_permission(request) and not SiteIdentity.objects.exists()
def add_view(self, request, form_url="", extra_context=None):
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == "POST":
site, _ = SiteIdentity.objects.get_or_create(pk=1)
return redirect("admin:content_siteidentity_change", site.pk)
return self._render(request, "admin/content/bootstrap.html", title="Create site identity")
def get_urls(self):
urls = [
path("<int:object_id>/draft/", self.admin_site.admin_view(self.draft_view), name="content_siteidentity_draft"),
path("<int:object_id>/revisions/<int:revision_id>/preview/", self.admin_site.admin_view(self.preview_view), name="content_siteidentity_preview"),
path("<int:object_id>/revisions/<int:revision_id>/publish/", self.admin_site.admin_view(self.publish_view), name="content_siteidentity_publish"),
path("<int:object_id>/revisions/<int:revision_id>/restore/", self.admin_site.admin_view(self.restore_view), name="content_siteidentity_restore"),
]
return urls + super().get_urls()
def change_view(self, request, object_id, form_url="", extra_context=None):
if request.method != "GET":
return HttpResponseNotAllowed(["GET"])
if not self.has_view_permission(request):
raise PermissionDenied
site = get_object_or_404(SiteIdentity, pk=object_id)
return self._render(request, "admin/content/dashboard.html", title="Site identity content", target=site, is_page=False,
drafts=site.revisions.filter(status="draft").order_by("-number")[:10],
history=site.revisions.filter(status="published").order_by("-number")[:10],
can_edit=self.has_change_permission(request),
can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"),
can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content"))
def draft_view(self, request, object_id):
require_permission(request.user, "change_content_draft")
site = get_object_or_404(SiteIdentity, pk=object_id)
base = site.revisions.order_by("-number").first()
bound = request.method == "POST"
stale = bound and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "")
form = SiteDraftForm(request.POST if bound else None, revision=base)
if bound and stale:
form.add_error(None, "A newer revision exists. Reload the draft before editing.")
if bound and form.is_valid() and not stale:
try:
revision = save_site_draft(request.user, site, form.payload(), base_revision=base)
except (ValidationError, PublishConflict) as exc:
form.add_error(None, str(exc))
else:
messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.")
return redirect("admin:content_siteidentity_change", site.pk)
return self._render(request, "admin/content/draft.html", title="Edit site identity draft", target=site,
is_page=False, form=form, base=base, contact_kinds=CONTACT_KINDS)
def preview_view(self, request, object_id, revision_id):
require_permission(request.user, "preview_content_draft")
revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id, status="draft")
response = self._render(request, "admin/content/preview.html", title=f"Site draft {revision.number} preview", revision=revision, is_page=False)
response["Cache-Control"] = "private, no-store"
response["X-Robots-Tag"] = "noindex, nofollow"
return response
def publish_view(self, request, object_id, revision_id):
require_permission(request.user, "publish_content")
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id)
try:
published = publish_site(request.user, revision, expected_published_id=_expected_id(request))
except (ValidationError, PublishConflict) as exc:
return HttpResponseBadRequest(str(exc))
messages.success(request, f"Published revision {published.number}.")
return redirect("admin:content_siteidentity_change", object_id)
def restore_view(self, request, object_id, revision_id):
require_permission(request.user, "restore_content")
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
site = get_object_or_404(SiteIdentity, pk=object_id)
source = get_object_or_404(SiteRevision, pk=revision_id, site=site)
try:
restored = restore_site(request.user, site, source, expected_published_id=_expected_id(request))
except (ValidationError, PublishConflict) as exc:
return HttpResponseBadRequest(str(exc))
messages.success(request, f"Restored as revision {restored.number}.")
return redirect("admin:content_siteidentity_change", object_id)
@admin.register(MediaAsset)
class MediaAssetAdmin(ContentAdmin):
list_display = ("__str__", "width", "height", "bytes", "approved", "created_at")
fields = ("file", "alt", "source", "license", "approved")
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
if obj is not None and obj.approved:
return False
return super().has_change_permission(request, obj)
return request.user.has_perm("content.change_aboutcontent")
def get_readonly_fields(self, request, obj=None):
if not (request.user.is_superuser or request.user.has_perm("content.publish_content")):
return ("approved",)
return ()
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def save_model(self, request, obj, form, change):
if not (request.user.is_superuser or request.user.has_perm("content.publish_content")):
obj.approved = False
obj.full_clean()
super().save_model(request, obj, form, change)
@admin.register(AboutContent)
class AboutContentAdmin(FixedContentAdmin):
inlines = (AboutItemInline,)
fieldsets = (
("Page introduction", {"fields": ("title", "intro", "identity_heading", "identity_body")}),
("Mission", {"fields": ("mission_heading", "mission_body")}),
("Activities", {"fields": ("activities_heading", "activities_body")}),
("Values", {"fields": ("values_heading", "values_body")}),
("Committees", {"fields": ("committees_heading", "committees_body")}),
("Affiliations", {"fields": ("affiliations_heading", "affiliations_body")}),
("FAQ and contact", {"fields": ("faq_heading", "contact_heading")}),
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@admin.register(ListingContent)
class ListingContentAdmin(FixedContentAdmin):
list_display = ("slug", "title")
readonly_fields = ("slug",)
fieldsets = (
("Content", {"fields": ("slug", "title", "intro")}),
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)