Add validated editorial content schema
This commit is contained in:
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.")
|
||||
Reference in New Issue
Block a user