diff --git a/apps/content/__init__.py b/apps/content/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/apps/content/__init__.py
@@ -0,0 +1 @@
+
diff --git a/apps/content/apps.py b/apps/content/apps.py
new file mode 100644
index 0000000..27bc66d
--- /dev/null
+++ b/apps/content/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ContentConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.content"
diff --git a/apps/content/migrations/0001_initial.py b/apps/content/migrations/0001_initial.py
new file mode 100644
index 0000000..ec140ba
--- /dev/null
+++ b/apps/content/migrations/0001_initial.py
@@ -0,0 +1,168 @@
+# Generated by Django 5.2.5 on 2026-09-27 05:53
+
+import apps.content.models
+import apps.content.validators
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='EditorialPage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.CharField(max_length=20, unique=True)),
+ ('page_kind', models.CharField(choices=[('sections', 'Sections'), ('listing_intro', 'Listing intro')], max_length=20)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='MediaAsset',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('file', models.ImageField(upload_to=apps.content.models.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)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='SiteIdentity',
+ fields=[
+ ('id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='PageRevision',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('number', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
+ ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)),
+ ('title', models.CharField(max_length=180)),
+ ('intro', models.CharField(blank=True, max_length=600)),
+ ('seo_title', models.CharField(max_length=120)),
+ ('seo_description', models.CharField(max_length=300)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('published_at', models.DateTimeField(blank=True, null=True)),
+ ('source_seed_key', models.CharField(blank=True, max_length=120)),
+ ('source_seed_version', models.CharField(blank=True, max_length=40)),
+ ('content_checksum', models.CharField(blank=True, max_length=64)),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='content_page_drafts', to=settings.AUTH_USER_MODEL)),
+ ('page', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='content.editorialpage')),
+ ('published_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='content_page_publications', to=settings.AUTH_USER_MODEL)),
+ ('seo_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='page_og_images', to='content.mediaasset')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='editorialpage',
+ name='published_revision',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='published_for', to='content.pagerevision'),
+ ),
+ migrations.CreateModel(
+ name='PageSection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('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(blank=True, max_length=300)),
+ ('all_link_label', models.CharField(blank=True, max_length=40)),
+ ('primary_action', models.JSONField(blank=True, null=True)),
+ ('secondary_action', models.JSONField(blank=True, null=True)),
+ ('entries', models.JSONField(blank=True, default=list)),
+ ('image_alt', models.CharField(blank=True, max_length=180)),
+ ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sections', to='content.mediaasset')),
+ ('revision', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='content.pagerevision')),
+ ],
+ options={
+ 'ordering': ['position'],
+ },
+ ),
+ migrations.CreateModel(
+ name='PublicationAudit',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('from_revision', models.PositiveIntegerField(blank=True, null=True)),
+ ('to_revision', models.PositiveIntegerField()),
+ ('operation', models.CharField(choices=[('create_draft', 'Create draft'), ('update_draft', 'Update draft'), ('publish', 'Publish'), ('restore', 'Restore')], max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('actor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ ('page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='content.editorialpage')),
+ ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='content.siteidentity')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='SiteRevision',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('number', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
+ ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)),
+ ('official_name', models.CharField(max_length=180)),
+ ('short_name', models.CharField(max_length=80)),
+ ('faculty_subline', models.CharField(blank=True, max_length=140)),
+ ('description', models.CharField(blank=True, max_length=500)),
+ ('logo_alt', models.CharField(blank=True, max_length=180)),
+ ('navigation', models.JSONField(blank=True, default=list, validators=[apps.content.validators.navigation])),
+ ('contacts', models.JSONField(blank=True, default=list, validators=[apps.content.validators.contacts])),
+ ('footer_copy', models.CharField(blank=True, max_length=300)),
+ ('seo_title', models.CharField(blank=True, max_length=120)),
+ ('seo_description', models.CharField(blank=True, max_length=300)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('published_at', models.DateTimeField(blank=True, null=True)),
+ ('source_seed_key', models.CharField(blank=True, max_length=120)),
+ ('source_seed_version', models.CharField(blank=True, max_length=40)),
+ ('content_checksum', models.CharField(blank=True, max_length=64)),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='content_site_drafts', to=settings.AUTH_USER_MODEL)),
+ ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_logos', to='content.mediaasset')),
+ ('published_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='content_site_publications', to=settings.AUTH_USER_MODEL)),
+ ('seo_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_og_images', to='content.mediaasset')),
+ ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='content.siteidentity')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='siteidentity',
+ name='published_revision',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='published_for', to='content.siterevision'),
+ ),
+ migrations.AddConstraint(
+ model_name='pagerevision',
+ constraint=models.UniqueConstraint(fields=('page', 'number'), name='content_page_revision_number'),
+ ),
+ migrations.AddConstraint(
+ model_name='pagesection',
+ constraint=models.UniqueConstraint(fields=('revision', 'key'), name='content_section_key'),
+ ),
+ migrations.AddConstraint(
+ model_name='pagesection',
+ constraint=models.UniqueConstraint(fields=('revision', 'position'), name='content_section_position'),
+ ),
+ migrations.AddConstraint(
+ model_name='publicationaudit',
+ constraint=models.CheckConstraint(condition=models.Q(models.Q(('page__isnull', True), ('site__isnull', False)), models.Q(('page__isnull', False), ('site__isnull', True)), _connector='OR'), name='content_audit_one_target'),
+ ),
+ migrations.AddConstraint(
+ model_name='siterevision',
+ constraint=models.UniqueConstraint(fields=('site', 'number'), name='content_site_revision_number'),
+ ),
+ migrations.AddConstraint(
+ model_name='siteidentity',
+ constraint=models.CheckConstraint(condition=models.Q(('id', 1)), name='content_single_site'),
+ ),
+ ]
diff --git a/apps/content/migrations/__init__.py b/apps/content/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/content/models.py b/apps/content/models.py
new file mode 100644
index 0000000..10b4fa8
--- /dev/null
+++ b/apps/content/models.py
@@ -0,0 +1,240 @@
+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")]
diff --git a/apps/content/tests/__init__.py b/apps/content/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/apps/content/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/apps/content/tests/integration/__init__.py b/apps/content/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/content/tests/integration/test_models.py b/apps/content/tests/integration/test_models.py
new file mode 100644
index 0000000..5bbb4d2
--- /dev/null
+++ b/apps/content/tests/integration/test_models.py
@@ -0,0 +1,106 @@
+import io
+
+import pytest
+from django.core.exceptions import ValidationError
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.db import IntegrityError, transaction
+from PIL import Image
+
+from apps.content.models import EditorialPage, MediaAsset, PageRevision, PageSection, SiteIdentity, SiteRevision
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.integration]
+
+
+def test_single_site_and_fixed_page_slugs():
+ SiteIdentity.objects.create()
+ with pytest.raises(IntegrityError), transaction.atomic():
+ SiteIdentity.objects.create()
+
+ page = EditorialPage(slug="home", page_kind="sections")
+ page.full_clean()
+ page.save()
+ with pytest.raises(ValidationError):
+ EditorialPage(slug="other", page_kind="sections").full_clean()
+ with pytest.raises(ValidationError):
+ EditorialPage(slug="events", page_kind="sections").full_clean()
+
+
+def test_site_revision_rejects_unsafe_text_and_contact_links():
+ site = SiteIdentity.objects.create()
+ navigation = [{"route_id": route, "label": route, "position": index, "visible": True} for index, route in enumerate(("home", "about", "events", "blog"))]
+ revision = SiteRevision(site=site, number=1, official_name="", short_name="ACE", navigation=navigation)
+ with pytest.raises(ValidationError):
+ revision.full_clean()
+
+ revision.official_name = "Association"
+ revision.contacts = [{"kind": "telegram", "label": "Join", "url": "javascript:alert(1)", "position": 0, "visible": True}]
+ with pytest.raises(ValidationError):
+ revision.full_clean()
+ revision.contacts = [{"kind": ["telegram"], "label": "Join", "url": "https://t.me/example", "position": 0, "visible": True}]
+ with pytest.raises(ValidationError):
+ revision.full_clean()
+ revision.contacts = [{"kind": "telegram", "label": "Join", "url": "https://t.me/example", "position": 0, "visible": True}]
+ revision.full_clean()
+
+
+def test_section_registry_and_plain_text():
+ page = EditorialPage.objects.create(slug="home", page_kind="sections")
+ revision = PageRevision.objects.create(page=page, number=1, title="Home", seo_title="Home", seo_description="About us")
+ section = PageSection(revision=revision, key="hero", section_type="hero", position=0, heading="Welcome", body="
")
+ with pytest.raises(ValidationError):
+ section.full_clean()
+ section.body = "Plain introduction"
+ section.full_clean()
+ section.image_alt = "Orphan alt"
+ with pytest.raises(ValidationError):
+ section.full_clean()
+ section.image_alt = ""
+ section.primary_action = {"label": "Go", "route_id": "javascript:alert(1)"}
+ with pytest.raises(ValidationError):
+ section.full_clean()
+ section.primary_action = {"label": "Go", "route_id": ["about"]}
+ with pytest.raises(ValidationError):
+ section.full_clean()
+ with pytest.raises(ValidationError):
+ PageSection(revision=revision, key="mission", section_type="mission", position=1, heading="Mission").full_clean()
+
+
+def test_revision_numbers_and_section_keys_are_unique():
+ page = EditorialPage.objects.create(slug="about", page_kind="sections")
+ PageRevision.objects.create(page=page, number=1, title="About", seo_title="About", seo_description="Association")
+ with pytest.raises(IntegrityError), transaction.atomic():
+ PageRevision.objects.create(page=page, number=1, title="Again", seo_title="Again", seo_description="Association")
+
+
+def test_section_entries_reject_markup_and_excess():
+ page = EditorialPage.objects.create(slug="about", page_kind="sections")
+ revision = PageRevision.objects.create(page=page, number=1, title="About", seo_title="About", seo_description="Association")
+ section = PageSection(revision=revision, key="faq", section_type="faq", position=1, heading="Questions", entries=[{"title": "Question", "text": ""}])
+ with pytest.raises(ValidationError):
+ section.full_clean()
+ section.entries = [{"title": f"Question {index}", "text": "Answer"} for index in range(21)]
+ with pytest.raises(ValidationError):
+ section.full_clean()
+
+
+def test_media_rejects_invalid_signature_and_oversize(settings, tmp_path):
+ settings.MEDIA_ROOT = tmp_path
+ bad = MediaAsset(file=SimpleUploadedFile("fake.png", b"not an image", content_type="image/png"), alt="Logo", source="owner", license="owned")
+ with pytest.raises(ValidationError):
+ bad.full_clean()
+
+ buffer = io.BytesIO()
+ Image.new("RGB", (10, 10)).save(buffer, format="PNG")
+ good = MediaAsset(file=SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png"), alt="Association logo", source="owner", license="owned")
+ good.full_clean()
+ good.save()
+ assert good.width == 10 and good.height == 10
+
+ huge = MediaAsset(file=SimpleUploadedFile("huge.png", b"x" * (5 * 1024 * 1024 + 1), content_type="image/png"), alt="Huge", source="owner", license="owned")
+ with pytest.raises(ValidationError):
+ huge.full_clean()
+
+ mismatch = MediaAsset(file=SimpleUploadedFile("logo.jpg", buffer.getvalue(), content_type="image/jpeg"), alt="Logo", source="owner", license="owned")
+ with pytest.raises(ValidationError):
+ mismatch.full_clean()
diff --git a/apps/content/validators.py b/apps/content/validators.py
new file mode 100644
index 0000000..2bf9853
--- /dev/null
+++ b/apps/content/validators.py
@@ -0,0 +1,82 @@
+import re
+from urllib.parse import urlsplit
+
+from django.core.exceptions import ValidationError
+
+
+ROUTES = {"home", "about", "events", "blog"}
+CONTACT_KINDS = {"email", "phone", "telegram", "instagram", "website"}
+TAG_PATTERN = re.compile(r"<\s*/?\s*[a-zA-Z][^>]*>|&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", re.I)
+
+
+def plain_text(value):
+ if value and (TAG_PATTERN.search(value) or "\x00" in value):
+ raise ValidationError("HTML and character entities are not allowed in editorial text.")
+
+
+def text(value, limit, *, required=False):
+ if not isinstance(value, str) or (required and not value.strip()) or len(value) > limit:
+ raise ValidationError(f"Expected {'nonempty ' if required else ''}text of at most {limit} characters.")
+ plain_text(value)
+
+
+def _entries(value, allowed, required):
+ if not isinstance(value, list) or len(value) > 20:
+ raise ValidationError("Expected a list of at most 20 entries.")
+ seen_keys = set()
+ seen_positions = set()
+ for entry in value:
+ if not isinstance(entry, dict) or set(entry) != allowed:
+ raise ValidationError("Entry fields do not match the approved schema.")
+ key = entry.get(required)
+ position = entry.get("position")
+ if not isinstance(key, str) or key in seen_keys or type(position) is not int or position < 0 or position in seen_positions or type(entry.get("visible")) is not bool:
+ raise ValidationError("Entry IDs and positions must be unique and valid.")
+ seen_keys.add(key)
+ seen_positions.add(position)
+ text(entry["label"], 80, required=True)
+
+
+def navigation(value):
+ _entries(value, {"route_id", "label", "position", "visible"}, "route_id")
+ if {entry["route_id"] for entry in value} != ROUTES:
+ raise ValidationError("Navigation must contain the four fixed routes.")
+
+
+def contacts(value):
+ _entries(value, {"kind", "label", "url", "position", "visible"}, "kind")
+ for entry in value:
+ kind = entry["kind"]
+ url = entry["url"]
+ if kind not in CONTACT_KINDS or not isinstance(url, str) or len(url) > 500:
+ raise ValidationError("Invalid contact kind or URL.")
+ try:
+ parsed = urlsplit(url)
+ except ValueError as exc:
+ raise ValidationError("Invalid contact URL.") from exc
+ if kind == "email":
+ valid = parsed.scheme == "mailto" and bool(parsed.path) and "@" in parsed.path and not parsed.netloc
+ elif kind == "phone":
+ valid = parsed.scheme == "tel" and bool(re.fullmatch(r"\+?[0-9 -]{5,25}", parsed.path))
+ else:
+ valid = parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password
+ if not valid:
+ raise ValidationError("Contact URL must use the approved scheme for its kind.")
+
+
+def section_entries(value):
+ if not isinstance(value, list) or len(value) > 20:
+ raise ValidationError("Expected at most 20 section entries.")
+ for entry in value:
+ if not isinstance(entry, dict) or set(entry) != {"title", "text"}:
+ raise ValidationError("Section entries require title and text.")
+ text(entry["title"], 120, required=True)
+ text(entry["text"], 600, required=True)
+
+
+def action(value):
+ if not isinstance(value, dict) or set(value) != {"label", "route_id"}:
+ raise ValidationError("Action requires label and route_id.")
+ text(value["label"], 40, required=True)
+ if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}:
+ raise ValidationError("Action route is not approved.")
diff --git a/config/settings/base.py b/config/settings/base.py
index 70fd635..1abff79 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -35,6 +35,7 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [
"core",
+ "apps.content",
"apps.users",
"apps.notifications",
"apps.blog",