Includes atomic migrations, per-rule reminder semantics, first-run helper script and the note on the undecided UI chat idea. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
110 lines
3.2 KiB
Python
110 lines
3.2 KiB
Python
import re
|
|
from dataclasses import dataclass
|
|
from typing import Awaitable, Callable
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from ..constants import FLAG_OFF, FLAG_ON
|
|
|
|
URL_RE = re.compile(r"^https?://[^\s/]+")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Field:
|
|
key: str
|
|
label: str
|
|
kind: str = "text"
|
|
required: bool = False
|
|
secret: bool = False
|
|
placeholder: str = ""
|
|
default: str = ""
|
|
choices: tuple[tuple[str, str], ...] = ()
|
|
hint: str = ""
|
|
transform: Callable[[str], str] | None = None
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.key.replace(".", "_")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Action:
|
|
label: str
|
|
path: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Section:
|
|
slug: str
|
|
title: str
|
|
fields: tuple[Field, ...]
|
|
validate: Callable[[dict], list[str]]
|
|
test: Callable[[dict], Awaitable[str]] | None = None
|
|
optional: bool = False
|
|
enabled_key: str | None = None
|
|
actions: tuple[Action, ...] = ()
|
|
context_keys: tuple[str, ...] = ()
|
|
notice: str = ""
|
|
extra_template: str = ""
|
|
extra_context: Callable[[object], dict] | None = None
|
|
|
|
|
|
def section_done(store, section: Section) -> bool:
|
|
if section.enabled_key:
|
|
return store.get(section.enabled_key) == FLAG_ON
|
|
return all(store.get(f.key) for f in section.fields if f.required)
|
|
|
|
|
|
def checkbox_default(field: Field) -> str:
|
|
return field.default or FLAG_ON
|
|
|
|
|
|
def collect(form, section: Section) -> dict[str, str | None]:
|
|
submitted: dict[str, str | None] = {}
|
|
for field in section.fields:
|
|
if field.kind == "checkbox":
|
|
submitted[field.key] = FLAG_ON if form.get(field.name) else FLAG_OFF
|
|
continue
|
|
raw = str(form.get(field.name, "")).strip()
|
|
if field.transform is not None:
|
|
raw = field.transform(raw)
|
|
submitted[field.key] = (raw or None) if field.secret else raw
|
|
return submitted
|
|
|
|
|
|
def effective(store, submitted: dict[str, str | None]) -> dict[str, str]:
|
|
return {key: (value if value is not None else store.get(key) or "") for key, value in submitted.items()}
|
|
|
|
|
|
def check(section: Section, values: dict[str, str]) -> list[str]:
|
|
missing = [f"{f.label} fehlt." for f in section.fields if f.required and not values.get(f.key)]
|
|
return missing or section.validate(values)
|
|
|
|
|
|
def form_values(section: Section, values: dict[str, str]) -> dict:
|
|
shown = {}
|
|
for field in section.fields:
|
|
if field.secret:
|
|
continue
|
|
if field.kind == "checkbox":
|
|
shown[field.name] = values.get(field.key, checkbox_default(field)) == FLAG_ON
|
|
else:
|
|
shown[field.name] = values.get(field.key, field.default)
|
|
return shown
|
|
|
|
|
|
def stored_values(store, section: Section) -> dict[str, str]:
|
|
return {f.key: store.get(f.key, checkbox_default(f) if f.kind == "checkbox" else f.default) or ""
|
|
for f in section.fields if not f.secret}
|
|
|
|
|
|
def secret_flags(store, section: Section) -> dict[str, bool]:
|
|
return {f.name: store.is_set(f.key) for f in section.fields if f.secret}
|
|
|
|
|
|
def find_section(sections: dict[str, Section], slug: str) -> Section:
|
|
section = sections.get(slug)
|
|
if section is None:
|
|
raise HTTPException(404, "Unbekannter Bereich")
|
|
return section
|