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

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