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("/draft/", self.admin_site.admin_view(self.draft_view), name="content_editorialpage_draft"), path("/revisions//preview/", self.admin_site.admin_view(self.preview_view), name="content_editorialpage_preview"), path("/revisions//publish/", self.admin_site.admin_view(self.publish_view), name="content_editorialpage_publish"), path("/revisions//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("/draft/", self.admin_site.admin_view(self.draft_view), name="content_siteidentity_draft"), path("/revisions//preview/", self.admin_site.admin_view(self.preview_view), name="content_siteidentity_preview"), path("/revisions//publish/", self.admin_site.admin_view(self.publish_view), name="content_siteidentity_publish"), path("/revisions//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)