307 lines
17 KiB
Python
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
|