Add validated editorial content schema

This commit is contained in:
2026-09-27 09:26:34 +03:30
parent 3922dbf673
commit 3bc9de2367
10 changed files with 605 additions and 0 deletions

1
apps/content/__init__.py Normal file
View File

@@ -0,0 +1 @@

6
apps/content/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.content"

View 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'),
),
]

View File

240
apps/content/models.py Normal file
View 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")]

View File

@@ -0,0 +1 @@

View 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()

View 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.")

View File

@@ -35,6 +35,7 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [ LOCAL_APPS = [
"core", "core",
"apps.content",
"apps.users", "apps.users",
"apps.notifications", "apps.notifications",
"apps.blog", "apps.blog",