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