diff --git a/apps/content/admin.py b/apps/content/admin.py index 1bfe8a9..326086b 100644 --- a/apps/content/admin.py +++ b/apps/content/admin.py @@ -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("/draft/", self.admin_site.admin_view(self.draft_view), name="content_editorialpage_draft"), - path("/revisions//preview/", self.admin_site.admin_view(self.preview_view), name="content_editorialpage_preview"), - path("/revisions//publish/", self.admin_site.admin_view(self.publish_view), name="content_editorialpage_publish"), - path("/revisions//restore/", self.admin_site.admin_view(self.restore_view), name="content_editorialpage_restore"), - ] - return urls + super().get_urls() - - def change_view(self, request, object_id, form_url="", extra_context=None): - if request.method != "GET": - return HttpResponseNotAllowed(["GET"]) - if not self.has_view_permission(request): - raise PermissionDenied - page = get_object_or_404(EditorialPage, pk=object_id) - return self._render(request, "admin/content/dashboard.html", title=f"{page.slug} content", target=page, is_page=True, - drafts=page.revisions.filter(status="draft").order_by("-number")[:10], - history=page.revisions.filter(status="published").order_by("-number")[:10], - can_edit=self.has_change_permission(request), - can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"), - can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content")) - - def draft_view(self, request, object_id): - require_permission(request.user, "change_content_draft") - page = get_object_or_404(EditorialPage, pk=object_id) - base = page.revisions.order_by("-number").first() - stale = request.method == "POST" and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "") - keys = ("hero", "featured_events", "featured_posts", "participation") if page.slug == "home" else ABOUT_KEYS if page.slug == "about" else () - existing = {section.key: section for section in base.sections.all()} if base else {} - bound = request.method == "POST" - form = PageDraftForm(request.POST if bound else None, initial={field: getattr(base, field) for field in ("title", "intro", "seo_title", "seo_description", "seo_image")} if base else None) - sections = [] - valid = form.is_valid() if bound else False - for key in keys: - section = existing.get(key) - section_form = SectionForm(request.POST if bound else None, key=key, slug=page.slug, section=section, prefix=f"section_{key}") - entry_set = None - if page.slug == "about" and key not in {"identity", "contact"}: - entry_set = EntryFormSet(request.POST if bound else None, prefix=f"entries_{key}", initial=section.entries if section else None) - if bound: - valid = section_form.is_valid() and valid - if entry_set: - valid = entry_set.is_valid() and valid - sections.append((key, section_form, entry_set)) - if bound and stale: - form.add_error(None, "A newer revision exists. Reload the draft before editing.") - if bound and valid and not stale: - payload = [] - try: - for key, section_form, entry_set in sections: - entries = [ - {"title": item["title"], "text": item["text"]} - for item in (entry_set.cleaned_data if entry_set else []) - if item and not item.get("DELETE") and item.get("title") - ] - if page.slug == "about" and key not in {"identity", "contact"} and not section_form.cleaned_data["heading"] and not entries: - continue - payload.append(section_form.payload(entries)) - if page.slug == "about": - contact = next(item for item in payload if item["key"] == "contact") - contact["position"] = max((item["position"] for item in payload if item["key"] != "contact"), default=0) + 1 - revision = save_page_draft(request.user, page, form.payload(), payload, base_revision=base) - except (ValidationError, PublishConflict) as exc: - form.add_error(None, str(exc)) - else: - messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.") - return redirect("admin:content_editorialpage_change", page.pk) - return self._render(request, "admin/content/draft.html", title=f"Edit {page.slug} draft", target=page, - is_page=True, form=form, sections=sections, base=base) - - def preview_view(self, request, object_id, revision_id): - require_permission(request.user, "preview_content_draft") - revision = get_object_or_404(PageRevision.objects.prefetch_related("sections"), pk=revision_id, page_id=object_id, status="draft") - response = self._render(request, "admin/content/preview.html", title=f"Draft {revision.number} preview", revision=revision, - is_page=True, sections=revision.sections.order_by("position")) - response["Cache-Control"] = "private, no-store" - response["X-Robots-Tag"] = "noindex, nofollow" - return response - - def publish_view(self, request, object_id, revision_id): - require_permission(request.user, "publish_content") - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - revision = get_object_or_404(PageRevision, pk=revision_id, page_id=object_id) - try: - published = publish_page(request.user, revision, expected_published_id=_expected_id(request)) - except (ValidationError, PublishConflict) as exc: - return HttpResponseBadRequest(str(exc)) - messages.success(request, f"Published revision {published.number}.") - return redirect("admin:content_editorialpage_change", object_id) - - def restore_view(self, request, object_id, revision_id): - require_permission(request.user, "restore_content") - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - page = get_object_or_404(EditorialPage, pk=object_id) - source = get_object_or_404(PageRevision, pk=revision_id, page=page) - try: - restored = restore_page(request.user, page, source, expected_published_id=_expected_id(request)) - except (ValidationError, PublishConflict) as exc: - return HttpResponseBadRequest(str(exc)) - messages.success(request, f"Restored as revision {restored.number}.") - return redirect("admin:content_editorialpage_change", object_id) +@admin.register(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("/draft/", self.admin_site.admin_view(self.draft_view), name="content_siteidentity_draft"), - path("/revisions//preview/", self.admin_site.admin_view(self.preview_view), name="content_siteidentity_preview"), - path("/revisions//publish/", self.admin_site.admin_view(self.publish_view), name="content_siteidentity_publish"), - path("/revisions//restore/", self.admin_site.admin_view(self.restore_view), name="content_siteidentity_restore"), - ] - return urls + super().get_urls() - - def change_view(self, request, object_id, form_url="", extra_context=None): - if request.method != "GET": - return HttpResponseNotAllowed(["GET"]) - if not self.has_view_permission(request): - raise PermissionDenied - site = get_object_or_404(SiteIdentity, pk=object_id) - return self._render(request, "admin/content/dashboard.html", title="Site identity content", target=site, is_page=False, - drafts=site.revisions.filter(status="draft").order_by("-number")[:10], - history=site.revisions.filter(status="published").order_by("-number")[:10], - can_edit=self.has_change_permission(request), - can_publish=request.user.is_superuser or request.user.has_perm("content.publish_content"), - can_restore=request.user.is_superuser or request.user.has_perm("content.restore_content")) - - def draft_view(self, request, object_id): - require_permission(request.user, "change_content_draft") - site = get_object_or_404(SiteIdentity, pk=object_id) - base = site.revisions.order_by("-number").first() - bound = request.method == "POST" - stale = bound and request.POST.get("base_revision_id", "") != (str(base.pk) if base else "") - form = SiteDraftForm(request.POST if bound else None, revision=base) - if bound and stale: - form.add_error(None, "A newer revision exists. Reload the draft before editing.") - if bound and form.is_valid() and not stale: - try: - revision = save_site_draft(request.user, site, form.payload(), base_revision=base) - except (ValidationError, PublishConflict) as exc: - form.add_error(None, str(exc)) - else: - messages.success(request, f"Draft revision {revision.number} saved. Review its preview before publishing.") - return redirect("admin:content_siteidentity_change", site.pk) - return self._render(request, "admin/content/draft.html", title="Edit site identity draft", target=site, - is_page=False, form=form, base=base, contact_kinds=CONTACT_KINDS) - - def preview_view(self, request, object_id, revision_id): - require_permission(request.user, "preview_content_draft") - revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id, status="draft") - response = self._render(request, "admin/content/preview.html", title=f"Site draft {revision.number} preview", revision=revision, is_page=False) - response["Cache-Control"] = "private, no-store" - response["X-Robots-Tag"] = "noindex, nofollow" - return response - - def publish_view(self, request, object_id, revision_id): - require_permission(request.user, "publish_content") - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - revision = get_object_or_404(SiteRevision, pk=revision_id, site_id=object_id) - try: - published = publish_site(request.user, revision, expected_published_id=_expected_id(request)) - except (ValidationError, PublishConflict) as exc: - return HttpResponseBadRequest(str(exc)) - messages.success(request, f"Published revision {published.number}.") - return redirect("admin:content_siteidentity_change", object_id) - - def restore_view(self, request, object_id, revision_id): - require_permission(request.user, "restore_content") - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - site = get_object_or_404(SiteIdentity, pk=object_id) - source = get_object_or_404(SiteRevision, pk=revision_id, site=site) - try: - restored = restore_site(request.user, site, source, expected_published_id=_expected_id(request)) - except (ValidationError, PublishConflict) as exc: - return HttpResponseBadRequest(str(exc)) - messages.success(request, f"Restored as revision {restored.number}.") - return redirect("admin:content_siteidentity_change", object_id) - - -@admin.register(MediaAsset) -class MediaAssetAdmin(ContentAdmin): - list_display = ("__str__", "width", "height", "bytes", "approved", "created_at") - fields = ("file", "alt", "source", "license", "approved") + def has_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")}), + ) diff --git a/apps/content/current.py b/apps/content/current.py new file mode 100644 index 0000000..b18eabd --- /dev/null +++ b/apps/content/current.py @@ -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() diff --git a/apps/content/forms.py b/apps/content/forms.py deleted file mode 100644 index fe36271..0000000 --- a/apps/content/forms.py +++ /dev/null @@ -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 diff --git a/apps/content/migrations/0004_direct_edit_content.py b/apps/content/migrations/0004_direct_edit_content.py new file mode 100644 index 0000000..530143c --- /dev/null +++ b/apps/content/migrations/0004_direct_edit_content.py @@ -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), + ] diff --git a/apps/content/migrations/0005_seed_current_website_copy.py b/apps/content/migrations/0005_seed_current_website_copy.py new file mode 100644 index 0000000..0ad0b5d --- /dev/null +++ b/apps/content/migrations/0005_seed_current_website_copy.py @@ -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)] diff --git a/apps/content/models.py b/apps/content/models.py index f9e1d82..6c26238 100644 --- a/apps/content/models.py +++ b/apps/content/models.py @@ -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 diff --git a/apps/content/services.py b/apps/content/services.py deleted file mode 100644 index 29fb821..0000000 --- a/apps/content/services.py +++ /dev/null @@ -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 diff --git a/apps/content/templates/admin/content/bootstrap.html b/apps/content/templates/admin/content/bootstrap.html deleted file mode 100644 index c7e510c..0000000 --- a/apps/content/templates/admin/content/bootstrap.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "admin/base_site.html" %} -{% block content %} -

