Replace content publishing workflow with direct Unfold editing
This commit is contained in:
@@ -1,279 +1,78 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
|
||||
from .forms import ABOUT_KEYS, CONTACT_KINDS, EntryFormSet, PageDraftForm, SectionForm, SiteDraftForm
|
||||
from .models import EditorialPage, MediaAsset, PageRevision, SiteIdentity, SiteRevision
|
||||
from .services import PublishConflict, publish_page, publish_site, require_permission, restore_page, restore_site, save_page_draft, save_site_draft
|
||||
from .current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent
|
||||
|
||||
|
||||
def _expected_id(request):
|
||||
value = request.POST.get("expected_published_id", "")
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid published revision token.")
|
||||
class FixedContentAdmin(ModelAdmin):
|
||||
"""The page records are installed by migration; editors only change them."""
|
||||
|
||||
|
||||
class ContentAdmin(admin.ModelAdmin):
|
||||
list_per_page = 20
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
user = request.user
|
||||
return bool(user.is_active and user.is_staff and (user.is_superuser or any(user.has_perm(f"content.{codename}") for codename in ("change_content_draft", "preview_content_draft", "publish_content"))))
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
user = request.user
|
||||
return bool(user.is_active and user.is_staff and (user.is_superuser or user.has_perm("content.change_content_draft")))
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return self.has_change_permission(request)
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_model_perms(self, request):
|
||||
return {"view": self.has_view_permission(request), "add": self.has_add_permission(request)}
|
||||
|
||||
def _render(self, request, template, **context):
|
||||
return TemplateResponse(request, template, {**self.admin_site.each_context(request), "opts": self.model._meta, **context})
|
||||
@admin.register(SiteContent)
|
||||
class SiteContentAdmin(FixedContentAdmin):
|
||||
fieldsets = (
|
||||
("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}),
|
||||
("Navigation labels", {"fields": ("nav_home", "nav_about", "nav_events", "nav_blog")}),
|
||||
("Contact and footer", {"fields": ("email", "phone", "telegram_url", "instagram_url", "website_url", "address", "footer_copy")}),
|
||||
("SEO defaults", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(EditorialPage)
|
||||
class EditorialPageAdmin(ContentAdmin):
|
||||
list_display = ("slug", "page_kind", "published_number")
|
||||
fields = ("slug", "page_kind")
|
||||
|
||||
@admin.display(description="Published revision")
|
||||
def published_number(self, obj):
|
||||
return obj.published_revision.number if obj.published_revision_id else "None"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.full_clean()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def get_urls(self):
|
||||
urls = [
|
||||
path("<int:object_id>/draft/", self.admin_site.admin_view(self.draft_view), name="content_editorialpage_draft"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/preview/", self.admin_site.admin_view(self.preview_view), name="content_editorialpage_preview"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/publish/", self.admin_site.admin_view(self.publish_view), name="content_editorialpage_publish"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/restore/", self.admin_site.admin_view(self.restore_view), name="content_editorialpage_restore"),
|
||||
]
|
||||
return urls + super().get_urls()
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
if request.method != "GET":
|
||||
return HttpResponseNotAllowed(["GET"])
|
||||
if not self.has_view_permission(request):
|
||||
raise PermissionDenied
|
||||
page = get_object_or_404(EditorialPage, pk=object_id)
|
||||
return self._render(request, "admin/content/dashboard.html", title=f"{page.slug} content", target=page, is_page=True,
|
||||
drafts=page.revisions.filter(status="draft").order_by("-number")[:10],
|
||||
history=page.revisions.filter(status="published").order_by("-number")[:10],
|
||||
can_edit=self.has_change_permission(request),
|
||||
can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"),
|
||||
can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content"))
|
||||
|
||||
def draft_view(self, request, object_id):
|
||||
require_permission(request.user, "change_content_draft")
|
||||
page = get_object_or_404(EditorialPage, pk=object_id)
|
||||
base = page.revisions.order_by("-number").first()
|
||||
stale = request.method == "POST" and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "")
|
||||
keys = ("hero", "featured_events", "featured_posts", "participation") if page.slug == "home" else ABOUT_KEYS if page.slug == "about" else ()
|
||||
existing = {section.key: section for section in base.sections.all()} if base else {}
|
||||
bound = request.method == "POST"
|
||||
form = PageDraftForm(request.POST if bound else None, initial={field: getattr(base, field) for field in ("title", "intro", "seo_title", "seo_description", "seo_image")} if base else None)
|
||||
sections = []
|
||||
valid = form.is_valid() if bound else False
|
||||
for key in keys:
|
||||
section = existing.get(key)
|
||||
section_form = SectionForm(request.POST if bound else None, key=key, slug=page.slug, section=section, prefix=f"section_{key}")
|
||||
entry_set = None
|
||||
if page.slug == "about" and key not in {"identity", "contact"}:
|
||||
entry_set = EntryFormSet(request.POST if bound else None, prefix=f"entries_{key}", initial=section.entries if section else None)
|
||||
if bound:
|
||||
valid = section_form.is_valid() and valid
|
||||
if entry_set:
|
||||
valid = entry_set.is_valid() and valid
|
||||
sections.append((key, section_form, entry_set))
|
||||
if bound and stale:
|
||||
form.add_error(None, "A newer revision exists. Reload the draft before editing.")
|
||||
if bound and valid and not stale:
|
||||
payload = []
|
||||
try:
|
||||
for key, section_form, entry_set in sections:
|
||||
entries = [
|
||||
{"title": item["title"], "text": item["text"]}
|
||||
for item in (entry_set.cleaned_data if entry_set else [])
|
||||
if item and not item.get("DELETE") and item.get("title")
|
||||
]
|
||||
if page.slug == "about" and key not in {"identity", "contact"} and not section_form.cleaned_data["heading"] and not entries:
|
||||
continue
|
||||
payload.append(section_form.payload(entries))
|
||||
if page.slug == "about":
|
||||
contact = next(item for item in payload if item["key"] == "contact")
|
||||
contact["position"] = max((item["position"] for item in payload if item["key"] != "contact"), default=0) + 1
|
||||
revision = save_page_draft(request.user, page, form.payload(), payload, base_revision=base)
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
form.add_error(None, str(exc))
|
||||
else:
|
||||
messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.")
|
||||
return redirect("admin:content_editorialpage_change", page.pk)
|
||||
return self._render(request, "admin/content/draft.html", title=f"Edit {page.slug} draft", target=page,
|
||||
is_page=True, form=form, sections=sections, base=base)
|
||||
|
||||
def preview_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "preview_content_draft")
|
||||
revision = get_object_or_404(PageRevision.objects.prefetch_related("sections"), pk=revision_id, page_id=object_id, status="draft")
|
||||
response = self._render(request, "admin/content/preview.html", title=f"Draft {revision.number} preview", revision=revision,
|
||||
is_page=True, sections=revision.sections.order_by("position"))
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
response["X-Robots-Tag"] = "noindex, nofollow"
|
||||
return response
|
||||
|
||||
def publish_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "publish_content")
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
revision = get_object_or_404(PageRevision, pk=revision_id, page_id=object_id)
|
||||
try:
|
||||
published = publish_page(request.user, revision, expected_published_id=_expected_id(request))
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
return HttpResponseBadRequest(str(exc))
|
||||
messages.success(request, f"Published revision {published.number}.")
|
||||
return redirect("admin:content_editorialpage_change", object_id)
|
||||
|
||||
def restore_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "restore_content")
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
page = get_object_or_404(EditorialPage, pk=object_id)
|
||||
source = get_object_or_404(PageRevision, pk=revision_id, page=page)
|
||||
try:
|
||||
restored = restore_page(request.user, page, source, expected_published_id=_expected_id(request))
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
return HttpResponseBadRequest(str(exc))
|
||||
messages.success(request, f"Restored as revision {restored.number}.")
|
||||
return redirect("admin:content_editorialpage_change", object_id)
|
||||
@admin.register(HomeContent)
|
||||
class HomeContentAdmin(FixedContentAdmin):
|
||||
fieldsets = (
|
||||
("Page introduction", {"fields": ("title", "intro")}),
|
||||
("Hero", {"fields": ("hero_heading", "hero_body", "hero_image", "hero_image_alt", "hero_action_label")}),
|
||||
("Featured events", {"fields": ("featured_events_heading", "featured_events_intro", "featured_events_link_label")}),
|
||||
("Featured blog posts", {"fields": ("featured_posts_heading", "featured_posts_intro", "featured_posts_link_label")}),
|
||||
("Participation", {"fields": ("participation_heading", "participation_body", "participation_image", "participation_image_alt", "participation_action_label")}),
|
||||
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SiteIdentity)
|
||||
class SiteIdentityAdmin(ContentAdmin):
|
||||
list_display = ("__str__", "published_number")
|
||||
class AboutItemInline(TabularInline):
|
||||
model = AboutItem
|
||||
extra = 0
|
||||
fields = ("kind", "title", "text", "position")
|
||||
|
||||
@admin.display(description="Published revision")
|
||||
def published_number(self, obj):
|
||||
return obj.published_revision.number if obj.published_revision_id else "None"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return super().has_add_permission(request) and not SiteIdentity.objects.exists()
|
||||
|
||||
def add_view(self, request, form_url="", extra_context=None):
|
||||
if not self.has_add_permission(request):
|
||||
raise PermissionDenied
|
||||
if request.method == "POST":
|
||||
site, _ = SiteIdentity.objects.get_or_create(pk=1)
|
||||
return redirect("admin:content_siteidentity_change", site.pk)
|
||||
return self._render(request, "admin/content/bootstrap.html", title="Create site identity")
|
||||
|
||||
def get_urls(self):
|
||||
urls = [
|
||||
path("<int:object_id>/draft/", self.admin_site.admin_view(self.draft_view), name="content_siteidentity_draft"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/preview/", self.admin_site.admin_view(self.preview_view), name="content_siteidentity_preview"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/publish/", self.admin_site.admin_view(self.publish_view), name="content_siteidentity_publish"),
|
||||
path("<int:object_id>/revisions/<int:revision_id>/restore/", self.admin_site.admin_view(self.restore_view), name="content_siteidentity_restore"),
|
||||
]
|
||||
return urls + super().get_urls()
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
if request.method != "GET":
|
||||
return HttpResponseNotAllowed(["GET"])
|
||||
if not self.has_view_permission(request):
|
||||
raise PermissionDenied
|
||||
site = get_object_or_404(SiteIdentity, pk=object_id)
|
||||
return self._render(request, "admin/content/dashboard.html", title="Site identity content", target=site, is_page=False,
|
||||
drafts=site.revisions.filter(status="draft").order_by("-number")[:10],
|
||||
history=site.revisions.filter(status="published").order_by("-number")[:10],
|
||||
can_edit=self.has_change_permission(request),
|
||||
can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"),
|
||||
can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content"))
|
||||
|
||||
def draft_view(self, request, object_id):
|
||||
require_permission(request.user, "change_content_draft")
|
||||
site = get_object_or_404(SiteIdentity, pk=object_id)
|
||||
base = site.revisions.order_by("-number").first()
|
||||
bound = request.method == "POST"
|
||||
stale = bound and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "")
|
||||
form = SiteDraftForm(request.POST if bound else None, revision=base)
|
||||
if bound and stale:
|
||||
form.add_error(None, "A newer revision exists. Reload the draft before editing.")
|
||||
if bound and form.is_valid() and not stale:
|
||||
try:
|
||||
revision = save_site_draft(request.user, site, form.payload(), base_revision=base)
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
form.add_error(None, str(exc))
|
||||
else:
|
||||
messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.")
|
||||
return redirect("admin:content_siteidentity_change", site.pk)
|
||||
return self._render(request, "admin/content/draft.html", title="Edit site identity draft", target=site,
|
||||
is_page=False, form=form, base=base, contact_kinds=CONTACT_KINDS)
|
||||
|
||||
def preview_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "preview_content_draft")
|
||||
revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id, status="draft")
|
||||
response = self._render(request, "admin/content/preview.html", title=f"Site draft {revision.number} preview", revision=revision, is_page=False)
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
response["X-Robots-Tag"] = "noindex, nofollow"
|
||||
return response
|
||||
|
||||
def publish_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "publish_content")
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id)
|
||||
try:
|
||||
published = publish_site(request.user, revision, expected_published_id=_expected_id(request))
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
return HttpResponseBadRequest(str(exc))
|
||||
messages.success(request, f"Published revision {published.number}.")
|
||||
return redirect("admin:content_siteidentity_change", object_id)
|
||||
|
||||
def restore_view(self, request, object_id, revision_id):
|
||||
require_permission(request.user, "restore_content")
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
site = get_object_or_404(SiteIdentity, pk=object_id)
|
||||
source = get_object_or_404(SiteRevision, pk=revision_id, site=site)
|
||||
try:
|
||||
restored = restore_site(request.user, site, source, expected_published_id=_expected_id(request))
|
||||
except (ValidationError, PublishConflict) as exc:
|
||||
return HttpResponseBadRequest(str(exc))
|
||||
messages.success(request, f"Restored as revision {restored.number}.")
|
||||
return redirect("admin:content_siteidentity_change", object_id)
|
||||
|
||||
|
||||
@admin.register(MediaAsset)
|
||||
class MediaAssetAdmin(ContentAdmin):
|
||||
list_display = ("__str__", "width", "height", "bytes", "approved", "created_at")
|
||||
fields = ("file", "alt", "source", "license", "approved")
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return request.user.has_perm("content.change_aboutcontent")
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
if obj is not None and obj.approved:
|
||||
return False
|
||||
return super().has_change_permission(request, obj)
|
||||
return request.user.has_perm("content.change_aboutcontent")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if not (request.user.is_superuser or request.user.has_perm("content.publish_content")):
|
||||
return ("approved",)
|
||||
return ()
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.has_perm("content.change_aboutcontent")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not (request.user.is_superuser or request.user.has_perm("content.publish_content")):
|
||||
obj.approved = False
|
||||
obj.full_clean()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.register(AboutContent)
|
||||
class AboutContentAdmin(FixedContentAdmin):
|
||||
inlines = (AboutItemInline,)
|
||||
fieldsets = (
|
||||
("Page introduction", {"fields": ("title", "intro", "identity_heading", "identity_body")}),
|
||||
("Mission", {"fields": ("mission_heading", "mission_body")}),
|
||||
("Activities", {"fields": ("activities_heading", "activities_body")}),
|
||||
("Values", {"fields": ("values_heading", "values_body")}),
|
||||
("Committees", {"fields": ("committees_heading", "committees_body")}),
|
||||
("Affiliations", {"fields": ("affiliations_heading", "affiliations_body")}),
|
||||
("FAQ and contact", {"fields": ("faq_heading", "contact_heading")}),
|
||||
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ListingContent)
|
||||
class ListingContentAdmin(FixedContentAdmin):
|
||||
list_display = ("slug", "title")
|
||||
readonly_fields = ("slug",)
|
||||
fieldsets = (
|
||||
("Content", {"fields": ("slug", "title", "intro")}),
|
||||
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
|
||||
)
|
||||
|
||||
198
apps/content/current.py
Normal file
198
apps/content/current.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Directly editable public site content."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from . import validators
|
||||
|
||||
|
||||
def content_upload_path(instance, filename):
|
||||
return f"content/{uuid4().hex}{Path(filename).suffix.lower()}"
|
||||
|
||||
|
||||
def check_image(file):
|
||||
if not file or getattr(file, "_committed", True):
|
||||
return
|
||||
if file.size > 5 * 1024 * 1024:
|
||||
raise ValidationError("Images must be at most 5 MiB.")
|
||||
try:
|
||||
file.seek(0)
|
||||
with Image.open(file) as image:
|
||||
expected = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(Path(file.name).suffix.lower())
|
||||
if image.format != expected:
|
||||
raise ValueError("Image format does not match its file extension.")
|
||||
image.verify()
|
||||
file.seek(0)
|
||||
with Image.open(file) as image:
|
||||
width, height = image.size
|
||||
if not 1 <= width <= 4096 or not 1 <= height <= 4096:
|
||||
raise ValueError("Image dimensions must be between 1 and 4096 pixels.")
|
||||
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
raise ValidationError("Upload a valid PNG, JPEG, or WebP image.") from exc
|
||||
finally:
|
||||
file.seek(0)
|
||||
|
||||
|
||||
class EditableContent(models.Model):
|
||||
seo_title = models.CharField("SEO title", max_length=120, blank=True)
|
||||
seo_description = models.CharField("SEO description", max_length=300, blank=True)
|
||||
seo_image = models.ImageField("Social sharing image", upload_to=content_upload_path, blank=True)
|
||||
seo_image_alt = models.CharField("Social image description", max_length=180, blank=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
errors = {}
|
||||
for field in self._meta.fields:
|
||||
if isinstance(field, (models.CharField, models.TextField)):
|
||||
try:
|
||||
validators.text(getattr(self, field.name), field.max_length or 5000)
|
||||
except ValidationError as exc:
|
||||
errors[field.name] = exc
|
||||
for image_name in ("seo_image", "logo", "hero_image", "participation_image"):
|
||||
if not hasattr(self, image_name):
|
||||
continue
|
||||
image = getattr(self, image_name)
|
||||
alt_name = f"{image_name}_alt" if image_name != "logo" else "logo_alt"
|
||||
if image and not getattr(self, alt_name).strip():
|
||||
errors[alt_name] = "Describe the image for screen readers."
|
||||
if not image and getattr(self, alt_name):
|
||||
errors[alt_name] = "Remove the description or upload an image."
|
||||
try:
|
||||
check_image(image)
|
||||
except ValidationError as exc:
|
||||
errors[image_name] = exc
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
|
||||
class SingleRecordContent(EditableContent):
|
||||
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SiteContent(SingleRecordContent):
|
||||
official_name = models.CharField(max_length=180, blank=True)
|
||||
short_name = models.CharField(max_length=80, blank=True)
|
||||
faculty_subline = models.CharField(max_length=140, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
logo = models.ImageField(upload_to=content_upload_path, blank=True)
|
||||
logo_alt = models.CharField(max_length=180, blank=True)
|
||||
nav_home = models.CharField("Home navigation label", max_length=80, default="Home")
|
||||
nav_about = models.CharField("About navigation label", max_length=80, default="About")
|
||||
nav_events = models.CharField("Events navigation label", max_length=80, default="Events")
|
||||
nav_blog = models.CharField("Blog navigation label", max_length=80, default="Blog")
|
||||
email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=30, blank=True)
|
||||
telegram_url = models.URLField(blank=True)
|
||||
instagram_url = models.URLField(blank=True)
|
||||
website_url = models.URLField(blank=True)
|
||||
address = models.TextField(blank=True)
|
||||
footer_copy = models.CharField(max_length=300, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Site content"
|
||||
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_site")]
|
||||
|
||||
def __str__(self):
|
||||
return "Site content"
|
||||
|
||||
|
||||
class HomeContent(SingleRecordContent):
|
||||
title = models.CharField(max_length=180, blank=True)
|
||||
intro = models.TextField(blank=True)
|
||||
hero_heading = models.CharField(max_length=120, blank=True)
|
||||
hero_body = models.TextField(blank=True)
|
||||
hero_image = models.ImageField(upload_to=content_upload_path, blank=True)
|
||||
hero_image_alt = models.CharField(max_length=180, blank=True)
|
||||
hero_action_label = models.CharField(max_length=40, blank=True)
|
||||
featured_events_heading = models.CharField(max_length=120, blank=True)
|
||||
featured_events_intro = models.CharField(max_length=300, blank=True)
|
||||
featured_events_link_label = models.CharField(max_length=40, blank=True)
|
||||
featured_posts_heading = models.CharField(max_length=120, blank=True)
|
||||
featured_posts_intro = models.CharField(max_length=300, blank=True)
|
||||
featured_posts_link_label = models.CharField(max_length=40, blank=True)
|
||||
participation_heading = models.CharField(max_length=120, blank=True)
|
||||
participation_body = models.TextField(blank=True)
|
||||
participation_image = models.ImageField(upload_to=content_upload_path, blank=True)
|
||||
participation_image_alt = models.CharField(max_length=180, blank=True)
|
||||
participation_action_label = models.CharField(max_length=40, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Home page content"
|
||||
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_home")]
|
||||
|
||||
def __str__(self):
|
||||
return "Home page"
|
||||
|
||||
|
||||
class AboutContent(SingleRecordContent):
|
||||
title = models.CharField(max_length=180, blank=True)
|
||||
intro = models.TextField(blank=True)
|
||||
identity_heading = models.CharField(max_length=120, blank=True)
|
||||
identity_body = models.TextField(blank=True)
|
||||
mission_heading = models.CharField(max_length=120, blank=True)
|
||||
mission_body = models.TextField(blank=True)
|
||||
activities_heading = models.CharField(max_length=120, blank=True)
|
||||
activities_body = models.TextField(blank=True)
|
||||
values_heading = models.CharField(max_length=120, blank=True)
|
||||
values_body = models.TextField(blank=True)
|
||||
committees_heading = models.CharField(max_length=120, blank=True)
|
||||
committees_body = models.TextField(blank=True)
|
||||
affiliations_heading = models.CharField(max_length=120, blank=True)
|
||||
affiliations_body = models.TextField(blank=True)
|
||||
faq_heading = models.CharField("FAQ heading", max_length=120, blank=True)
|
||||
contact_heading = models.CharField(max_length=120, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "About page content"
|
||||
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_about")]
|
||||
|
||||
def __str__(self):
|
||||
return "About page"
|
||||
|
||||
|
||||
class AboutItem(models.Model):
|
||||
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="items")
|
||||
kind = models.CharField(max_length=20, choices=[(key, key.title()) for key in ("mission", "activities", "values", "committees", "affiliations", "faq")])
|
||||
title = models.CharField(max_length=120)
|
||||
text = models.TextField()
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["kind", "position", "pk"]
|
||||
verbose_name = "About page item"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
validators.text(self.title, 120, required=True)
|
||||
validators.text(self.text, 600, required=True)
|
||||
|
||||
|
||||
class ListingContent(EditableContent):
|
||||
slug = models.CharField(max_length=10, primary_key=True, choices=[("events", "Events"), ("blog", "Blog")])
|
||||
title = models.CharField(max_length=180, blank=True)
|
||||
intro = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Listing page content"
|
||||
verbose_name_plural = "Listing page content"
|
||||
|
||||
def __str__(self):
|
||||
return self.get_slug_display()
|
||||
@@ -1,167 +0,0 @@
|
||||
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
|
||||
208
apps/content/migrations/0004_direct_edit_content.py
Normal file
208
apps/content/migrations/0004_direct_edit_content.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Q
|
||||
import django.db.models.deletion
|
||||
|
||||
import apps.content.current
|
||||
|
||||
|
||||
def seo_fields():
|
||||
return [
|
||||
("seo_title", models.CharField(blank=True, max_length=120, verbose_name="SEO title")),
|
||||
("seo_description", models.CharField(blank=True, max_length=300, verbose_name="SEO description")),
|
||||
("seo_image", models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path, verbose_name="Social sharing image")),
|
||||
("seo_image_alt", models.CharField(blank=True, max_length=180, verbose_name="Social image description")),
|
||||
]
|
||||
|
||||
|
||||
def single_id():
|
||||
return [("id", models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False))]
|
||||
|
||||
|
||||
def strings(*names):
|
||||
return [(name, models.CharField(blank=True, max_length=length)) for name, length in names]
|
||||
|
||||
|
||||
def image(name):
|
||||
return (name, models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path))
|
||||
|
||||
|
||||
def copy_image(target, name, asset_id, alt, assets):
|
||||
asset = assets.get(asset_id)
|
||||
if asset:
|
||||
setattr(target, name, asset.file.name)
|
||||
setattr(target, f"{name}_alt", alt or asset.alt)
|
||||
|
||||
|
||||
def populate_current_content(apps, schema_editor):
|
||||
db = schema_editor.connection.alias
|
||||
SiteContent = apps.get_model("content", "SiteContent")
|
||||
HomeContent = apps.get_model("content", "HomeContent")
|
||||
AboutContent = apps.get_model("content", "AboutContent")
|
||||
AboutItem = apps.get_model("content", "AboutItem")
|
||||
ListingContent = apps.get_model("content", "ListingContent")
|
||||
SiteIdentity = apps.get_model("content", "SiteIdentity")
|
||||
EditorialPage = apps.get_model("content", "EditorialPage")
|
||||
MediaAsset = apps.get_model("content", "MediaAsset")
|
||||
assets = {asset.pk: asset for asset in MediaAsset.objects.using(db).all()}
|
||||
|
||||
site = SiteContent(id=1)
|
||||
identity = SiteIdentity.objects.using(db).filter(pk=1).first()
|
||||
revision = identity.published_revision if identity and identity.published_revision_id else None
|
||||
if revision:
|
||||
for field in ("official_name", "short_name", "faculty_subline", "description", "footer_copy", "seo_title", "seo_description"):
|
||||
setattr(site, field, getattr(revision, field))
|
||||
copy_image(site, "logo", revision.logo_id, revision.logo_alt, assets)
|
||||
copy_image(site, "seo_image", revision.seo_image_id, "Social sharing image", assets)
|
||||
for nav in revision.navigation:
|
||||
if nav.get("route_id") in ("home", "about", "events", "blog"):
|
||||
setattr(site, f"nav_{nav['route_id']}", nav.get("label", ""))
|
||||
for contact in revision.contacts:
|
||||
kind, url = contact.get("kind"), contact.get("url", "")
|
||||
if kind == "email":
|
||||
site.email = url.removeprefix("mailto:")
|
||||
elif kind == "phone":
|
||||
site.phone = url.removeprefix("tel:")
|
||||
elif kind in ("telegram", "instagram", "website"):
|
||||
setattr(site, f"{kind}_url", url)
|
||||
site.save(using=db)
|
||||
|
||||
pages = {page.slug: page for page in EditorialPage.objects.using(db).all()}
|
||||
for slug, Model in (("home", HomeContent), ("about", AboutContent), ("events", ListingContent), ("blog", ListingContent)):
|
||||
page = pages.get(slug)
|
||||
revision = page.published_revision if page and page.published_revision_id else None
|
||||
record = Model(id=1) if slug in ("home", "about") else Model(slug=slug)
|
||||
if revision:
|
||||
for field in ("title", "intro", "seo_title", "seo_description"):
|
||||
setattr(record, field, getattr(revision, field))
|
||||
copy_image(record, "seo_image", revision.seo_image_id, "Social sharing image", assets)
|
||||
if slug in ("home", "about"):
|
||||
sections = list(revision.sections.using(db).all())
|
||||
for section in sections:
|
||||
prefix = section.key
|
||||
if slug == "home":
|
||||
if prefix in ("hero", "participation"):
|
||||
setattr(record, f"{prefix}_heading", section.heading)
|
||||
setattr(record, f"{prefix}_body", section.body)
|
||||
setattr(record, f"{prefix}_action_label", (section.primary_action or {}).get("label", ""))
|
||||
copy_image(record, f"{prefix}_image", section.image_id, section.image_alt, assets)
|
||||
elif prefix in ("featured_events", "featured_posts"):
|
||||
setattr(record, f"{prefix}_heading", section.heading)
|
||||
setattr(record, f"{prefix}_intro", section.intro)
|
||||
setattr(record, f"{prefix}_link_label", section.all_link_label)
|
||||
elif prefix in ("identity", "mission", "activities", "values", "committees", "affiliations", "faq", "contact"):
|
||||
setattr(record, f"{prefix}_heading", section.heading)
|
||||
if hasattr(record, f"{prefix}_body"):
|
||||
setattr(record, f"{prefix}_body", section.body)
|
||||
record.save(using=db)
|
||||
if revision and slug == "about":
|
||||
for section in sections:
|
||||
if section.key in ("mission", "activities", "values", "committees", "affiliations", "faq"):
|
||||
for position, entry in enumerate(section.entries):
|
||||
AboutItem.objects.using(db).create(about_id=1, kind=section.key, title=entry["title"], text=entry["text"], position=position)
|
||||
|
||||
|
||||
def preserve_editor_access(apps, schema_editor):
|
||||
db = schema_editor.connection.alias
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
|
||||
models_to_change = ("sitecontent", "homecontent", "aboutcontent", "aboutitem", "listingcontent")
|
||||
new_permissions = []
|
||||
for model in models_to_change:
|
||||
content_type, _ = ContentType.objects.using(db).get_or_create(app_label="content", model=model)
|
||||
permission, _ = Permission.objects.using(db).get_or_create(
|
||||
content_type=content_type, codename=f"change_{model}", defaults={"name": f"Can change {model}"}
|
||||
)
|
||||
new_permissions.append(permission)
|
||||
for group in Group.objects.using(db).filter(name__in=("content_editor", "content_publisher")):
|
||||
group.permissions.add(*new_permissions)
|
||||
old_permissions = Permission.objects.using(db).filter(
|
||||
content_type__app_label="content", codename__in=("change_content_draft", "publish_content")
|
||||
)
|
||||
for user in User.objects.using(db).filter(user_permissions__in=old_permissions).distinct():
|
||||
user.user_permissions.add(*new_permissions)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("content", "0003_content_groups"),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SiteContent",
|
||||
fields=single_id() + seo_fields() + strings(
|
||||
("official_name", 180), ("short_name", 80), ("faculty_subline", 140),
|
||||
("logo_alt", 180), ("phone", 30), ("footer_copy", 300)
|
||||
) + [
|
||||
("description", models.TextField(blank=True)), image("logo"),
|
||||
("nav_home", models.CharField(default="Home", max_length=80, verbose_name="Home navigation label")),
|
||||
("nav_about", models.CharField(default="About", max_length=80, verbose_name="About navigation label")),
|
||||
("nav_events", models.CharField(default="Events", max_length=80, verbose_name="Events navigation label")),
|
||||
("nav_blog", models.CharField(default="Blog", max_length=80, verbose_name="Blog navigation label")),
|
||||
("email", models.EmailField(blank=True, max_length=254)),
|
||||
("telegram_url", models.URLField(blank=True)),
|
||||
("instagram_url", models.URLField(blank=True)),
|
||||
("website_url", models.URLField(blank=True)),
|
||||
("address", models.TextField(blank=True)),
|
||||
],
|
||||
options={"verbose_name": "Site content"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="HomeContent",
|
||||
fields=single_id() + seo_fields() + strings(
|
||||
("title", 180), ("hero_heading", 120), ("hero_image_alt", 180),
|
||||
("hero_action_label", 40), ("featured_events_heading", 120),
|
||||
("featured_events_intro", 300), ("featured_events_link_label", 40),
|
||||
("featured_posts_heading", 120), ("featured_posts_intro", 300),
|
||||
("featured_posts_link_label", 40), ("participation_heading", 120),
|
||||
("participation_image_alt", 180), ("participation_action_label", 40)
|
||||
) + [
|
||||
("intro", models.TextField(blank=True)), ("hero_body", models.TextField(blank=True)),
|
||||
image("hero_image"), ("participation_body", models.TextField(blank=True)),
|
||||
image("participation_image"),
|
||||
],
|
||||
options={"verbose_name": "Home page content"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AboutContent",
|
||||
fields=single_id() + seo_fields() + strings(
|
||||
("title", 180), ("identity_heading", 120), ("mission_heading", 120),
|
||||
("activities_heading", 120), ("values_heading", 120),
|
||||
("committees_heading", 120), ("affiliations_heading", 120),
|
||||
("contact_heading", 120)
|
||||
) + [
|
||||
("faq_heading", models.CharField(blank=True, max_length=120, verbose_name="FAQ heading")),
|
||||
("intro", models.TextField(blank=True)),
|
||||
*[(f"{name}_body", models.TextField(blank=True)) for name in ("identity", "mission", "activities", "values", "committees", "affiliations")],
|
||||
],
|
||||
options={"verbose_name": "About page content"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ListingContent",
|
||||
fields=[("slug", models.CharField(choices=[("events", "Events"), ("blog", "Blog")], max_length=10, primary_key=True, serialize=False))]
|
||||
+ seo_fields() + strings(("title", 180)) + [("intro", models.TextField(blank=True))],
|
||||
options={"verbose_name": "Listing page content", "verbose_name_plural": "Listing page content"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AboutItem",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("kind", models.CharField(choices=[(key, key.title()) for key in ("mission", "activities", "values", "committees", "affiliations", "faq")], max_length=20)),
|
||||
("title", models.CharField(max_length=120)), ("text", models.TextField()),
|
||||
("position", models.PositiveSmallIntegerField(default=0)),
|
||||
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="items", to="content.aboutcontent")),
|
||||
],
|
||||
options={"verbose_name": "About page item", "ordering": ["kind", "position", "pk"]},
|
||||
),
|
||||
migrations.AddConstraint(model_name="sitecontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_site")),
|
||||
migrations.AddConstraint(model_name="homecontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_home")),
|
||||
migrations.AddConstraint(model_name="aboutcontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_about")),
|
||||
migrations.RunPython(populate_current_content, migrations.RunPython.noop),
|
||||
migrations.RunPython(preserve_editor_access, migrations.RunPython.noop),
|
||||
]
|
||||
136
apps/content/migrations/0005_seed_current_website_copy.py
Normal file
136
apps/content/migrations/0005_seed_current_website_copy.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Copy current static website copy into untouched direct-edit content records.
|
||||
|
||||
Sources: frontend src/views/{Home,AboutUs,Events,Blog}.tsx and
|
||||
src/app/{page,about/page,events/page,blog/page,layout}.tsx as of 2026-09-27.
|
||||
The migration deliberately leaves existing CMS edits and uploaded images alone.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
HOME = {
|
||||
"title": "انجمن علمی کامپیوتر دانشگاه گیلان",
|
||||
"intro": "با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.",
|
||||
"hero_heading": "انجمن علمی کامپیوتر دانشگاه گیلان",
|
||||
"hero_body": "با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.",
|
||||
"hero_action_label": "مشاهده رویدادها",
|
||||
"featured_events_heading": "رویدادهای تخصصی",
|
||||
"featured_events_intro": "با شرکت در رویدادهای ما، دانش و مهارتهای خود را در کنار اساتید و متخصصان ارتقا دهید.",
|
||||
"featured_events_link_label": "مشاهده رویدادها",
|
||||
"featured_posts_heading": "مقالات آموزشی",
|
||||
"featured_posts_intro": "تازهترین مقالات تخصصی در حوزههای مختلف علوم کامپیوتر برای یادگیری و توسعه فردی.",
|
||||
"featured_posts_link_label": "خواندن مقالات",
|
||||
"participation_heading": "آماده عضویت هستید؟",
|
||||
"participation_body": "همین حالا به جمع ما بپیوندید و از برنامهها و فرصتهای یادگیری انجمن علمی کامپیوتر دانشگاه گیلان استفاده کنید.",
|
||||
"participation_action_label": "ثبتنام کنید",
|
||||
"seo_title": "انجمن علمی کامپیوتر دانشگاه گیلان",
|
||||
"seo_description": "با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.",
|
||||
}
|
||||
|
||||
ABOUT = {
|
||||
"title": "انجمنهای علمی کامپیوتر گیلان",
|
||||
"intro": "پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاهها و نشستهای تخصصی — با محوریت رشد مهارتهای فنی و مسیرهای شغلی.",
|
||||
"identity_heading": "انجمنهای علمی کامپیوتر گیلان",
|
||||
"identity_body": "پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاهها و نشستهای تخصصی — با محوریت رشد مهارتهای فنی و مسیرهای شغلی.",
|
||||
"mission_heading": "ماموریت ما",
|
||||
"mission_body": "توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت، ایجاد شبکههای حرفهای و فراهمکردن مسیرهای یادگیری پروژهمحور؛ از معرفی نقشها و مسیرهای شغلی تا تمرین مهارتهای نرم و فنی.",
|
||||
"values_heading": "ارزشهای ما",
|
||||
"values_body": "یادگیری پیوسته و اشتراک دانش\nکیفیت، شفافیت و مسئولیتپذیری\nفرصت برابر برای مشارکت\nارتباط مؤثر با صنعت و جامعه",
|
||||
"committees_heading": "کمیتهها و گروههای کاری",
|
||||
"affiliations_heading": "انجمنها",
|
||||
"affiliations_body": "اطلاعات انجمنهای زیرمجموعه/همکار",
|
||||
"faq_heading": "پرسشهای متداول",
|
||||
"contact_heading": "راههای ارتباطی",
|
||||
"seo_title": "درباره ما | انجمن علمی کامپیوتر شرق گیلان",
|
||||
"seo_description": "آشنایی با تاریخچه، مأموریتها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راههای مشارکت دانشجویان در برنامههای انجمن.",
|
||||
}
|
||||
|
||||
EVENTS = {
|
||||
"title": "رویدادها",
|
||||
"intro": "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاهها، مسابقات و برنامههای آموزشی.",
|
||||
"seo_title": "رویدادها",
|
||||
"seo_description": "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاهها، مسابقات و برنامههای آموزشی.",
|
||||
}
|
||||
|
||||
BLOG = {
|
||||
"title": "بلاگ",
|
||||
"intro": "نوشتههای آموزشی، تجربههای دانشجویی و یادداشتهای تخصصی اعضای انجمن علمی.",
|
||||
"seo_title": "وبلاگ",
|
||||
"seo_description": "مقالات، نوشتهها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.",
|
||||
}
|
||||
|
||||
SITE = {
|
||||
"official_name": "انجمن علمی مهندسی کامپیوتر دانشگاه گیلان (دانشکدهی فنی و مهندسی شرق گیلان)",
|
||||
"short_name": "انجمن علمی مهندسی کامپیوتر",
|
||||
"faculty_subline": "دانشکدهی فنی و مهندسی شرق گیلان",
|
||||
"description": "ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.",
|
||||
"nav_home": "خانه",
|
||||
"nav_about": "درباره ما",
|
||||
"nav_events": "رویدادها",
|
||||
"nav_blog": "بلاگ",
|
||||
"email": "eastguilanceassociation@gmail.com",
|
||||
"phone": "۰۱۳-۴۲۶۸۸۴۴۷",
|
||||
"telegram_url": "https://t.me/guilancea",
|
||||
"instagram_url": "https://instagram.com/guilance.ir",
|
||||
"website_url": "https://east-guilan-ce.ir",
|
||||
"address": "گیلان، رودسر، واجارگاه، دانشکدهی فنی و مهندسی شرق گیلان\nکدپستی: ۴۴۹۱۸۹۸۵۶۶",
|
||||
"footer_copy": "ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.",
|
||||
"seo_title": "انجمن علمی کامپیوتر شرق گیلان",
|
||||
"seo_description": "انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاهها و محتوای آموزشی برای دانشجویان و علاقهمندان فناوری.",
|
||||
}
|
||||
|
||||
# Each pair is (kind, title, text). These are the visible About page items.
|
||||
ABOUT_ITEMS = (
|
||||
("committees", "کمیته آموزش", "برنامهریزی و اجرای دورهها و کارگاههای مهارتی، هماهنگی با مدرسین و طراحی مسیرهای یادگیری."),
|
||||
("committees", "کمیته صنعت و اشتغال", "تعامل با شرکتها، دعوت از متخصصان صنعت، و شبکهسازی برای فرصتهای کارآموزی و استخدام."),
|
||||
("committees", "کمیته محتوای دیجیتال", "تولید محتوای آموزشی، خبرنامه، مدیریت شبکههای اجتماعی و پوشش رسانهای رویدادها."),
|
||||
("affiliations", "انجمن علمی مهندسی کامپیوتر دانشکدهی فنی شرق", "دانشگاه گیلان، رودسر؛ تأسیس: ۱۳۹۲. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی. حوزهها: مهندسی نرمافزار، هوش مصنوعی و داده، طراحی محصول، DevOps/امنیت."),
|
||||
("affiliations", "انجمن علمی مهندسی کامپیوتر دانشکدهی فنی", "دانشگاه گیلان، رشت؛ تأسیس: ۱۳۵۳. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی. حوزهها: مهندسی نرمافزار، هوش مصنوعی و داده، DevOps."),
|
||||
("affiliations", "انجمن علمی علوم کامپیوتر دانشکدهی علومپایه", "دانشگاه گیلان، رشت؛ تأسیس: ۱۳۵۳. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی. حوزهها: امنیت، سیستمعامل، سختافزار."),
|
||||
("faq", "چطور در رویدادها شرکت کنم؟", "برای ثبتنام در رویدادها ابتدا باید حسابکاربری با کددانشجویی خود ایجاد کنید و سپس از طریق صفحهی ثبتنام در رویداد موردنظرتان شرکت کنید :)"),
|
||||
("faq", "آیا امکان همکاری انجمنها/شرکتها هم وجود دارد؟", "بله، ما همیشه آمادهی همکاری با سازمانها و انجمنهای مختلف دانشگاهها جهت توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت و ایجاد شبکههای حرفهای هستیم."),
|
||||
("faq", "برای سخنرانی/منتورینگ به چه چیزهایی نیاز است؟", "رزومه کوتاه، موضوع پیشنهادی و زمانهای دسترسپذیرتان را ارسال کنید تا هماهنگ کنیم."),
|
||||
)
|
||||
|
||||
|
||||
def seed_if_untouched(record, values, marker_fields, db):
|
||||
if any(getattr(record, name) for name in marker_fields):
|
||||
return False
|
||||
for name, value in values.items():
|
||||
setattr(record, name, value)
|
||||
record.save(using=db)
|
||||
return True
|
||||
|
||||
|
||||
def seed_current_copy(apps, schema_editor):
|
||||
db = schema_editor.connection.alias
|
||||
SiteContent = apps.get_model("content", "SiteContent")
|
||||
HomeContent = apps.get_model("content", "HomeContent")
|
||||
AboutContent = apps.get_model("content", "AboutContent")
|
||||
AboutItem = apps.get_model("content", "AboutItem")
|
||||
ListingContent = apps.get_model("content", "ListingContent")
|
||||
|
||||
site, _ = SiteContent.objects.using(db).get_or_create(pk=1)
|
||||
seed_if_untouched(site, SITE, ("official_name", "short_name", "email", "seo_title"), db)
|
||||
|
||||
home, _ = HomeContent.objects.using(db).get_or_create(pk=1)
|
||||
seed_if_untouched(home, HOME, ("title", "hero_heading", "seo_title"), db)
|
||||
|
||||
about, _ = AboutContent.objects.using(db).get_or_create(pk=1)
|
||||
seeded_about = seed_if_untouched(about, ABOUT, ("title", "identity_heading", "seo_title"), db)
|
||||
if seeded_about and not AboutItem.objects.using(db).filter(about_id=1).exists():
|
||||
positions = {}
|
||||
for kind, title, item_text in ABOUT_ITEMS:
|
||||
position = positions.get(kind, 0)
|
||||
AboutItem.objects.using(db).create(about_id=1, kind=kind, title=title, text=item_text, position=position)
|
||||
positions[kind] = position + 1
|
||||
|
||||
for slug, values in (("events", EVENTS), ("blog", BLOG)):
|
||||
listing, _ = ListingContent.objects.using(db).get_or_create(slug=slug)
|
||||
seed_if_untouched(listing, values, ("title", "intro", "seo_title"), db)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("content", "0004_direct_edit_content")]
|
||||
|
||||
operations = [migrations.RunPython(seed_current_copy, migrations.RunPython.noop)]
|
||||
@@ -299,3 +299,8 @@ class PublicationAudit(models.Model):
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
raise ValidationError("Publication audit records are append only.")
|
||||
|
||||
|
||||
# Imported for Django model discovery. The revision models above remain for
|
||||
# forward-only migration of data created under the earlier CMS design.
|
||||
from .current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent # noqa: E402,F401
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"""The only application path for changing editorial revisions and publication pointers."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ABOUT_KEYS, HOME_KEYS, EditorialPage, PageRevision, PageSection, PublicationAudit, SiteIdentity, SiteRevision
|
||||
|
||||
|
||||
PAGE_FIELDS = ("title", "intro", "seo_title", "seo_description", "seo_image")
|
||||
SITE_FIELDS = (
|
||||
"official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt",
|
||||
"navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image",
|
||||
)
|
||||
SECTION_FIELDS = (
|
||||
"key", "section_type", "position", "visible", "heading", "body", "intro",
|
||||
"all_link_label", "primary_action", "secondary_action", "entries", "image", "image_alt",
|
||||
)
|
||||
|
||||
|
||||
class PublishConflict(Exception):
|
||||
"""The published pointer changed since the editor loaded it."""
|
||||
|
||||
|
||||
def _allowed(actor, codename):
|
||||
return bool(actor and actor.is_authenticated and actor.is_active and (
|
||||
actor.is_superuser or (actor.is_staff and actor.has_perm(f"content.{codename}"))
|
||||
))
|
||||
|
||||
|
||||
def require_permission(actor, codename):
|
||||
if not _allowed(actor, codename):
|
||||
raise PermissionDenied("You do not have permission for this content action.")
|
||||
|
||||
|
||||
def _fields(data, allowed):
|
||||
if not isinstance(data, dict) or not set(data) <= set(allowed):
|
||||
raise ValidationError("Unexpected editorial fields.")
|
||||
return dict(data)
|
||||
|
||||
|
||||
def _next_number(target):
|
||||
return (target.revisions.aggregate(Max("number"))["number__max"] or 0) + 1
|
||||
|
||||
|
||||
def _checksum(data):
|
||||
encoded = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _field_values(instance, names):
|
||||
return {field: getattr(instance, f"{field}_id") if field in {"logo", "seo_image", "image"} else getattr(instance, field) for field in names}
|
||||
|
||||
|
||||
def _copy_fields(instance, names):
|
||||
return {field: getattr(instance, field) for field in names}
|
||||
|
||||
|
||||
def _page_checksum(revision):
|
||||
sections = [_field_values(section, SECTION_FIELDS) for section in revision.sections.order_by("position")]
|
||||
return _checksum({"page": _field_values(revision, PAGE_FIELDS), "sections": sections})
|
||||
|
||||
|
||||
def _site_checksum(revision):
|
||||
return _checksum(_field_values(revision, SITE_FIELDS))
|
||||
|
||||
|
||||
def _check_media_approved(revision):
|
||||
media = []
|
||||
for field in ("logo", "seo_image"):
|
||||
if hasattr(revision, field):
|
||||
media.append(getattr(revision, field))
|
||||
if isinstance(revision, PageRevision):
|
||||
media.extend(section.image for section in revision.sections.all())
|
||||
if any(asset is not None and not asset.approved for asset in media):
|
||||
raise ValidationError("All published media must be approved.")
|
||||
|
||||
|
||||
def _validate_page(revision):
|
||||
revision.full_clean()
|
||||
sections = list(revision.sections.order_by("position"))
|
||||
for section in sections:
|
||||
section.full_clean()
|
||||
slug = revision.page.slug
|
||||
keys = [section.key for section in sections]
|
||||
if slug == "home" and keys != list(HOME_KEYS):
|
||||
raise ValidationError("Home requires all four sections in the fixed order.")
|
||||
if slug == "about" and (not keys or keys[0] != "identity" or keys[-1] != "contact" or set(keys) - set(ABOUT_KEYS)):
|
||||
raise ValidationError("About requires identity first and contact last.")
|
||||
if slug in {"events", "blog"} and sections:
|
||||
raise ValidationError("Listing pages cannot have sections.")
|
||||
_check_media_approved(revision)
|
||||
|
||||
|
||||
def _validate_site(revision):
|
||||
revision.full_clean()
|
||||
_check_media_approved(revision)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_page_draft(actor, page: EditorialPage, fields: dict, sections: list[dict], *, base_revision=None):
|
||||
require_permission(actor, "change_content_draft")
|
||||
page = EditorialPage.objects.select_for_update().get(pk=page.pk)
|
||||
if base_revision is not None:
|
||||
base_revision = PageRevision.objects.get(pk=base_revision.pk)
|
||||
if base_revision.page_id != page.pk:
|
||||
raise ValidationError("Base revision belongs to another page.")
|
||||
if base_revision.pk != page.revisions.order_by("-number").values_list("pk", flat=True).first():
|
||||
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
||||
if not isinstance(sections, list):
|
||||
raise ValidationError("Sections must be a list.")
|
||||
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_fields(fields, PAGE_FIELDS))
|
||||
revision.full_clean()
|
||||
revision.save()
|
||||
for data in sections:
|
||||
section = PageSection(revision=revision, **_fields(data, SECTION_FIELDS))
|
||||
section.full_clean()
|
||||
section.save()
|
||||
revision.content_checksum = _page_checksum(revision)
|
||||
revision.save(update_fields=["content_checksum"])
|
||||
PublicationAudit.objects.create(actor=actor, page=page, from_revision=base_revision.number if base_revision else None, to_revision=revision.number, operation="update_draft" if base_revision else "create_draft")
|
||||
return revision
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_site_draft(actor, site: SiteIdentity, fields: dict, *, base_revision=None):
|
||||
require_permission(actor, "change_content_draft")
|
||||
site = SiteIdentity.objects.select_for_update().get(pk=site.pk)
|
||||
if base_revision is not None:
|
||||
base_revision = SiteRevision.objects.get(pk=base_revision.pk)
|
||||
if base_revision.site_id != site.pk:
|
||||
raise ValidationError("Base revision belongs to another site.")
|
||||
if base_revision.pk != site.revisions.order_by("-number").values_list("pk", flat=True).first():
|
||||
raise PublishConflict("A newer revision exists. Reload the draft before editing.")
|
||||
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_fields(fields, SITE_FIELDS))
|
||||
revision.full_clean()
|
||||
revision.content_checksum = _site_checksum(revision)
|
||||
revision.save()
|
||||
PublicationAudit.objects.create(actor=actor, site=site, from_revision=base_revision.number if base_revision else None, to_revision=revision.number, operation="update_draft" if base_revision else "create_draft")
|
||||
return revision
|
||||
|
||||
|
||||
def _check_expected(target, expected_published_id):
|
||||
if target.published_revision_id != expected_published_id:
|
||||
raise PublishConflict("Published content changed. Reload before publishing.")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def publish_page(actor, draft: PageRevision, *, expected_published_id):
|
||||
require_permission(actor, "publish_content")
|
||||
page = EditorialPage.objects.select_for_update().get(pk=draft.page_id)
|
||||
_check_expected(page, expected_published_id)
|
||||
revision = PageRevision.objects.get(pk=draft.pk, page=page)
|
||||
if revision.status != "draft":
|
||||
raise ValidationError("Only a draft can be published.")
|
||||
_validate_page(revision)
|
||||
if revision.content_checksum != _page_checksum(revision):
|
||||
raise ValidationError("Draft content changed outside the content service.")
|
||||
revision.status = "published"
|
||||
revision.published_by = actor
|
||||
revision.published_at = timezone.now()
|
||||
revision.save(update_fields=["status", "published_by", "published_at"])
|
||||
if not EditorialPage.objects.filter(pk=page.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
||||
raise PublishConflict("Published content changed. Reload before publishing.")
|
||||
PublicationAudit.objects.create(actor=actor, page=page, from_revision=page.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first(), to_revision=revision.number, operation="publish")
|
||||
return revision
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def publish_site(actor, draft: SiteRevision, *, expected_published_id):
|
||||
require_permission(actor, "publish_content")
|
||||
site = SiteIdentity.objects.select_for_update().get(pk=draft.site_id)
|
||||
_check_expected(site, expected_published_id)
|
||||
revision = SiteRevision.objects.get(pk=draft.pk, site=site)
|
||||
if revision.status != "draft":
|
||||
raise ValidationError("Only a draft can be published.")
|
||||
_validate_site(revision)
|
||||
if revision.content_checksum != _site_checksum(revision):
|
||||
raise ValidationError("Draft content changed outside the content service.")
|
||||
revision.status = "published"
|
||||
revision.published_by = actor
|
||||
revision.published_at = timezone.now()
|
||||
revision.save(update_fields=["status", "published_by", "published_at"])
|
||||
if not SiteIdentity.objects.filter(pk=site.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
||||
raise PublishConflict("Published content changed. Reload before publishing.")
|
||||
PublicationAudit.objects.create(actor=actor, site=site, from_revision=site.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first(), to_revision=revision.number, operation="publish")
|
||||
return revision
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def restore_page(actor, page: EditorialPage, source: PageRevision, *, expected_published_id):
|
||||
require_permission(actor, "restore_content")
|
||||
page = EditorialPage.objects.select_for_update().get(pk=page.pk)
|
||||
_check_expected(page, expected_published_id)
|
||||
source = PageRevision.objects.get(pk=source.pk, page=page)
|
||||
if source.status != "published":
|
||||
raise ValidationError("Only a published revision can be restored.")
|
||||
if source.content_checksum != _page_checksum(source):
|
||||
raise ValidationError("Published content checksum does not match.")
|
||||
revision = PageRevision(page=page, number=_next_number(page), created_by=actor, **_copy_fields(source, PAGE_FIELDS))
|
||||
revision.full_clean()
|
||||
revision.save()
|
||||
for original in source.sections.all():
|
||||
section = PageSection(revision=revision, **_copy_fields(original, SECTION_FIELDS))
|
||||
section.full_clean()
|
||||
section.save()
|
||||
_validate_page(revision)
|
||||
revision.content_checksum = _page_checksum(revision)
|
||||
revision.status = "published"
|
||||
revision.published_by = actor
|
||||
revision.published_at = timezone.now()
|
||||
revision.save(update_fields=["content_checksum", "status", "published_by", "published_at"])
|
||||
if not EditorialPage.objects.filter(pk=page.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
||||
raise PublishConflict("Published content changed. Reload before restoring.")
|
||||
previous = page.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first()
|
||||
PublicationAudit.objects.create(actor=actor, page=page, from_revision=previous, to_revision=revision.number, operation="restore")
|
||||
return revision
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def restore_site(actor, site: SiteIdentity, source: SiteRevision, *, expected_published_id):
|
||||
require_permission(actor, "restore_content")
|
||||
site = SiteIdentity.objects.select_for_update().get(pk=site.pk)
|
||||
_check_expected(site, expected_published_id)
|
||||
source = SiteRevision.objects.get(pk=source.pk, site=site)
|
||||
if source.status != "published":
|
||||
raise ValidationError("Only a published revision can be restored.")
|
||||
if source.content_checksum != _site_checksum(source):
|
||||
raise ValidationError("Published content checksum does not match.")
|
||||
revision = SiteRevision(site=site, number=_next_number(site), created_by=actor, **_copy_fields(source, SITE_FIELDS))
|
||||
_validate_site(revision)
|
||||
revision.content_checksum = _site_checksum(revision)
|
||||
revision.status = "published"
|
||||
revision.published_by = actor
|
||||
revision.published_at = timezone.now()
|
||||
revision.save()
|
||||
if not SiteIdentity.objects.filter(pk=site.pk, published_revision_id=expected_published_id).update(published_revision=revision):
|
||||
raise PublishConflict("Published content changed. Reload before restoring.")
|
||||
previous = site.revisions.filter(pk=expected_published_id).values_list("number", flat=True).first()
|
||||
PublicationAudit.objects.create(actor=actor, site=site, from_revision=previous, to_revision=revision.number, operation="restore")
|
||||
return revision
|
||||
@@ -1,6 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,28 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,212 +1,96 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from apps.content.models import EditorialPage, MediaAsset, PageRevision, SiteIdentity
|
||||
from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent
|
||||
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))
|
||||
def staff(username, *permissions):
|
||||
user = User.objects.create_user(
|
||||
username=username, mobile=f"0912{User.objects.count():07d}",
|
||||
password="TestPass123!", is_staff=True,
|
||||
)
|
||||
user.user_permissions.add(*Permission.objects.filter(content_type__app_label="content", codename__in=permissions))
|
||||
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_home_uses_standard_unfold_change_form_and_saves_directly(client):
|
||||
client.force_login(staff("home-editor", "change_homecontent"))
|
||||
url = reverse("admin:content_homecontent_change", args=[1])
|
||||
|
||||
|
||||
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"
|
||||
assert b'name="hero_heading"' in response.content
|
||||
assert b'name="seo_title"' in response.content
|
||||
assert b"published_revision" not in response.content
|
||||
assert client.get(reverse("admin:content_homecontent_add")).status_code == 403
|
||||
|
||||
current = HomeContent.objects.get(pk=1)
|
||||
data = {
|
||||
field.name: getattr(current, field.name)
|
||||
for field in current._meta.fields
|
||||
if field.editable and not field.is_relation and not isinstance(field, models.ImageField)
|
||||
}
|
||||
data["hero_heading"] = "A new heading"
|
||||
data["seo_title"] = "New SEO title"
|
||||
data["_save"] = "Save"
|
||||
response = client.post(url, data)
|
||||
assert response.status_code == 302
|
||||
current.refresh_from_db()
|
||||
assert current.hero_heading == "A new heading"
|
||||
assert current.seo_title == "New SEO title"
|
||||
|
||||
|
||||
def test_staff_without_model_change_permission_cannot_edit(client):
|
||||
client.force_login(staff("other-staff"))
|
||||
assert client.get(reverse("admin:content_aboutcontent_change", args=[1])).status_code == 403
|
||||
assert client.post(reverse("admin:content_aboutcontent_change", args=[1]), {"title": "Changed"}).status_code == 403
|
||||
|
||||
|
||||
def test_site_links_use_native_fields_and_reject_unsafe_url(client):
|
||||
client.force_login(staff("site-editor", "change_sitecontent"))
|
||||
url = reverse("admin:content_sitecontent_change", args=[1])
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b'name="telegram_url"' in response.content
|
||||
assert b'name="logo"' in response.content
|
||||
|
||||
current = SiteContent.objects.get(pk=1)
|
||||
data = {
|
||||
field.name: getattr(current, field.name)
|
||||
for field in current._meta.fields
|
||||
if field.editable and not field.is_relation and not isinstance(field, models.ImageField)
|
||||
}
|
||||
data["telegram_url"] = "javascript:alert(1)"
|
||||
response = client.post(url, data)
|
||||
assert response.status_code == 200
|
||||
assert b"contact_email_url" in response.content
|
||||
assert site.revisions.count() == 0
|
||||
current.refresh_from_db()
|
||||
assert current.telegram_url != "javascript:alert(1)"
|
||||
|
||||
|
||||
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_fixed_page_records_exist_and_cannot_be_added_or_deleted(client):
|
||||
assert HomeContent.objects.filter(pk=1).exists()
|
||||
assert AboutContent.objects.filter(pk=1).exists()
|
||||
assert SiteContent.objects.filter(pk=1).exists()
|
||||
assert set(ListingContent.objects.values_list("slug", flat=True)) == {"events", "blog"}
|
||||
|
||||
client.force_login(staff("listing-editor", "change_listingcontent"))
|
||||
assert client.get(reverse("admin:content_listingcontent_add")).status_code == 403
|
||||
assert client.get(reverse("admin:content_listingcontent_delete", args=["events"])).status_code == 403
|
||||
assert client.get(reverse("admin:content_listingcontent_change", args=["events"])).status_code == 200
|
||||
|
||||
|
||||
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"
|
||||
def test_current_website_copy_is_seeded_once():
|
||||
home = HomeContent.objects.get(pk=1)
|
||||
about = AboutContent.objects.get(pk=1)
|
||||
events = ListingContent.objects.get(pk="events")
|
||||
blog = ListingContent.objects.get(pk="blog")
|
||||
assert home.hero_heading == "انجمن علمی کامپیوتر دانشگاه گیلان"
|
||||
assert about.items.filter(kind="faq").count() == 3
|
||||
assert events.seo_title == "رویدادها"
|
||||
assert blog.title == "بلاگ" and blog.seo_title == "وبلاگ"
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import io
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import connection, connections, close_old_connections
|
||||
from PIL import Image
|
||||
|
||||
from apps.content.models import EditorialPage, MediaAsset, PageRevision, PublicationAudit, SiteIdentity
|
||||
from apps.content.services import (
|
||||
PublishConflict,
|
||||
publish_page,
|
||||
publish_site,
|
||||
restore_page,
|
||||
restore_site,
|
||||
save_page_draft,
|
||||
save_site_draft,
|
||||
)
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.django_db, pytest.mark.integration]
|
||||
|
||||
|
||||
def actor(name, *permissions, staff=True, superuser=False):
|
||||
user = User.objects.create_user(username=name, mobile=f"0912{User.objects.count():07d}", password="TestPass123!")
|
||||
user.is_staff = staff
|
||||
user.is_superuser = superuser
|
||||
user.save(update_fields=["is_staff", "is_superuser"])
|
||||
user.user_permissions.add(*Permission.objects.filter(content_type__app_label="content", codename__in=permissions))
|
||||
return user
|
||||
|
||||
|
||||
def page_fields(title="Home"):
|
||||
return {"title": title, "intro": "Introduction", "seo_title": title, "seo_description": "Description"}
|
||||
|
||||
|
||||
def home_sections():
|
||||
return [
|
||||
{"key": "hero", "section_type": "hero", "position": 0, "heading": "Welcome", "body": "Hello"},
|
||||
{"key": "featured_events", "section_type": "featured_events", "position": 1, "heading": "Events", "all_link_label": "All events"},
|
||||
{"key": "featured_posts", "section_type": "featured_posts", "position": 2, "heading": "Posts", "all_link_label": "All posts"},
|
||||
{"key": "participation", "section_type": "participation", "position": 3, "heading": "Join", "body": "Join us"},
|
||||
]
|
||||
|
||||
|
||||
def site_fields():
|
||||
return {
|
||||
"official_name": "Association",
|
||||
"short_name": "ACE",
|
||||
"navigation": [{"route_id": route, "label": route, "position": index, "visible": True} for index, route in enumerate(("home", "about", "events", "blog"))],
|
||||
"contacts": [],
|
||||
}
|
||||
|
||||
|
||||
def test_role_matrix_and_audit_for_page():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
outsider = actor("outsider", staff=False)
|
||||
staff = actor("staff")
|
||||
editor = actor("editor", "change_content_draft", "preview_content_draft")
|
||||
nonstaff_editor = actor("nonstaff", "change_content_draft", staff=False)
|
||||
publisher = actor("publisher", "change_content_draft", "preview_content_draft", "publish_content", "restore_content")
|
||||
for denied in (outsider, staff, nonstaff_editor):
|
||||
with pytest.raises(PermissionDenied):
|
||||
save_page_draft(denied, page, page_fields(), home_sections())
|
||||
draft = save_page_draft(editor, page, page_fields(), home_sections())
|
||||
assert draft.number == 1 and draft.status == "draft"
|
||||
with pytest.raises(PermissionDenied):
|
||||
publish_page(editor, draft, expected_published_id=None)
|
||||
published = publish_page(publisher, draft, expected_published_id=None)
|
||||
page.refresh_from_db()
|
||||
assert page.published_revision_id == published.id
|
||||
assert published.status == "published" and published.published_by == publisher
|
||||
assert list(PublicationAudit.objects.values_list("operation", flat=True)) == ["create_draft", "publish"]
|
||||
|
||||
|
||||
def test_edit_creates_revision_and_restore_preserves_history():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
user = actor("chief", superuser=True)
|
||||
first = save_page_draft(user, page, page_fields("First"), home_sections())
|
||||
publish_page(user, first, expected_published_id=None)
|
||||
second = save_page_draft(user, page, page_fields("Second"), home_sections(), base_revision=first)
|
||||
publish_page(user, second, expected_published_id=first.id)
|
||||
restored = restore_page(user, page, first, expected_published_id=second.id)
|
||||
assert restored.number == 3 and restored.title == "First"
|
||||
assert restored.content_checksum == first.content_checksum
|
||||
assert PageRevision.objects.get(pk=second.pk).title == "Second"
|
||||
assert page.revisions.count() == 3
|
||||
assert list(PublicationAudit.objects.values_list("operation", flat=True)) == ["create_draft", "publish", "update_draft", "publish", "restore"]
|
||||
|
||||
|
||||
def test_stale_publish_is_rejected_without_swapping_pointer():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
user = actor("chief", superuser=True)
|
||||
first = save_page_draft(user, page, page_fields("First"), home_sections())
|
||||
second = save_page_draft(user, page, page_fields("Second"), home_sections())
|
||||
publish_page(user, first, expected_published_id=None)
|
||||
with pytest.raises(PublishConflict):
|
||||
publish_page(user, second, expected_published_id=None)
|
||||
page.refresh_from_db()
|
||||
assert page.published_revision_id == first.id
|
||||
assert second.status == "draft"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.skipif(connection.vendor != "postgresql", reason="SQLite has no row-level select_for_update locking")
|
||||
def test_concurrent_publish_only_one_revision_wins():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
user = actor("chief", superuser=True)
|
||||
drafts = [save_page_draft(user, page, page_fields(title), home_sections()) for title in ("First", "Second")]
|
||||
barrier = Barrier(3)
|
||||
|
||||
def attempt(revision_id):
|
||||
close_old_connections()
|
||||
barrier.wait()
|
||||
try:
|
||||
publish_page(user, PageRevision.objects.get(pk=revision_id), expected_published_id=None)
|
||||
return "published"
|
||||
except PublishConflict:
|
||||
return "conflict"
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(attempt, draft.pk) for draft in drafts]
|
||||
barrier.wait()
|
||||
outcomes = [future.result(timeout=15) for future in futures]
|
||||
assert sorted(outcomes) == ["conflict", "published"]
|
||||
page.refresh_from_db()
|
||||
assert page.published_revision_id in {draft.pk for draft in drafts}
|
||||
|
||||
|
||||
def test_publish_rejects_incomplete_page_and_leaves_no_public_content():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
user = actor("chief", superuser=True)
|
||||
draft = save_page_draft(user, page, page_fields(), home_sections()[:-1])
|
||||
with pytest.raises(ValidationError):
|
||||
publish_page(user, draft, expected_published_id=None)
|
||||
page.refresh_from_db()
|
||||
assert page.published_revision_id is None
|
||||
|
||||
|
||||
def test_site_draft_and_publish_require_distinct_permissions():
|
||||
site = SiteIdentity.objects.create()
|
||||
editor = actor("editor", "change_content_draft")
|
||||
publisher = actor("publisher", "publish_content")
|
||||
draft = save_site_draft(editor, site, site_fields())
|
||||
with pytest.raises(PermissionDenied):
|
||||
publish_site(editor, draft, expected_published_id=None)
|
||||
publish_site(publisher, draft, expected_published_id=None)
|
||||
site.refresh_from_db()
|
||||
assert site.published_revision_id == draft.id
|
||||
|
||||
|
||||
def test_restore_site_copies_media_reference_and_creates_new_revision(settings, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (20, 20)).save(buffer, format="PNG")
|
||||
logo = MediaAsset.objects.create(file=SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png"), alt="Logo", source="owner", license="owned", approved=True)
|
||||
site = SiteIdentity.objects.create()
|
||||
user = actor("chief", superuser=True)
|
||||
fields = site_fields() | {"logo": logo, "logo_alt": "Association logo"}
|
||||
first = save_site_draft(user, site, fields)
|
||||
publish_site(user, first, expected_published_id=None)
|
||||
second = save_site_draft(user, site, site_fields() | {"official_name": "New name"})
|
||||
publish_site(user, second, expected_published_id=first.id)
|
||||
restored = restore_site(user, site, first, expected_published_id=second.id)
|
||||
site.refresh_from_db()
|
||||
assert site.published_revision_id == restored.id
|
||||
assert restored.logo_id == logo.id and restored.number == 3
|
||||
assert restored.content_checksum == first.content_checksum
|
||||
|
||||
|
||||
def test_unapproved_media_cannot_be_published(settings, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (20, 20)).save(buffer, format="PNG")
|
||||
logo = MediaAsset.objects.create(file=SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png"), alt="Logo", source="owner", license="owned")
|
||||
site = SiteIdentity.objects.create()
|
||||
user = actor("chief", superuser=True)
|
||||
draft = save_site_draft(user, site, site_fields() | {"logo": logo, "logo_alt": "Association logo"})
|
||||
with pytest.raises(ValidationError):
|
||||
publish_site(user, draft, expected_published_id=None)
|
||||
site.refresh_from_db()
|
||||
assert site.published_revision_id is None
|
||||
|
||||
|
||||
def test_groups_are_provisioned_without_assigning_accounts():
|
||||
editor = Group.objects.get(name="content_editor")
|
||||
publisher = Group.objects.get(name="content_publisher")
|
||||
assert set(editor.permissions.values_list("codename", flat=True)) >= {"change_content_draft", "preview_content_draft"}
|
||||
assert set(publisher.permissions.values_list("codename", flat=True)) >= {"change_content_draft", "preview_content_draft", "publish_content", "restore_content"}
|
||||
assert editor.user_set.count() == 0 and publisher.user_set.count() == 0
|
||||
|
||||
|
||||
def test_revision_sections_and_audit_are_immutable():
|
||||
page = EditorialPage.objects.create(slug="home", page_kind="sections")
|
||||
user = actor("chief", superuser=True)
|
||||
draft = save_page_draft(user, page, page_fields(), home_sections())
|
||||
draft.title = "Changed in place"
|
||||
with pytest.raises(ValidationError):
|
||||
draft.save()
|
||||
section = draft.sections.first()
|
||||
section.heading = "Changed in place"
|
||||
with pytest.raises(ValidationError):
|
||||
section.save()
|
||||
audit = PublicationAudit.objects.first()
|
||||
audit.operation = "publish"
|
||||
with pytest.raises(ValidationError):
|
||||
audit.save()
|
||||
published = publish_page(user, PageRevision.objects.get(pk=draft.pk), expected_published_id=None)
|
||||
published.status = "draft"
|
||||
with pytest.raises(ValidationError):
|
||||
published.save()
|
||||
@@ -87,7 +87,7 @@ UNFOLD = {
|
||||
{
|
||||
"title": "Public content",
|
||||
"icon": "article",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/content/editorialpage/"),
|
||||
"link": lambda request: request.build_absolute_uri("/admin/content/"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user