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

241 lines
13 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 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):
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")]
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."})
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 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")
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)
validators.text(self.body, 1200 if slug == "about" else 600)
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.")
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")]