Files
guilan-ace-backend/apps/content/models.py

307 lines
17 KiB
Python

from pathlib import Path
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q
from PIL import Image, UnidentifiedImageError
from . import validators
PAGE_KINDS = {"home": "sections", "about": "sections", "events": "listing_intro", "blog": "listing_intro"}
HOME_KEYS = ("hero", "featured_events", "featured_posts", "participation")
ABOUT_KEYS = ("identity", "mission", "activities", "values", "committees", "affiliations", "faq", "contact")
def media_path(instance, filename):
return f"content/{uuid4().hex}{Path(filename).suffix.lower()}"
class MediaAsset(models.Model):
file = models.ImageField(upload_to=media_path)
width = models.PositiveIntegerField(default=0)
height = models.PositiveIntegerField(default=0)
bytes = models.PositiveIntegerField(default=0)
alt = models.CharField(max_length=180)
source = models.CharField(max_length=300)
license = models.CharField(max_length=200)
approved = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.file.name or f"Media asset {self.pk}"
def clean(self):
super().clean()
for field in ("alt", "source", "license"):
validators.text(getattr(self, field), self._meta.get_field(field).max_length, required=True)
if not self.file:
return
size = self.file.size
if size > 5 * 1024 * 1024:
raise ValidationError({"file": "CMS images must be at most 5 MiB."})
try:
self.file.seek(0)
with Image.open(self.file) as image:
suffix = Path(self.file.name).suffix.lower()
expected = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(suffix)
if image.format != expected:
raise ValueError("Unsupported image signature.")
image.verify()
self.file.seek(0)
with Image.open(self.file) as image:
self.width, self.height = image.size
if not 1 <= self.width <= 4096 or not 1 <= self.height <= 4096:
raise ValueError("Image dimensions must be between 1 and 4096 pixels.")
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise ValidationError({"file": "Invalid CMS image."}) from exc
finally:
self.file.seek(0)
self.bytes = size
def save(self, *args, **kwargs):
if self.pk:
previous = type(self).objects.get(pk=self.pk)
protected = ("file", "alt", "source", "license", "width", "height", "bytes")
if previous.approved and (not self.approved or any(getattr(self, field) != getattr(previous, field) for field in protected)):
raise ValidationError("Approved media is immutable.")
self.full_clean()
return super().save(*args, **kwargs)
class SiteIdentity(models.Model):
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
published_revision = models.ForeignKey("SiteRevision", null=True, blank=True, on_delete=models.PROTECT, related_name="published_for")
class Meta:
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_single_site")]
permissions = [
("change_content_draft", "Can change content drafts"),
("preview_content_draft", "Can preview content drafts"),
("publish_content", "Can publish content"),
("restore_content", "Can restore published content"),
]
def __str__(self):
return "Association site identity"
def clean(self):
if self.published_revision_id and self.published_revision.site_id != self.pk:
raise ValidationError("Published revision belongs to another site.")
class SiteRevision(models.Model):
site = models.ForeignKey(SiteIdentity, on_delete=models.PROTECT, related_name="revisions")
number = models.PositiveIntegerField(validators=[MinValueValidator(1)])
status = models.CharField(max_length=10, choices=[("draft", "Draft"), ("published", "Published")], default="draft")
official_name = models.CharField(max_length=180)
short_name = models.CharField(max_length=80)
faculty_subline = models.CharField(max_length=140, blank=True)
description = models.CharField(max_length=500, blank=True)
logo = models.ForeignKey(MediaAsset, null=True, blank=True, on_delete=models.PROTECT, related_name="site_logos")
logo_alt = models.CharField(max_length=180, blank=True)
navigation = models.JSONField(default=list, blank=True, validators=[validators.navigation])
contacts = models.JSONField(default=list, blank=True, validators=[validators.contacts])
footer_copy = models.CharField(max_length=300, blank=True)
seo_title = models.CharField(max_length=120, blank=True)
seo_description = models.CharField(max_length=300, blank=True)
seo_image = models.ForeignKey(MediaAsset, null=True, blank=True, on_delete=models.PROTECT, related_name="site_og_images")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name="content_site_drafts")
published_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name="content_site_publications")
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True, blank=True)
source_seed_key = models.CharField(max_length=120, blank=True)
source_seed_version = models.CharField(max_length=40, blank=True)
content_checksum = models.CharField(max_length=64, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["site", "number"], name="content_site_revision_number")]
def clean(self):
super().clean()
validators.navigation(self.navigation)
validators.contacts(self.contacts)
for field in ("official_name", "short_name", "faculty_subline", "description", "logo_alt", "footer_copy", "seo_title", "seo_description"):
validators.text(getattr(self, field), self._meta.get_field(field).max_length, required=field in {"official_name", "short_name"})
if self.logo_id and not self.logo_alt.strip():
raise ValidationError({"logo_alt": "Logo alt text is required."})
if not self.logo_id and self.logo_alt:
raise ValidationError({"logo_alt": "Alt text requires a logo."})
def save(self, *args, **kwargs):
if self.pk:
previous = type(self).objects.get(pk=self.pk)
protected = ("site_id", "number", "official_name", "short_name", "faculty_subline", "description", "logo_id", "logo_alt", "navigation", "contacts", "footer_copy", "seo_title", "seo_description", "seo_image_id", "created_by_id", "source_seed_key", "source_seed_version", "content_checksum")
if any(getattr(self, field) != getattr(previous, field) for field in protected):
raise ValidationError("Site revision content is immutable.")
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
raise ValidationError("Published site revision metadata is immutable.")
return super().save(*args, **kwargs)
class EditorialPage(models.Model):
slug = models.CharField(max_length=20, unique=True)
page_kind = models.CharField(max_length=20, choices=[("sections", "Sections"), ("listing_intro", "Listing intro")])
published_revision = models.ForeignKey("PageRevision", null=True, blank=True, on_delete=models.PROTECT, related_name="published_for")
def __str__(self):
return self.slug
def clean(self):
super().clean()
if self.slug not in PAGE_KINDS or self.page_kind != PAGE_KINDS[self.slug]:
raise ValidationError("Page slug and kind must match the fixed registry.")
if self.pk and type(self).objects.filter(pk=self.pk).exclude(slug=self.slug).exists():
raise ValidationError({"slug": "Page slug is immutable."})
if self.published_revision_id and self.published_revision.page_id != self.pk:
raise ValidationError("Published revision belongs to another page.")
class PageRevision(models.Model):
page = models.ForeignKey(EditorialPage, on_delete=models.PROTECT, related_name="revisions")
number = models.PositiveIntegerField(validators=[MinValueValidator(1)])
status = models.CharField(max_length=10, choices=[("draft", "Draft"), ("published", "Published")], default="draft")
title = models.CharField(max_length=180)
intro = models.CharField(max_length=600, blank=True)
seo_title = models.CharField(max_length=120)
seo_description = models.CharField(max_length=300)
seo_image = models.ForeignKey(MediaAsset, null=True, blank=True, on_delete=models.PROTECT, related_name="page_og_images")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name="content_page_drafts")
published_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name="content_page_publications")
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True, blank=True)
source_seed_key = models.CharField(max_length=120, blank=True)
source_seed_version = models.CharField(max_length=40, blank=True)
content_checksum = models.CharField(max_length=64, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["page", "number"], name="content_page_revision_number")]
def clean(self):
super().clean()
for field in ("title", "intro", "seo_title", "seo_description"):
validators.text(getattr(self, field), self._meta.get_field(field).max_length, required=field != "intro")
def save(self, *args, **kwargs):
if self.pk:
previous = type(self).objects.get(pk=self.pk)
protected = ("page_id", "number", "title", "intro", "seo_title", "seo_description", "seo_image_id", "created_by_id", "source_seed_key", "source_seed_version")
if any(getattr(self, field) != getattr(previous, field) for field in protected):
raise ValidationError("Page revision content is immutable.")
if self.content_checksum != previous.content_checksum and previous.content_checksum:
raise ValidationError("Page revision checksum is immutable.")
if previous.status == "published" and (self.status != previous.status or self.published_by_id != previous.published_by_id or self.published_at != previous.published_at):
raise ValidationError("Published page revision metadata is immutable.")
return super().save(*args, **kwargs)
class PageSection(models.Model):
revision = models.ForeignKey(PageRevision, on_delete=models.CASCADE, related_name="sections")
key = models.CharField(max_length=30)
section_type = models.CharField(max_length=30)
position = models.PositiveSmallIntegerField()
visible = models.BooleanField(default=True)
heading = models.CharField(max_length=120)
body = models.TextField(blank=True)
intro = models.CharField(max_length=300, blank=True)
all_link_label = models.CharField(max_length=40, blank=True)
primary_action = models.JSONField(null=True, blank=True)
secondary_action = models.JSONField(null=True, blank=True)
entries = models.JSONField(default=list, blank=True)
image = models.ForeignKey(MediaAsset, null=True, blank=True, on_delete=models.PROTECT, related_name="sections")
image_alt = models.CharField(max_length=180, blank=True)
class Meta:
ordering = ["position"]
constraints = [
models.UniqueConstraint(fields=["revision", "key"], name="content_section_key"),
models.UniqueConstraint(fields=["revision", "position"], name="content_section_position"),
]
def clean(self):
super().clean()
if not self.revision_id:
return
slug = self.revision.page.slug
allowed = HOME_KEYS if slug == "home" else ABOUT_KEYS if slug == "about" else ()
if self.key not in allowed or self.section_type != self.key:
raise ValidationError("Section key/type is not approved for this page.")
if slug == "home" and (self.position != HOME_KEYS.index(self.key) or not self.visible):
raise ValidationError("Home sections have fixed visible positions.")
if self.key == "identity" and (self.position != 0 or not self.visible):
raise ValidationError("About identity must be first and visible.")
if self.key == "contact" and not self.visible:
raise ValidationError("About contact must be visible.")
validators.text(self.heading, 120 if self.key in {"hero", "identity"} else 90, required=True)
body_limit = 500 if self.key == "hero" else 600 if self.key in {"participation", "identity"} else 1200
validators.text(self.body, body_limit)
validators.text(self.intro, 300)
validators.text(self.all_link_label, 40)
validators.text(self.image_alt, 180)
if self.image_id and not self.image_alt.strip():
raise ValidationError({"image_alt": "Image alt text is required."})
if not self.image_id and self.image_alt:
raise ValidationError({"image_alt": "Alt text requires an image."})
if self.image_id and self.key not in {"hero", "participation"}:
raise ValidationError("This section does not accept an image.")
if self.key in {"featured_events", "featured_posts"}:
if self.body or self.entries or not self.all_link_label.strip():
raise ValidationError("Featured sections need a listing label and cannot contain body or entries.")
elif self.all_link_label:
raise ValidationError("Listing label is only valid for featured sections.")
if self.key == "contact" and (self.body or self.intro or self.entries):
raise ValidationError("Contacts come from the site identity.")
if self.key in {"identity", "contact"} and self.image_id:
raise ValidationError("This section cannot contain an image.")
if self.key in {"hero", "participation"}:
if self.primary_action is not None:
validators.action(self.primary_action)
if self.secondary_action is not None:
validators.action(self.secondary_action)
elif self.primary_action is not None or self.secondary_action is not None:
raise ValidationError("Actions are not allowed on this section.")
validators.section_entries(self.entries)
if self.entries and self.key not in {"mission", "activities", "values", "committees", "affiliations", "faq"}:
raise ValidationError("Entries are not allowed on this section.")
if self.body and self.entries:
raise ValidationError("Use body or entries, not both.")
def save(self, *args, **kwargs):
if self.pk:
raise ValidationError("Page sections are immutable; create a new revision.")
if self.revision.status != "draft":
raise ValidationError("Sections can only be added to a draft revision.")
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
raise ValidationError("Page sections are immutable; create a new revision.")
class PublicationAudit(models.Model):
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
site = models.ForeignKey(SiteIdentity, null=True, blank=True, on_delete=models.PROTECT)
page = models.ForeignKey(EditorialPage, null=True, blank=True, on_delete=models.PROTECT)
from_revision = models.PositiveIntegerField(null=True, blank=True)
to_revision = models.PositiveIntegerField()
operation = models.CharField(max_length=20, choices=[("create_draft", "Create draft"), ("update_draft", "Update draft"), ("publish", "Publish"), ("restore", "Restore")])
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.CheckConstraint(condition=(Q(site__isnull=False, page__isnull=True) | Q(site__isnull=True, page__isnull=False)), name="content_audit_one_target")]
def save(self, *args, **kwargs):
if self.pk:
raise ValidationError("Publication audit records are append only.")
return super().save(*args, **kwargs)
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