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",