Replace content publishing workflow with direct Unfold editing

This commit is contained in:
2026-09-27 11:09:25 +03:30
parent 148b7038a9
commit f6b4ef7dd8
14 changed files with 680 additions and 1232 deletions

View File

@@ -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
View 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()

View File

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

View 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),
]

View 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)]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 == "وبلاگ"

View File

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

View File

@@ -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/"),
},
],
},