Add validated editorial content schema
This commit is contained in:
1
apps/content/__init__.py
Normal file
1
apps/content/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
apps/content/apps.py
Normal file
6
apps/content/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.content"
|
||||
168
apps/content/migrations/0001_initial.py
Normal file
168
apps/content/migrations/0001_initial.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
apps/content/migrations/__init__.py
Normal file
0
apps/content/migrations/__init__.py
Normal file
240
apps/content/models.py
Normal file
240
apps/content/models.py
Normal file
@@ -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")]
|
||||
1
apps/content/tests/__init__.py
Normal file
1
apps/content/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
apps/content/tests/integration/__init__.py
Normal file
0
apps/content/tests/integration/__init__.py
Normal file
106
apps/content/tests/integration/test_models.py
Normal file
106
apps/content/tests/integration/test_models.py
Normal file
@@ -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="<script>alert(1)</script>", 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="<img src=x onerror=alert(1)>")
|
||||
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": "<script>alert(1)</script>"}])
|
||||
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()
|
||||
82
apps/content/validators.py
Normal file
82
apps/content/validators.py
Normal file
@@ -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.")
|
||||
@@ -35,6 +35,7 @@ THIRD_PARTY_APPS = [
|
||||
|
||||
LOCAL_APPS = [
|
||||
"core",
|
||||
"apps.content",
|
||||
"apps.users",
|
||||
"apps.notifications",
|
||||
"apps.blog",
|
||||
|
||||
Reference in New Issue
Block a user