Files
govoplan-forms/src/govoplan_forms/backend/semantic_subjects.py
T
zemion b9132d076d
Module Package Release / publish-packages (push) Successful in 12s
feat: expose form semantic documentation subjects
2026-08-21 15:44:42 +02:00

504 lines
17 KiB
Python

from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
SemanticDocumentationBreadcrumb,
SemanticDocumentationSubjectAnchor,
SemanticDocumentationSubjectDescriptor,
SemanticDocumentationSubjectPage,
SemanticDocumentationSubjectQuery,
SemanticDocumentationSubjectReference,
SemanticDocumentationSubjectResolution,
semantic_documentation_fingerprint,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import definition_from_mapping
SUBJECT_KIND = "form_definition"
READ_SCOPE = "forms:definition:read"
_MAX_SUBJECTS = 20_000
@dataclass(frozen=True, slots=True)
class _IdentityState:
field_ids: Mapping[str, str]
section_ids: Mapping[tuple[str, str], str]
historical_field_ids: frozenset[str]
historical_section_ids: frozenset[str]
class FormsSemanticDocumentationSubjectProvider:
provider_id = "forms.semantic_subjects"
module_id = "forms"
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage:
if not _authorized(principal, request.tenant_id):
return SemanticDocumentationSubjectPage()
db = _session(session)
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
return SemanticDocumentationSubjectPage()
rows = (
db.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == request.tenant_id,
FormDefinitionRevision.superseded_at.is_(None),
)
.order_by(FormDefinitionRevision.form_key, FormDefinitionRevision.form_id)
.all()
)
subjects: list[SemanticDocumentationSubjectDescriptor] = []
for row in rows:
definition = definition_from_mapping(row.payload)
identities = _identity_state(db, row)
subjects.extend(_descriptors(definition, identities))
if len(subjects) > _MAX_SUBJECTS:
raise ValueError(
"Forms semantic subject limit exceeded; narrow the query."
)
query = request.query.casefold().strip()
if query:
subjects = [
item
for item in subjects
if query in _descriptor_search_text(item).casefold()
]
offset = _cursor_offset(request.cursor)
selected = tuple(subjects[offset : offset + request.limit])
next_offset = offset + len(selected)
has_more = next_offset < len(subjects)
return SemanticDocumentationSubjectPage(
subjects=selected,
next_cursor=str(next_offset) if has_more else None,
has_more=has_more,
)
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
if (
reference.module_id != self.module_id
or reference.subject_kind != SUBJECT_KIND
or not _authorized(principal, reference.tenant_id)
):
return None
db = _session(session)
row = (
db.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == reference.tenant_id,
FormDefinitionRevision.form_id == reference.subject_id,
FormDefinitionRevision.superseded_at.is_(None),
)
.one_or_none()
)
if row is None:
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code="form_missing",
)
definition = definition_from_mapping(row.payload)
identities = _identity_state(db, row)
descriptor = next(
(
item
for item in _descriptors(definition, identities)
if item.reference.stable_key == reference.stable_key
),
None,
)
if descriptor is None:
anchor = reference.anchor
reason = "subject_missing"
if anchor is not None and anchor.kind == "field":
reason = (
"field_deleted"
if anchor.id in identities.historical_field_ids
else "field_missing"
)
elif anchor is not None and anchor.kind == "section":
reason = (
"section_deleted"
if anchor.id in identities.historical_section_ids
else "section_missing"
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code=reason,
)
changed = any(
expected is not None and expected != actual
for expected, actual in (
(reference.observed_revision, descriptor.reference.observed_revision),
(
reference.observed_fingerprint,
descriptor.reference.observed_fingerprint,
),
)
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="changed" if changed else "available",
subject=descriptor,
)
def _descriptors(definition, identities: _IdentityState):
form_reference = _reference(
definition,
revision=definition.temporal.revision,
fingerprint=_form_fingerprint(definition),
)
route = f"/forms?formId={quote(definition.reference.object_id, safe='')}"
form_labels = _form_labels(definition)
form_descriptions = _form_descriptions(definition)
result = [
SemanticDocumentationSubjectDescriptor(
reference=form_reference,
labels=form_labels,
descriptions=form_descriptions,
route=route,
required_scopes=(READ_SCOPE,),
)
]
field_locations = _field_locations(definition)
for field in definition.fields:
identity = identities.field_ids[field.key]
page, section = field_locations.get(field.key, (None, None))
labels = _field_labels(definition, field.key, field.label)
descriptions = _field_descriptions(definition, field.key, field.help_text)
fingerprint = _field_fingerprint(definition, field, page, section, labels)
breadcrumbs = [
SemanticDocumentationBreadcrumb(
label=_label(form_labels),
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
)
]
if page is not None:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=page.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
)
)
if section is not None:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=section.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
anchor=SemanticDocumentationSubjectAnchor(
kind="section",
id=identities.section_ids[(page.key, section.key)],
),
)
)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
anchor=SemanticDocumentationSubjectAnchor(
kind="field", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels=labels,
descriptions=descriptions,
breadcrumbs=tuple(breadcrumbs),
route=route,
route_anchor=f"field-{field.key}",
required_scopes=(READ_SCOPE,),
)
)
for page in definition.pages:
for section in page.sections:
identity = identities.section_ids[(page.key, section.key)]
labels = _section_labels(definition, section.key, section.title)
fingerprint = _section_fingerprint(definition, page, section, labels)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
anchor=SemanticDocumentationSubjectAnchor(
kind="section", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels=labels,
descriptions=(
{_fallback_locale(definition): section.description}
if section.description
else {}
),
breadcrumbs=(
SemanticDocumentationBreadcrumb(
label=_label(form_labels),
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
),
SemanticDocumentationBreadcrumb(
label=page.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
),
),
route=route,
route_anchor=f"section-{page.key}-{section.key}",
required_scopes=(READ_SCOPE,),
)
)
return tuple(result)
def _reference(
definition,
*,
revision: str,
fingerprint: str,
anchor: SemanticDocumentationSubjectAnchor | None = None,
) -> SemanticDocumentationSubjectReference:
return SemanticDocumentationSubjectReference(
module_id="forms",
tenant_id=definition.reference.tenant_id,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
anchor=anchor,
observed_revision=revision,
observed_fingerprint=fingerprint,
)
def _identity_state(
session: Session,
current: FormDefinitionRevision,
) -> _IdentityState:
rows = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == current.tenant_id,
FormDefinitionRevision.form_id == current.form_id,
)
.order_by(FormDefinitionRevision.recorded_at, FormDefinitionRevision.id)
.all()
)
active_fields: dict[str, str] = {}
active_sections: dict[tuple[str, str], str] = {}
historical_fields: set[str] = set()
historical_sections: set[str] = set()
for row in rows:
definition = definition_from_mapping(row.payload)
field_keys = {field.key for field in definition.fields}
section_keys = {
(page.key, section.key)
for page in definition.pages
for section in page.sections
}
active_fields = {
key: value for key, value in active_fields.items() if key in field_keys
}
active_sections = {
key: value for key, value in active_sections.items() if key in section_keys
}
for key in sorted(field_keys):
active_fields.setdefault(key, _lineage_id("field", row.id, key))
historical_fields.add(active_fields[key])
for page_key, section_key in sorted(section_keys):
key = (page_key, section_key)
active_sections.setdefault(
key,
_lineage_id("section", row.id, page_key, section_key),
)
historical_sections.add(active_sections[key])
if row.id == current.id:
break
return _IdentityState(
field_ids=active_fields,
section_ids=active_sections,
historical_field_ids=frozenset(historical_fields),
historical_section_ids=frozenset(historical_sections),
)
def _lineage_id(kind: str, *parts: str) -> str:
value = "\x1f".join((kind, *parts)).encode()
return f"{kind}-{hashlib.sha256(value).hexdigest()[:40]}"
def _form_fingerprint(definition) -> str:
return semantic_documentation_fingerprint(
{
"revision": definition.temporal.revision,
"publication_state": definition.publication_state,
}
)
def _field_fingerprint(definition, field, page, section, labels) -> str:
return semantic_documentation_fingerprint(
{
"canonical_label": field.label,
"canonical_help": field.help_text,
"labels": labels,
"help": _field_descriptions(definition, field.key, field.help_text),
"value_type": field.value_type,
"required": field.required,
"options": list(field.options),
"constraints": dict(field.constraints),
"visibility": (
field.visibility_condition.to_dict()
if field.visibility_condition is not None
else None
),
"page": page.key if page else None,
"section": section.key if section else None,
}
)
def _section_fingerprint(definition, page, section, labels) -> str:
return semantic_documentation_fingerprint(
{
"labels": labels,
"description": section.description,
"page": page.key,
"field_keys": list(section.field_keys),
"visibility": (
section.visibility_condition.to_dict()
if section.visibility_condition is not None
else None
),
}
)
def _form_labels(definition) -> dict[str, str]:
labels = {_fallback_locale(definition): definition.title}
for localization in definition.localizations:
if localization.title:
labels[localization.locale] = localization.title
return labels
def _form_descriptions(definition) -> dict[str, str]:
descriptions = (
{_fallback_locale(definition): definition.description}
if definition.description
else {}
)
for localization in definition.localizations:
if localization.description:
descriptions[localization.locale] = localization.description
return descriptions
def _field_labels(definition, key: str, canonical: str) -> dict[str, str]:
labels = {_fallback_locale(definition): canonical}
for localization in definition.localizations:
label = localization.field_labels.get(key)
if label:
labels[localization.locale] = label
return labels
def _field_descriptions(
definition, key: str, canonical: str | None
) -> dict[str, str]:
descriptions = (
{_fallback_locale(definition): canonical} if canonical else {}
)
for localization in definition.localizations:
value = localization.field_help_texts.get(key)
if value:
descriptions[localization.locale] = value
return descriptions
def _section_labels(definition, key: str, canonical: str) -> dict[str, str]:
labels = {_fallback_locale(definition): canonical}
for localization in definition.localizations:
label = localization.section_titles.get(key)
if label:
labels[localization.locale] = label
return labels
def _fallback_locale(definition) -> str:
return definition.fallback_locale or "en"
def _field_locations(definition) -> dict[str, tuple[object, object]]:
return {
field_key: (page, section)
for page in definition.pages
for section in page.sections
for field_key in section.field_keys
}
def _label(labels: Mapping[str, str]) -> str:
return labels.get("de") or labels.get("en") or next(iter(labels.values()))
def _descriptor_search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
return " ".join(
(
item.reference.subject_id,
*(item.labels.values()),
*(item.descriptions.values()),
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
)
)
def _authorized(principal: object, tenant_id: str) -> bool:
if str(getattr(principal, "tenant_id", "") or "") != tenant_id:
return False
checker = getattr(principal, "has", None)
if callable(checker):
return bool(checker(READ_SCOPE))
return READ_SCOPE in getattr(principal, "scopes", ())
def _cursor_offset(value: str | None) -> int:
if value is None:
return 0
if not value.isdigit() or int(value) < 0:
raise ValueError("Forms semantic subject cursor is invalid.")
return int(value)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Forms semantic subjects require a SQLAlchemy session.")
return value
__all__ = [
"FormsSemanticDocumentationSubjectProvider",
"SUBJECT_KIND",
]