{{ title }}

-

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

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

{{ title }}

-

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

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

Draft revisions

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

No drafts yet.

{% endfor %} -
-
-

Published history

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

No published revision yet.

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

{{ title }}

-

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

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

Cards are loaded from the existing event or blog API.

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

Contact details come from the site identity.

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

Entries

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

{{ title }}

-

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

- {% if is_page %} -

{{ revision.title }}

{{ revision.intro }}

-

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

- {% for section in sections %} -
-

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

-

{{ section.body|linebreaksbr }}

{{ section.intro }}

- {% if section.image %}

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

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

Listing link: {{ section.all_link_label }}

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

{{ entry.title }}

{{ entry.text|linebreaksbr }}

{% endfor %} -
- {% endfor %} - {% else %} -

{{ revision.official_name }}

-

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

-

{{ revision.description|linebreaksbr }}

- {% if revision.logo %}

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

{% endif %} -

Navigation

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

Contacts

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

{{ revision.footer_copy }}

- {% endif %} -
-{% endblock %} diff --git a/apps/content/tests/integration/test_admin_workflow.py b/apps/content/tests/integration/test_admin_workflow.py index 263c400..7d32c29 100644 --- a/apps/content/tests/integration/test_admin_workflow.py +++ b/apps/content/tests/integration/test_admin_workflow.py @@ -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 == "وبلاگ" diff --git a/apps/content/tests/integration/test_services.py b/apps/content/tests/integration/test_services.py deleted file mode 100644 index 06bfa02..0000000 --- a/apps/content/tests/integration/test_services.py +++ /dev/null @@ -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() diff --git a/config/services/unfold.py b/config/services/unfold.py index 7d73dc1..fe182c8 100644 --- a/config/services/unfold.py +++ b/config/services/unfold.py @@ -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/"), }, ], },