83 lines
3.5 KiB
Python
83 lines
3.5 KiB
Python
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.")
|