Add structured content editing in Django admin
This commit is contained in:
279
apps/content/admin.py
Normal file
279
apps/content/admin.py
Normal file
@@ -0,0 +1,279 @@
|
||||
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 .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
|
||||
|
||||
|
||||
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 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)
|
||||
|
||||
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(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(SiteIdentity)
|
||||
class SiteIdentityAdmin(ContentAdmin):
|
||||
list_display = ("__str__", "published_number")
|
||||
|
||||
@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_change_permission(self, request, obj=None):
|
||||
if obj is not None and obj.approved:
|
||||
return False
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
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 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)
|
||||
Reference in New Issue
Block a user