Add structured content editing in Django admin

This commit is contained in:
2026-09-27 10:20:47 +03:30
parent f529f85bf0
commit 2f07158f14
8 changed files with 815 additions and 0 deletions

279
apps/content/admin.py Normal file
View 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)

167
apps/content/forms.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,6 @@
{% extends "admin/base_site.html" %}
{% block content %}
<h1>{{ title }}</h1>
<p>Create the single site record. It will have no published content until a publisher approves a draft.</p>
<form method="post">{% csrf_token %}<button type="submit">Create site record</button></form>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "admin/base_site.html" %}
{% block content %}
<main class="cms-workflow">
<style>
.cms-workflow { max-width: 72rem; margin-inline: auto; padding: 1rem; }
.cms-workflow h1 { font-size: 1.5rem; margin-block: .5rem 1rem; }
.cms-workflow .panel { border: 1px solid #b9c7c9; border-radius: .6rem; padding: 1rem; margin-block: 1rem; }
.cms-workflow .row { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center; padding-block: .5rem; border-top: 1px solid #dce3e4; }
.cms-workflow button, .cms-workflow .button { min-height: 2.5rem; padding: .5rem .8rem; }
.cms-workflow button { border: 1px solid #076e73; border-radius: .35rem; background: #076e73; color: #fff; font-weight: 700; cursor: pointer; }
.cms-workflow button:hover { background: #055b60; }
.cms-workflow .muted { color: #4b5f63; }
</style>
<h1>{{ title }}</h1>
<p>Published revision: <strong>{% if target.published_revision %}{{ target.published_revision.number }}{% else %}None{% endif %}</strong></p>
{% if can_edit %}
{% if is_page %}<a class="button" href="{% url 'admin:content_editorialpage_draft' target.pk %}">Edit a new draft</a>
{% else %}<a class="button" href="{% url 'admin:content_siteidentity_draft' target.pk %}">Edit a new draft</a>{% endif %}
{% endif %}
<section class="panel" aria-labelledby="drafts-heading">
<h2 id="drafts-heading">Draft revisions</h2>
{% for revision in drafts %}
<div class="row">
<strong>Revision {{ revision.number }}</strong>
<span class="muted">Created {{ revision.created_at }} by {{ revision.created_by|default:"system" }}</span>
{% if is_page %}<a href="{% url 'admin:content_editorialpage_preview' target.pk revision.pk %}">Preview draft</a>
{% else %}<a href="{% url 'admin:content_siteidentity_preview' target.pk revision.pk %}">Preview draft</a>{% endif %}
{% if can_publish %}
<form method="post" action="{% if is_page %}{% url 'admin:content_editorialpage_publish' target.pk revision.pk %}{% else %}{% url 'admin:content_siteidentity_publish' target.pk revision.pk %}{% endif %}">
{% csrf_token %}<input type="hidden" name="expected_published_id" value="{{ target.published_revision_id|default_if_none:'' }}">
<button type="submit">Publish this revision</button>
</form>
{% endif %}
</div>
{% empty %}<p>No drafts yet.</p>{% endfor %}
</section>
<section class="panel" aria-labelledby="history-heading">
<h2 id="history-heading">Published history</h2>
{% for revision in history %}
<div class="row">
<strong>Revision {{ revision.number }}</strong>
<span class="muted">Published {{ revision.published_at }} by {{ revision.published_by|default:"system" }}</span>
{% if can_restore and revision.pk != target.published_revision_id %}
<form method="post" action="{% if is_page %}{% url 'admin:content_editorialpage_restore' target.pk revision.pk %}{% else %}{% url 'admin:content_siteidentity_restore' target.pk revision.pk %}{% endif %}">
{% csrf_token %}<input type="hidden" name="expected_published_id" value="{{ target.published_revision_id|default_if_none:'' }}">
<button type="submit">Restore as new revision</button>
</form>
{% endif %}
</div>
{% empty %}<p>No published revision yet.</p>{% endfor %}
</section>
</main>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "admin/base_site.html" %}
{% block content %}
<main class="cms-editor">
<style>
.cms-editor { max-width: 72rem; margin-inline: auto; padding: 1rem; }
.cms-editor h1 { font-size: 1.5rem; margin-block: .5rem 1rem; }
.cms-editor fieldset { border: 1px solid #b9c7c9; border-radius: .6rem; padding: 1rem; margin-block: 1rem; min-width: 0; }
.cms-editor legend { font-weight: 700; padding-inline: .35rem; }
.cms-editor .field { display: grid; gap: .25rem; margin-block: .75rem; }
.cms-editor input:not([type=checkbox]), .cms-editor textarea, .cms-editor select { box-sizing: border-box; max-width: 100%; width: min(100%, 42rem); padding: .6rem; border: 1px solid #8da5a9; border-radius: .35rem; background: #fff; color: #24383b; }
.cms-editor input:focus-visible, .cms-editor textarea:focus-visible, .cms-editor select:focus-visible { outline: 2px solid #067c80; outline-offset: 2px; }
.cms-editor label { font-weight: 600; }
.cms-editor input:disabled { background: #edf2f2; color: #4b5f63; }
.cms-editor input[type=checkbox] { width: 1.2rem; height: 1.2rem; }
.cms-editor .errorlist { color: #9a2020; }
.cms-editor button { min-height: 2.6rem; padding: .5rem .8rem; border: 1px solid #076e73; border-radius: .35rem; background: #076e73; color: #fff; font-weight: 700; cursor: pointer; }
.cms-editor button:hover { background: #055b60; }
.cms-editor .entry { border-top: 1px solid #dce3e4; margin-block: .8rem; padding-block: .8rem; }
</style>
<h1>{{ title }}</h1>
<p>Saving creates a new draft revision. The published content stays at revision {% if target.published_revision %}{{ target.published_revision.number }}{% else %}none{% endif %}.</p>
<form method="post" novalidate>
{% csrf_token %}
<input type="hidden" name="base_revision_id" value="{{ base.pk|default_if_none:'' }}">
{{ form.non_field_errors }}
<fieldset>
<legend>{% if is_page %}Page text and SEO{% else %}Site identity, navigation, and contacts{% endif %}</legend>
{% for field in form %}<div class="field">{{ field.label_tag }}{{ field }}{{ field.errors }}</div>{% endfor %}
</fieldset>
{% if is_page %}
{% for key, section_form, entry_set in sections %}
<fieldset>
<legend>{{ section_form.title }}</legend>
{% if key == 'featured_events' or key == 'featured_posts' %}<p>Cards are loaded from the existing event or blog API.</p>{% endif %}
{% if key == 'contact' %}<p>Contact details come from the site identity.</p>{% endif %}
{{ section_form.non_field_errors }}
{% for field in section_form %}<div class="field">{{ field.label_tag }}{{ field }}{{ field.errors }}</div>{% endfor %}
{% if entry_set %}
<h3>Entries</h3>
{{ entry_set.management_form }}{{ entry_set.non_form_errors }}
<div id="entry-list-{{ key }}">
{% for entry_form in entry_set %}<div class="entry">{{ entry_form.non_field_errors }}{% for field in entry_form %}<div class="field">{{ field.label_tag }}{{ field }}{{ field.errors }}</div>{% endfor %}</div>{% endfor %}
</div>
<template id="entry-empty-{{ key }}"><div class="entry">{% for field in entry_set.empty_form %}<div class="field">{{ field.label_tag }}{{ field }}</div>{% endfor %}</div></template>
<button type="button" data-entry-add="{{ key }}">Add entry</button>
{% endif %}
</fieldset>
{% endfor %}
{% endif %}
<button type="submit">Save new draft</button>
</form>
<script>
document.querySelectorAll('[data-entry-add]').forEach(function (button) {
button.addEventListener('click', function () {
var key = button.getAttribute('data-entry-add');
var count = document.getElementById('id_entries_' + key + '-TOTAL_FORMS');
if (Number(count.value) >= 20) return;
var template = document.getElementById('entry-empty-' + key);
document.getElementById('entry-list-' + key).insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__prefix__', count.value));
count.value = String(Number(count.value) + 1);
});
});
</script>
</main>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% block content %}
<main style="max-width:72rem;margin-inline:auto;padding:1rem">
<h1>{{ title }}</h1>
<p><strong>Draft preview only.</strong> Revision {{ revision.number }} is not public.</p>
{% if is_page %}
<h2>{{ revision.title }}</h2><p>{{ revision.intro }}</p>
<p>SEO: {{ revision.seo_title }} — {{ revision.seo_description }}</p>
{% for section in sections %}
<section style="border-top:1px solid #b9c7c9;padding-block:1rem">
<h3>{{ section.heading }} {% if not section.visible %}(hidden in public view){% endif %}</h3>
<p>{{ section.body|linebreaksbr }}</p><p>{{ section.intro }}</p>
{% if section.image %}<p>Image: {{ section.image }} — {{ section.image_alt }}</p>{% endif %}
{% if section.all_link_label %}<p>Listing link: {{ section.all_link_label }}</p>{% endif %}
{% for entry in section.entries %}<h4>{{ entry.title }}</h4><p>{{ entry.text|linebreaksbr }}</p>{% endfor %}
</section>
{% endfor %}
{% else %}
<h2>{{ revision.official_name }}</h2>
<p>{{ revision.short_name }} — {{ revision.faculty_subline }}</p>
<p>{{ revision.description|linebreaksbr }}</p>
{% if revision.logo %}<p>Logo: {{ revision.logo }} — {{ revision.logo_alt }}</p>{% endif %}
<h3>Navigation</h3><ul>{% for item in revision.navigation %}<li>{{ item.label }} → {{ item.route_id }} {% if not item.visible %}(hidden){% endif %}</li>{% endfor %}</ul>
<h3>Contacts</h3><ul>{% for item in revision.contacts %}<li>{{ item.label }}: {{ item.url }} {% if not item.visible %}(hidden){% endif %}</li>{% endfor %}</ul>
<p>{{ revision.footer_copy }}</p>
{% endif %}
</main>
{% endblock %}

View File

@@ -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"