From 2f07158f14a9d797a884caf72e40f5f7352b9f7b Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 27 Sep 2026 10:20:47 +0330 Subject: [PATCH] Add structured content editing in Django admin --- apps/content/admin.py | 279 ++++++++++++++++++ apps/content/forms.py | 167 +++++++++++ .../templates/admin/content/bootstrap.html | 6 + .../templates/admin/content/dashboard.html | 53 ++++ .../templates/admin/content/draft.html | 65 ++++ .../templates/admin/content/preview.html | 28 ++ .../tests/integration/test_admin_workflow.py | 212 +++++++++++++ config/services/unfold.py | 5 + 8 files changed, 815 insertions(+) create mode 100644 apps/content/admin.py create mode 100644 apps/content/forms.py create mode 100644 apps/content/templates/admin/content/bootstrap.html create mode 100644 apps/content/templates/admin/content/dashboard.html create mode 100644 apps/content/templates/admin/content/draft.html create mode 100644 apps/content/templates/admin/content/preview.html create mode 100644 apps/content/tests/integration/test_admin_workflow.py diff --git a/apps/content/admin.py b/apps/content/admin.py new file mode 100644 index 0000000..1bfe8a9 --- /dev/null +++ b/apps/content/admin.py @@ -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("/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) diff --git a/apps/content/forms.py b/apps/content/forms.py new file mode 100644 index 0000000..fe36271 --- /dev/null +++ b/apps/content/forms.py @@ -0,0 +1,167 @@ +from django import forms + +from .models import ABOUT_KEYS, HOME_KEYS, MediaAsset + + +ROUTE_CHOICES = (("", "—"), ("about", "About"), ("events", "Events"), ("blog", "Blog")) +CONTACT_KINDS = ("email", "phone", "telegram", "instagram", "website") + + +class PageDraftForm(forms.Form): + title = forms.CharField(max_length=180) + intro = forms.CharField(max_length=600, required=False, widget=forms.Textarea(attrs={"rows": 3})) + seo_title = forms.CharField(max_length=120) + seo_description = forms.CharField(max_length=300, widget=forms.Textarea(attrs={"rows": 2})) + seo_image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False) + + def payload(self): + return {field: self.cleaned_data[field] for field in ("title", "intro", "seo_title", "seo_description", "seo_image")} + + +class SiteDraftForm(forms.Form): + official_name = forms.CharField(max_length=180) + short_name = forms.CharField(max_length=80) + faculty_subline = forms.CharField(max_length=140, required=False) + description = forms.CharField(max_length=500, required=False, widget=forms.Textarea(attrs={"rows": 3})) + logo = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False) + logo_alt = forms.CharField(max_length=180, required=False) + footer_copy = forms.CharField(max_length=300, required=False) + seo_title = forms.CharField(max_length=120, required=False) + seo_description = forms.CharField(max_length=300, required=False, widget=forms.Textarea(attrs={"rows": 2})) + seo_image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False) + + def __init__(self, *args, revision=None, **kwargs): + initial = kwargs.setdefault("initial", {}) + if revision: + for field in ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt", "footer_copy", "seo_title", "seo_description", "seo_image"): + initial[field] = getattr(revision, field) + super().__init__(*args, **kwargs) + navigation = {item["route_id"]: item for item in revision.navigation} if revision else {} + contacts = {item["kind"]: item for item in revision.contacts} if revision else {} + for position, route in enumerate(("home", "about", "events", "blog")): + item = navigation.get(route, {}) + self.fields[f"nav_{route}_label"] = forms.CharField(max_length=80, initial=item.get("label", route.title())) + self.fields[f"nav_{route}_position"] = forms.IntegerField(min_value=0, initial=item.get("position", position)) + self.fields[f"nav_{route}_visible"] = forms.BooleanField(required=False, initial=item.get("visible", True)) + for kind in CONTACT_KINDS: + item = contacts.get(kind, {}) + self.fields[f"contact_{kind}_label"] = forms.CharField(max_length=80, required=False, initial=item.get("label", "")) + self.fields[f"contact_{kind}_url"] = forms.CharField(max_length=500, required=False, initial=item.get("url", "")) + self.fields[f"contact_{kind}_position"] = forms.IntegerField(min_value=0, required=False, initial=item.get("position")) + self.fields[f"contact_{kind}_visible"] = forms.BooleanField(required=False, initial=item.get("visible", True)) + + def clean(self): + cleaned = super().clean() + for kind in CONTACT_KINDS: + label = cleaned.get(f"contact_{kind}_label") + url = cleaned.get(f"contact_{kind}_url") + if bool(label) != bool(url): + self.add_error(f"contact_{kind}_url", "Contact label and URL must be filled together.") + return cleaned + + def payload(self): + data = {field: self.cleaned_data[field] for field in ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt", "footer_copy", "seo_title", "seo_description", "seo_image")} + data["navigation"] = [ + {"route_id": route, "label": self.cleaned_data[f"nav_{route}_label"], "position": self.cleaned_data[f"nav_{route}_position"], "visible": self.cleaned_data[f"nav_{route}_visible"]} + for route in ("home", "about", "events", "blog") + ] + data["contacts"] = [] + for kind in CONTACT_KINDS: + url = self.cleaned_data[f"contact_{kind}_url"] + if url: + data["contacts"].append({ + "kind": kind, "label": self.cleaned_data[f"contact_{kind}_label"], "url": url, + "position": self.cleaned_data[f"contact_{kind}_position"] if self.cleaned_data[f"contact_{kind}_position"] is not None else len(data["contacts"]), + "visible": self.cleaned_data[f"contact_{kind}_visible"], + }) + return data + + +class EntryForm(forms.Form): + title = forms.CharField(max_length=120, required=False) + text = forms.CharField(max_length=600, required=False, widget=forms.Textarea(attrs={"rows": 2})) + + def clean(self): + cleaned = super().clean() + if bool(cleaned.get("title")) != bool(cleaned.get("text")): + raise forms.ValidationError("Each entry needs both a title and text.") + return cleaned + + +EntryFormSet = forms.formset_factory(EntryForm, extra=1, max_num=20, validate_max=True, can_delete=True) + + +class SectionForm(forms.Form): + heading = forms.CharField(max_length=120, required=False) + body = forms.CharField(max_length=1200, required=False, widget=forms.Textarea(attrs={"rows": 3})) + intro = forms.CharField(max_length=300, required=False, widget=forms.Textarea(attrs={"rows": 2})) + all_link_label = forms.CharField(max_length=40, required=False) + position = forms.IntegerField(min_value=0, required=False) + visible = forms.BooleanField(required=False) + image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False) + image_alt = forms.CharField(max_length=180, required=False) + primary_label = forms.CharField(max_length=40, required=False) + primary_route = forms.ChoiceField(choices=ROUTE_CHOICES, required=False) + secondary_label = forms.CharField(max_length=40, required=False) + secondary_route = forms.ChoiceField(choices=ROUTE_CHOICES, required=False) + + def __init__(self, *args, key, slug, section=None, **kwargs): + initial = kwargs.setdefault("initial", {}) + if section: + for field in ("heading", "body", "intro", "all_link_label", "position", "visible", "image", "image_alt"): + initial[field] = getattr(section, field) + for side in ("primary", "secondary"): + action = getattr(section, f"{side}_action") or {} + initial[f"{side}_label"] = action.get("label", "") + initial[f"{side}_route"] = action.get("route_id", "") + else: + initial["visible"] = True + if slug == "home": + initial["position"] = HOME_KEYS.index(key) + elif key == "identity": + initial["position"] = 0 + elif slug == "about": + initial["position"] = ABOUT_KEYS.index(key) + super().__init__(*args, **kwargs) + self.key = key + self.slug = slug + self.title = key.replace("_", " ").title() + if key in {"hero", "identity", "contact"} or slug == "home": + self.fields["heading"].required = True + if slug == "home" or key in {"identity", "contact"}: + self.fields["position"].disabled = True + if key in {"hero", "identity", "contact"} or slug == "home": + self.fields["visible"].disabled = True + allowed = {"heading", "position", "visible"} + if key in {"hero", "participation"}: + allowed |= {"body", "image", "image_alt", "primary_label", "primary_route", "secondary_label", "secondary_route"} + self.fields["body"].max_length = 600 if key == "participation" else 500 + elif key in {"featured_events", "featured_posts"}: + allowed |= {"intro", "all_link_label"} + elif key != "contact": + allowed.add("body") + self.fields["body"].max_length = 600 if key == "identity" else 1200 + for field in tuple(self.fields): + if field not in allowed: + del self.fields[field] + + def clean(self): + cleaned = super().clean() + for side in ("primary", "secondary"): + if bool(cleaned.get(f"{side}_label")) != bool(cleaned.get(f"{side}_route")): + self.add_error(f"{side}_label", "Action label and route must be filled together.") + return cleaned + + def payload(self, entries=(), *, contact_position=None): + data = self.cleaned_data + position = contact_position if contact_position is not None else data["position"] + section = { + "key": self.key, "section_type": self.key, "position": position, + "visible": True if self.slug == "home" or self.key in {"identity", "contact"} else data["visible"], + "heading": data["heading"], "body": data.get("body", ""), "intro": data.get("intro", ""), + "all_link_label": data.get("all_link_label", ""), "image": data.get("image"), "image_alt": data.get("image_alt", ""), + "entries": list(entries), + } + for side in ("primary", "secondary"): + section[f"{side}_action"] = {"label": data[f"{side}_label"], "route_id": data[f"{side}_route"]} if data.get(f"{side}_label") else None + return section diff --git a/apps/content/templates/admin/content/bootstrap.html b/apps/content/templates/admin/content/bootstrap.html new file mode 100644 index 0000000..c7e510c --- /dev/null +++ b/apps/content/templates/admin/content/bootstrap.html @@ -0,0 +1,6 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

{{ title }}

+

Create the single site record. It will have no published content until a publisher approves a draft.

+
{% csrf_token %}
+{% endblock %} diff --git a/apps/content/templates/admin/content/dashboard.html b/apps/content/templates/admin/content/dashboard.html new file mode 100644 index 0000000..0ea917b --- /dev/null +++ b/apps/content/templates/admin/content/dashboard.html @@ -0,0 +1,53 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
+ +

{{ title }}

+

Published revision: {% if target.published_revision %}{{ target.published_revision.number }}{% else %}None{% endif %}

+ {% if can_edit %} + {% if is_page %}Edit a new draft + {% else %}Edit a new draft{% endif %} + {% endif %} +
+

Draft revisions

+ {% for revision in drafts %} +
+ Revision {{ revision.number }} + Created {{ revision.created_at }} by {{ revision.created_by|default:"system" }} + {% if is_page %}Preview draft + {% else %}Preview draft{% endif %} + {% if can_publish %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% empty %}

No drafts yet.

{% endfor %} +
+
+

Published history

+ {% for revision in history %} +
+ Revision {{ revision.number }} + Published {{ revision.published_at }} by {{ revision.published_by|default:"system" }} + {% if can_restore and revision.pk != target.published_revision_id %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% empty %}

No published revision yet.

{% endfor %} +
+
+{% endblock %} diff --git a/apps/content/templates/admin/content/draft.html b/apps/content/templates/admin/content/draft.html new file mode 100644 index 0000000..da2aa0a --- /dev/null +++ b/apps/content/templates/admin/content/draft.html @@ -0,0 +1,65 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
+ +

{{ title }}

+

Saving creates a new draft revision. The published content stays at revision {% if target.published_revision %}{{ target.published_revision.number }}{% else %}none{% endif %}.

+
+ {% csrf_token %} + + {{ form.non_field_errors }} +
+ {% if is_page %}Page text and SEO{% else %}Site identity, navigation, and contacts{% endif %} + {% for field in form %}
{{ field.label_tag }}{{ field }}{{ field.errors }}
{% endfor %} +
+ {% if is_page %} + {% for key, section_form, entry_set in sections %} +
+ {{ section_form.title }} + {% if key == 'featured_events' or key == 'featured_posts' %}

Cards are loaded from the existing event or blog API.

{% endif %} + {% if key == 'contact' %}

Contact details come from the site identity.

{% endif %} + {{ section_form.non_field_errors }} + {% for field in section_form %}
{{ field.label_tag }}{{ field }}{{ field.errors }}
{% endfor %} + {% if entry_set %} +

Entries

+ {{ entry_set.management_form }}{{ entry_set.non_form_errors }} +
+ {% for entry_form in entry_set %}
{{ entry_form.non_field_errors }}{% for field in entry_form %}
{{ field.label_tag }}{{ field }}{{ field.errors }}
{% endfor %}
{% endfor %} +
+ + + {% endif %} +
+ {% endfor %} + {% endif %} + +
+ +
+{% endblock %} diff --git a/apps/content/templates/admin/content/preview.html b/apps/content/templates/admin/content/preview.html new file mode 100644 index 0000000..4959287 --- /dev/null +++ b/apps/content/templates/admin/content/preview.html @@ -0,0 +1,28 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
+

{{ title }}

+

Draft preview only. Revision {{ revision.number }} is not public.

+ {% if is_page %} +

{{ revision.title }}

{{ revision.intro }}

+

SEO: {{ revision.seo_title }} — {{ revision.seo_description }}

+ {% for section in sections %} +
+

{{ section.heading }} {% if not section.visible %}(hidden in public view){% endif %}

+

{{ section.body|linebreaksbr }}

{{ section.intro }}

+ {% if section.image %}

Image: {{ section.image }} — {{ section.image_alt }}

{% endif %} + {% if section.all_link_label %}

Listing link: {{ section.all_link_label }}

{% endif %} + {% for entry in section.entries %}

{{ entry.title }}

{{ entry.text|linebreaksbr }}

{% endfor %} +
+ {% endfor %} + {% else %} +

{{ revision.official_name }}

+

{{ revision.short_name }} — {{ revision.faculty_subline }}

+

{{ revision.description|linebreaksbr }}

+ {% if revision.logo %}

Logo: {{ revision.logo }} — {{ revision.logo_alt }}

{% endif %} +

Navigation

    {% for item in revision.navigation %}
  • {{ item.label }} → {{ item.route_id }} {% if not item.visible %}(hidden){% endif %}
  • {% endfor %}
+

Contacts

    {% for item in revision.contacts %}
  • {{ item.label }}: {{ item.url }} {% if not item.visible %}(hidden){% endif %}
  • {% endfor %}
+

{{ revision.footer_copy }}

+ {% endif %} +
+{% endblock %} diff --git a/apps/content/tests/integration/test_admin_workflow.py b/apps/content/tests/integration/test_admin_workflow.py new file mode 100644 index 0000000..263c400 --- /dev/null +++ b/apps/content/tests/integration/test_admin_workflow.py @@ -0,0 +1,212 @@ +import io + +import pytest +from django.contrib.auth.models import Group +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from PIL import Image + +from apps.content.models import EditorialPage, MediaAsset, PageRevision, SiteIdentity +from apps.users.models import User + + +pytestmark = [pytest.mark.django_db, pytest.mark.integration] + + +def staff(name, group): + user = User.objects.create_user(username=name, mobile=f"0912{User.objects.count():07d}", password="TestPass123!") + user.is_staff = True + user.save(update_fields=["is_staff"]) + user.groups.add(Group.objects.get(name=group)) + return user + + +def home_post(): + data = {"title": "Home", "intro": "Welcome", "seo_title": "Home", "seo_description": "Association"} + for position, key in enumerate(("hero", "featured_events", "featured_posts", "participation")): + data[f"section_{key}-heading"] = key.replace("_", " ").title() + if key in {"featured_events", "featured_posts"}: + data[f"section_{key}-all_link_label"] = "See all" + data[f"section_{key}-body"] = "Introduction" if key in {"hero", "participation"} else "" + return data + + +def test_editor_can_save_and_preview_draft_without_publishing(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + editor = staff("editor", "content_editor") + client.force_login(editor) + draft_url = reverse("admin:content_editorialpage_draft", args=[page.pk]) + response = client.get(draft_url) + assert response.status_code == 200 + assert b"section_hero-heading" in response.content + assert b"primary_action" not in response.content + response = client.post(draft_url, home_post(), follow=True) + assert response.status_code == 200 + revision = PageRevision.objects.get(page=page) + assert revision.status == "draft" + assert page.published_revision_id is None + preview_url = reverse("admin:content_editorialpage_preview", args=[page.pk, revision.pk]) + response = client.get(preview_url) + assert response.status_code == 200 + assert b"Home" in response.content + assert "private" in response["Cache-Control"] and "no-store" in response["Cache-Control"] + publish_url = reverse("admin:content_editorialpage_publish", args=[page.pk, revision.pk]) + assert client.post(publish_url, {"expected_published_id": ""}).status_code == 403 + + +def test_publisher_can_publish_after_preview(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + editor = staff("editor", "content_editor") + publisher = staff("publisher", "content_publisher") + client.force_login(editor) + client.post(reverse("admin:content_editorialpage_draft", args=[page.pk]), home_post()) + revision = PageRevision.objects.get(page=page) + client.force_login(publisher) + response = client.post(reverse("admin:content_editorialpage_publish", args=[page.pk, revision.pk]), {"expected_published_id": ""}, follow=True) + assert response.status_code == 200 + page.refresh_from_db() + assert page.published_revision_id == revision.pk + assert b"Published revision" in response.content + + +def test_site_admin_uses_named_contact_fields_and_rejects_bad_url(client): + site = SiteIdentity.objects.create() + editor = staff("editor", "content_editor") + client.force_login(editor) + url = reverse("admin:content_siteidentity_draft", args=[site.pk]) + response = client.get(url) + assert response.status_code == 200 + assert b"contact_email_url" in response.content + assert b'name="navigation"' not in response.content + data = {"official_name": "Association", "short_name": "ACE", "contact_email_url": "javascript:alert(1)", "contact_email_label": "Email"} + for position, route in enumerate(("home", "about", "events", "blog")): + data[f"nav_{route}_label"] = route + data[f"nav_{route}_position"] = position + data[f"nav_{route}_visible"] = "on" + response = client.post(url, data) + assert response.status_code == 200 + assert b"contact_email_url" in response.content + assert site.revisions.count() == 0 + + +def test_about_optional_section_can_be_hidden_and_reordered(client): + page = EditorialPage.objects.create(slug="about", page_kind="sections") + client.force_login(staff("editor", "content_editor")) + data = {"title": "About", "intro": "Introduction", "seo_title": "About", "seo_description": "Association"} + for key, heading in (("identity", "Identity"), ("mission", "Mission"), ("contact", "Contact")): + data[f"section_{key}-heading"] = heading + data["section_identity-body"] = "Official story" + data["section_mission-position"] = "2" + data["section_mission-body"] = "Our mission" + for key in ("mission", "activities", "values", "committees", "affiliations", "faq"): + data[f"entries_{key}-TOTAL_FORMS"] = "1" + data[f"entries_{key}-INITIAL_FORMS"] = "0" + data[f"entries_{key}-MIN_NUM_FORMS"] = "0" + data[f"entries_{key}-MAX_NUM_FORMS"] = "20" + response = client.post(reverse("admin:content_editorialpage_draft", args=[page.pk]), data, follow=True) + assert response.status_code == 200 + revision = PageRevision.objects.get(page=page) + assert list(revision.sections.values_list("key", "position", "visible")) == [ + ("identity", 0, True), ("mission", 2, False), ("contact", 3, True), + ] + + +def test_site_editor_can_save_named_links_and_preview(client): + site = SiteIdentity.objects.create() + client.force_login(staff("editor", "content_editor")) + data = {"official_name": "Association", "short_name": "ACE", "contact_email_label": "Email", "contact_email_url": "mailto:test@example.com"} + for position, route in enumerate(("home", "about", "events", "blog")): + data[f"nav_{route}_label"] = route + data[f"nav_{route}_position"] = str(position) + data[f"nav_{route}_visible"] = "on" + response = client.post(reverse("admin:content_siteidentity_draft", args=[site.pk]), data, follow=True) + assert response.status_code == 200 + revision = site.revisions.get() + assert revision.contacts == [{"kind": "email", "label": "Email", "url": "mailto:test@example.com", "position": 0, "visible": False}] + assert site.published_revision_id is None + preview = client.get(reverse("admin:content_siteidentity_preview", args=[site.pk, revision.pk])) + assert preview.status_code == 200 and b"test@example.com" in preview.content + + +def test_anonymous_cannot_preview_draft(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + client.force_login(staff("editor", "content_editor")) + client.post(reverse("admin:content_editorialpage_draft", args=[page.pk]), home_post()) + revision = page.revisions.get() + client.logout() + response = client.get(reverse("admin:content_editorialpage_preview", args=[page.pk, revision.pk])) + assert response.status_code == 302 and "/admin/login/" in response["Location"] + + +def test_staff_without_content_permission_cannot_preview_or_publish(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + client.force_login(staff("editor", "content_editor")) + client.post(reverse("admin:content_editorialpage_draft", args=[page.pk]), home_post()) + revision = page.revisions.get() + plain_staff = User.objects.create_user(username="plain", mobile="09121111111", password="TestPass123!", is_staff=True) + client.force_login(plain_staff) + assert client.get(reverse("admin:content_editorialpage_preview", args=[page.pk, revision.pk])).status_code == 403 + assert client.post(reverse("admin:content_editorialpage_publish", args=[page.pk, revision.pk]), {"expected_published_id": ""}).status_code == 403 + + +def test_stale_admin_draft_post_reports_conflict(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + client.force_login(staff("editor", "content_editor")) + url = reverse("admin:content_editorialpage_draft", args=[page.pk]) + assert client.get(url).status_code == 200 + client.post(url, home_post()) + response = client.post(url, home_post()) + assert response.status_code == 200 + assert b"newer revision" in response.content + assert page.revisions.count() == 1 + + +def test_media_approval_is_publisher_only_and_locks_asset(client, settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + buffer = io.BytesIO() + Image.new("RGB", (20, 20)).save(buffer, format="PNG") + client.force_login(staff("editor", "content_editor")) + upload = SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png") + response = client.post(reverse("admin:content_mediaasset_add"), { + "file": upload, "alt": "Association logo", "source": "Owner", "license": "Owned", "approved": "on", "_save": "Save", + }) + assert response.status_code == 302 + asset = MediaAsset.objects.get() + assert not asset.approved + client.force_login(staff("publisher", "content_publisher")) + change_url = reverse("admin:content_mediaasset_change", args=[asset.pk]) + response = client.post(change_url, { + "alt": asset.alt, "source": asset.source, "license": asset.license, "approved": "on", "_save": "Save", + }) + assert response.status_code == 302 + asset.refresh_from_db() + assert asset.approved + assert client.get(change_url).status_code == 200 + assert client.post(change_url, {"approved": ""}).status_code == 403 + + +def test_listing_intro_page_saves_without_sections(client): + page = EditorialPage.objects.create(slug="events", page_kind="listing_intro") + client.force_login(staff("editor", "content_editor")) + response = client.post(reverse("admin:content_editorialpage_draft", args=[page.pk]), { + "title": "Events", "intro": "Find upcoming events", "seo_title": "Events", "seo_description": "Association events", + }, follow=True) + assert response.status_code == 200 + revision = page.revisions.get() + assert revision.intro == "Find upcoming events" and revision.sections.count() == 0 + + +def test_admin_restore_creates_new_published_revision(client): + page = EditorialPage.objects.create(slug="home", page_kind="sections") + client.force_login(staff("publisher", "content_publisher")) + draft_url = reverse("admin:content_editorialpage_draft", args=[page.pk]) + client.post(draft_url, home_post() | {"title": "First"}) + first = page.revisions.get() + client.post(reverse("admin:content_editorialpage_publish", args=[page.pk, first.pk]), {"expected_published_id": ""}) + client.post(draft_url, home_post() | {"title": "Second", "base_revision_id": str(first.pk)}) + second = page.revisions.order_by("-number").first() + client.post(reverse("admin:content_editorialpage_publish", args=[page.pk, second.pk]), {"expected_published_id": str(first.pk)}) + response = client.post(reverse("admin:content_editorialpage_restore", args=[page.pk, first.pk]), {"expected_published_id": str(second.pk)}, follow=True) + assert response.status_code == 200 + page.refresh_from_db() + assert page.published_revision.number == 3 and page.published_revision.title == "First" diff --git a/config/services/unfold.py b/config/services/unfold.py index 1024445..7d73dc1 100644 --- a/config/services/unfold.py +++ b/config/services/unfold.py @@ -84,6 +84,11 @@ UNFOLD = { "icon": "call", "link": lambda request: request.build_absolute_uri("/admin/communications/"), }, + { + "title": "Public content", + "icon": "article", + "link": lambda request: request.build_absolute_uri("/admin/content/editorialpage/"), + }, ], }, ],