feat(template-rules): backend foundation for profile-based document recommendations
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / test-python-backend (push) Successful in 33s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m27s
CI / test-go (push) Failing after 46s
CI / iace-gt-coverage (push) Successful in 25s
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / test-python-backend (push) Successful in 33s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m27s
CI / test-go (push) Failing after 46s
CI / iace-gt-coverage (push) Successful in 25s
Introduces the sustainable backend replacement for the hardcoded inline rules in
admin-compliance/app/sdk/document-generator/templateRecommendations.ts.
What's in this commit (Phase 1.1 - 1.5 of the rustling-yawning-boot plan):
- Migration 147: 4 new tables
- compliance_template_rules (rule shell, document_type, current_version_id)
- compliance_template_rule_versions (lifecycle, JSONB conditions,
source_citation, change_summary, approval timestamps)
- compliance_template_rule_approvals (audit trail)
- compliance_tenant_rule_overrides (per-tenant classification overrides)
Plus partial unique index for "only one is_live=1 version per rule".
- SQLAlchemy models: TemplateRuleDB, TemplateRuleVersionDB,
TemplateRuleApprovalDB, TenantRuleOverrideDB (compliance/db/).
- Pydantic schemas (compliance/schemas/template_rule.py): full request/response
set including RecommendationRequest/Result with reasons and override tracking.
- TemplateRuleService (compliance/services/): CRUD + Lifecycle transitions
(submit_for_review/approve/publish/reject) following legal_document_service.py
pattern with _transition() helper and approval audit trail. Plus tenant
override upsert.
- RecommendationService: condition evaluator (eq, neq, in, not_in, gte/lte/gt/lt,
exists, truthy) over JSONB conditions, override application, reason generation
for human-readable explanations in workspace UI.
- 18 FastAPI routes in compliance/api/template_rule_routes.py covering rule CRUD,
version lifecycle, override management and POST /recommend evaluation endpoint.
- Seed data: 33 initial rules ported from templateRecommendations.ts in
compliance/data/template_rule_seed_data.py, written as published versions
on first seed run. Idempotent via rule_key.
Phase 1.6 (pytest suite) and Phase 2 (editorial UI in admin-compliance) follow
in separate commits.
[migration-approved]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
# mypy: disable-error-code="arg-type,assignment,union-attr"
|
||||
"""
|
||||
Recommendation Service — Auswertung der Empfehlungs-Regeln gegen ein Profil.
|
||||
|
||||
- Lädt alle live (is_live=1, status='published') Regeln
|
||||
- Lädt Tenant-Overrides
|
||||
- Iteriert Regeln, evaluiert ``conditions`` JSONB gegen Profil-Dict
|
||||
- Wendet Overrides an (Klassifikation überschreiben oder Regel deaktivieren)
|
||||
- Gruppiert in required/recommended/optional mit Begründung
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.db.template_rule_models import (
|
||||
TemplateRuleDB,
|
||||
TemplateRuleVersionDB,
|
||||
TenantRuleOverrideDB,
|
||||
)
|
||||
from compliance.schemas.template_rule import (
|
||||
RecommendationRequest,
|
||||
RecommendationResult,
|
||||
RecommendedItem,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- condition eval
|
||||
|
||||
def _eval_clause(clause: dict[str, Any], profile: dict[str, Any]) -> bool:
|
||||
"""Evaluate eine einzelne Bedingungsklausel gegen das Profil-Dict."""
|
||||
field = clause.get("field")
|
||||
op = clause.get("op")
|
||||
expected = clause.get("value")
|
||||
|
||||
if not field or not op:
|
||||
return False
|
||||
|
||||
actual = profile.get(field)
|
||||
|
||||
if op == "exists":
|
||||
return actual is not None and actual != "" and actual is not False
|
||||
if op == "eq":
|
||||
return actual == expected
|
||||
if op == "neq":
|
||||
return actual != expected
|
||||
if op == "in":
|
||||
if not isinstance(expected, list):
|
||||
return False
|
||||
return actual in expected
|
||||
if op == "not_in":
|
||||
if not isinstance(expected, list):
|
||||
return True
|
||||
return actual not in expected
|
||||
if op in ("gt", "gte", "lt", "lte"):
|
||||
try:
|
||||
a = _to_number(actual)
|
||||
e = _to_number(expected)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if op == "gt":
|
||||
return a > e
|
||||
if op == "gte":
|
||||
return a >= e
|
||||
if op == "lt":
|
||||
return a < e
|
||||
if op == "lte":
|
||||
return a <= e
|
||||
if op == "truthy":
|
||||
return bool(actual)
|
||||
if op == "falsy":
|
||||
return not bool(actual)
|
||||
return False
|
||||
|
||||
|
||||
def _to_number(v: Any) -> float:
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
if isinstance(v, str):
|
||||
# Buckets wie "50_249" → nimm untere Schwelle
|
||||
if "_" in v:
|
||||
head = v.split("_")[0]
|
||||
return float(head)
|
||||
return float(v)
|
||||
raise ValueError(f"Cannot coerce to number: {v}")
|
||||
|
||||
|
||||
def _eval_condition(condition: dict[str, Any], profile: dict[str, Any]) -> bool:
|
||||
"""Evaluate die gesamte Bedingung (all/any über Klauseln)."""
|
||||
if not condition:
|
||||
return True # leere Bedingung → immer wahr
|
||||
kind = condition.get("kind", "all")
|
||||
clauses = condition.get("clauses") or []
|
||||
if not clauses:
|
||||
return True
|
||||
|
||||
if kind == "all":
|
||||
return all(_eval_clause(c, profile) for c in clauses)
|
||||
if kind == "any":
|
||||
return any(_eval_clause(c, profile) for c in clauses)
|
||||
return False
|
||||
|
||||
|
||||
def _build_reason(
|
||||
rule: TemplateRuleDB,
|
||||
version: TemplateRuleVersionDB,
|
||||
profile: dict[str, Any],
|
||||
override: Optional[TenantRuleOverrideDB],
|
||||
) -> str:
|
||||
"""Menschenlesbare Begründung für den Reviewer im Workspace."""
|
||||
parts: list[str] = []
|
||||
conds = version.conditions or {}
|
||||
clauses = conds.get("clauses") or []
|
||||
matched = [
|
||||
c for c in clauses if _eval_clause(c, profile)
|
||||
]
|
||||
if matched:
|
||||
bits = []
|
||||
for c in matched[:3]:
|
||||
field = c.get("field", "?")
|
||||
op = c.get("op", "?")
|
||||
val = c.get("value", "?")
|
||||
actual = profile.get(field)
|
||||
bits.append(f"{field} ({actual}) {op} {val}")
|
||||
parts.append("Trifft zu: " + "; ".join(bits))
|
||||
else:
|
||||
parts.append(f"Always-rule für '{rule.document_type}'")
|
||||
|
||||
if version.source_citation:
|
||||
parts.append(f"Quelle: {version.source_citation}")
|
||||
|
||||
if override:
|
||||
if override.override_classification:
|
||||
parts.append(
|
||||
f"Tenant-Override: {version.classification} → "
|
||||
f"{override.override_classification} ({override.reason})"
|
||||
)
|
||||
else:
|
||||
parts.append(f"Tenant-Override: deaktiviert ({override.reason})")
|
||||
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- service
|
||||
|
||||
class RecommendationService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def recommend(
|
||||
self,
|
||||
request: RecommendationRequest,
|
||||
tenant_id: Optional[str] = None,
|
||||
) -> RecommendationResult:
|
||||
profile = dict(request.profile or {})
|
||||
if request.compliance_depth_level:
|
||||
profile.setdefault("compliance_depth_level", request.compliance_depth_level)
|
||||
|
||||
# Live-Versionen aller Regeln laden
|
||||
live_versions = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.is_live == 1)
|
||||
.all()
|
||||
)
|
||||
if not live_versions:
|
||||
return RecommendationResult(
|
||||
required=[], recommended=[], optional=[], profile_used=profile,
|
||||
)
|
||||
|
||||
rule_ids = [v.rule_id for v in live_versions]
|
||||
rules = {
|
||||
r.id: r for r in
|
||||
self.db.query(TemplateRuleDB)
|
||||
.filter(TemplateRuleDB.id.in_(rule_ids))
|
||||
.all()
|
||||
}
|
||||
|
||||
# Tenant-Overrides
|
||||
overrides: dict[Any, TenantRuleOverrideDB] = {}
|
||||
if tenant_id:
|
||||
for o in (
|
||||
self.db.query(TenantRuleOverrideDB)
|
||||
.filter(TenantRuleOverrideDB.tenant_id == tenant_id)
|
||||
.filter(TenantRuleOverrideDB.rule_id.in_(rule_ids))
|
||||
.all()
|
||||
):
|
||||
overrides[o.rule_id] = o
|
||||
|
||||
buckets: dict[str, list[RecommendedItem]] = {
|
||||
"required": [], "recommended": [], "optional": [],
|
||||
}
|
||||
|
||||
for v in live_versions:
|
||||
rule = rules.get(v.rule_id)
|
||||
if not rule:
|
||||
continue
|
||||
if not _eval_condition(v.conditions or {}, profile):
|
||||
continue
|
||||
|
||||
override = overrides.get(v.rule_id)
|
||||
base_class = v.classification
|
||||
effective_class = base_class
|
||||
if override:
|
||||
if override.override_classification is None:
|
||||
# Override = deaktiviert
|
||||
continue
|
||||
effective_class = override.override_classification
|
||||
|
||||
reason = _build_reason(rule, v, profile, override)
|
||||
item = RecommendedItem(
|
||||
document_type=rule.document_type,
|
||||
title=rule.title,
|
||||
rule_id=str(rule.id),
|
||||
rule_key=rule.rule_key,
|
||||
classification=effective_class,
|
||||
base_classification=base_class,
|
||||
source_citation=v.source_citation or "",
|
||||
reason=reason,
|
||||
override_applied=override is not None,
|
||||
)
|
||||
buckets[effective_class].append(item)
|
||||
|
||||
# Sortiert nach document_type stabil
|
||||
for k in buckets:
|
||||
buckets[k].sort(key=lambda i: i.document_type)
|
||||
|
||||
return RecommendationResult(
|
||||
required=buckets["required"],
|
||||
recommended=buckets["recommended"],
|
||||
optional=buckets["optional"],
|
||||
profile_used=profile,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RecommendationService"]
|
||||
@@ -0,0 +1,499 @@
|
||||
# mypy: disable-error-code="arg-type,assignment,union-attr"
|
||||
"""
|
||||
Template Rule service — Empfehlungs-Regeln + Versionierung + Approval-Workflow
|
||||
+ Tenant-Overrides.
|
||||
|
||||
Analog zu ``legal_document_service.py`` aufgebaut:
|
||||
- ``_transition()``-Helper für Lifecycle-Übergänge
|
||||
- Audit-Trail in ``compliance_template_rule_approvals``
|
||||
- Service-Klasse mit DB-Session-Konstruktor
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.db.template_rule_models import (
|
||||
TemplateRuleDB,
|
||||
TemplateRuleVersionDB,
|
||||
TemplateRuleApprovalDB,
|
||||
TenantRuleOverrideDB,
|
||||
)
|
||||
from compliance.domain import NotFoundError, ValidationError
|
||||
from compliance.schemas.template_rule import (
|
||||
ApprovalActionRequest,
|
||||
ApprovalHistoryEntry,
|
||||
OverrideCreate,
|
||||
OverrideResponse,
|
||||
RejectActionRequest,
|
||||
RuleCondition,
|
||||
RuleCreate,
|
||||
RuleResponse,
|
||||
RuleVersionCreate,
|
||||
RuleVersionResponse,
|
||||
RuleVersionUpdate,
|
||||
SubmitForReviewRequest,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- response mappers
|
||||
|
||||
def _rule_to_response(rule: TemplateRuleDB) -> RuleResponse:
|
||||
return RuleResponse(
|
||||
id=str(rule.id),
|
||||
rule_key=rule.rule_key,
|
||||
document_type=rule.document_type,
|
||||
title=rule.title,
|
||||
current_version_id=str(rule.current_version_id) if rule.current_version_id else None,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _version_to_response(v: TemplateRuleVersionDB) -> RuleVersionResponse:
|
||||
return RuleVersionResponse(
|
||||
id=str(v.id),
|
||||
rule_id=str(v.rule_id),
|
||||
version_number=v.version_number,
|
||||
status=v.status,
|
||||
is_live=bool(v.is_live),
|
||||
classification=v.classification,
|
||||
conditions=v.conditions or {},
|
||||
source_citation=v.source_citation or "",
|
||||
rationale=v.rationale,
|
||||
change_summary=v.change_summary,
|
||||
created_by=v.created_by,
|
||||
submitted_by=v.submitted_by,
|
||||
submitted_at=v.submitted_at,
|
||||
approved_by=v.approved_by,
|
||||
approved_at=v.approved_at,
|
||||
published_by=v.published_by,
|
||||
published_at=v.published_at,
|
||||
rejected_by=v.rejected_by,
|
||||
rejected_at=v.rejected_at,
|
||||
rejection_reason=v.rejection_reason,
|
||||
created_at=v.created_at,
|
||||
updated_at=v.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _override_to_response(o: TenantRuleOverrideDB) -> OverrideResponse:
|
||||
return OverrideResponse(
|
||||
id=str(o.id),
|
||||
tenant_id=o.tenant_id,
|
||||
rule_id=str(o.rule_id),
|
||||
override_classification=o.override_classification,
|
||||
reason=o.reason,
|
||||
created_by=o.created_by,
|
||||
created_at=o.created_at,
|
||||
updated_at=o.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _log_approval(
|
||||
db: Session,
|
||||
version_id: Any,
|
||||
action: str,
|
||||
approver: Optional[str] = None,
|
||||
comment: Optional[str] = None,
|
||||
) -> TemplateRuleApprovalDB:
|
||||
entry = TemplateRuleApprovalDB(
|
||||
version_id=version_id, action=action, approver=approver, comment=comment,
|
||||
)
|
||||
db.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _transition(
|
||||
db: Session,
|
||||
version_id: str,
|
||||
from_statuses: list[str],
|
||||
to_status: str,
|
||||
action: str,
|
||||
approver: Optional[str],
|
||||
comment: Optional[str],
|
||||
extra_updates: Optional[dict[str, Any]] = None,
|
||||
) -> RuleVersionResponse:
|
||||
version = (
|
||||
db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.id == version_id)
|
||||
.first()
|
||||
)
|
||||
if not version:
|
||||
raise NotFoundError(f"Rule version {version_id} not found")
|
||||
if version.status not in from_statuses:
|
||||
raise ValidationError(
|
||||
f"Cannot perform '{action}' on version with status "
|
||||
f"'{version.status}' (expected: {from_statuses})"
|
||||
)
|
||||
|
||||
version.status = to_status
|
||||
version.updated_at = datetime.now(timezone.utc)
|
||||
if extra_updates:
|
||||
for k, v in extra_updates.items():
|
||||
setattr(version, k, v)
|
||||
|
||||
_log_approval(db, version.id, action=action, approver=approver, comment=comment)
|
||||
db.commit()
|
||||
db.refresh(version)
|
||||
return _version_to_response(version)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- service
|
||||
|
||||
class TemplateRuleService:
|
||||
"""Business logic for rules, versions, approval workflow and tenant overrides."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ----- Rules (Hülle) -----
|
||||
|
||||
def list_rules(
|
||||
self,
|
||||
document_type: Optional[str] = None,
|
||||
) -> list[RuleResponse]:
|
||||
q = self.db.query(TemplateRuleDB)
|
||||
if document_type:
|
||||
q = q.filter(TemplateRuleDB.document_type == document_type)
|
||||
rules = q.order_by(TemplateRuleDB.created_at.asc()).all()
|
||||
return [_rule_to_response(r) for r in rules]
|
||||
|
||||
def get_rule(self, rule_id: str) -> RuleResponse:
|
||||
rule = self._rule_or_raise(rule_id)
|
||||
return _rule_to_response(rule)
|
||||
|
||||
def _rule_or_raise(self, rule_id: str) -> TemplateRuleDB:
|
||||
rule = (
|
||||
self.db.query(TemplateRuleDB)
|
||||
.filter(TemplateRuleDB.id == rule_id)
|
||||
.first()
|
||||
)
|
||||
if not rule:
|
||||
raise NotFoundError(f"Rule {rule_id} not found")
|
||||
return rule
|
||||
|
||||
def create_rule(self, request: RuleCreate) -> RuleResponse:
|
||||
existing = (
|
||||
self.db.query(TemplateRuleDB)
|
||||
.filter(TemplateRuleDB.rule_key == request.rule_key)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValidationError(f"Rule with key '{request.rule_key}' already exists")
|
||||
rule = TemplateRuleDB(
|
||||
rule_key=request.rule_key,
|
||||
document_type=request.document_type,
|
||||
title=request.title,
|
||||
)
|
||||
self.db.add(rule)
|
||||
self.db.commit()
|
||||
self.db.refresh(rule)
|
||||
return _rule_to_response(rule)
|
||||
|
||||
def delete_rule(self, rule_id: str) -> None:
|
||||
rule = self._rule_or_raise(rule_id)
|
||||
self.db.delete(rule)
|
||||
self.db.commit()
|
||||
|
||||
# ----- Versions -----
|
||||
|
||||
def list_versions_for(self, rule_id: str) -> list[RuleVersionResponse]:
|
||||
self._rule_or_raise(rule_id)
|
||||
versions = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.rule_id == rule_id)
|
||||
.order_by(TemplateRuleVersionDB.version_number.desc())
|
||||
.all()
|
||||
)
|
||||
return [_version_to_response(v) for v in versions]
|
||||
|
||||
def get_version(self, version_id: str) -> RuleVersionResponse:
|
||||
v = self._version_or_raise(version_id)
|
||||
return _version_to_response(v)
|
||||
|
||||
def _version_or_raise(self, version_id: str) -> TemplateRuleVersionDB:
|
||||
v = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.id == version_id)
|
||||
.first()
|
||||
)
|
||||
if not v:
|
||||
raise NotFoundError(f"Rule version {version_id} not found")
|
||||
return v
|
||||
|
||||
def create_version(self, request: RuleVersionCreate) -> RuleVersionResponse:
|
||||
"""Erzeugt einen neuen Draft. Vorhandener offener Draft blockiert."""
|
||||
self._rule_or_raise(request.rule_id)
|
||||
|
||||
# Es darf nur eine nicht-finalisierte Version geben (draft|review)
|
||||
open_v = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.rule_id == request.rule_id)
|
||||
.filter(TemplateRuleVersionDB.status.in_(["draft", "review"]))
|
||||
.first()
|
||||
)
|
||||
if open_v:
|
||||
raise ValidationError(
|
||||
f"Rule {request.rule_id} already has an open draft (version {open_v.version_number})"
|
||||
)
|
||||
|
||||
# Nächste Versionsnummer ermitteln
|
||||
last = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.rule_id == request.rule_id)
|
||||
.order_by(TemplateRuleVersionDB.version_number.desc())
|
||||
.first()
|
||||
)
|
||||
next_n = (last.version_number + 1) if last else 1
|
||||
|
||||
if request.classification not in ("required", "recommended", "optional"):
|
||||
raise ValidationError(f"Invalid classification '{request.classification}'")
|
||||
|
||||
if not request.source_citation or not request.source_citation.strip():
|
||||
raise ValidationError("source_citation is required (Pflichtfeld)")
|
||||
|
||||
version = TemplateRuleVersionDB(
|
||||
rule_id=request.rule_id,
|
||||
version_number=next_n,
|
||||
status="draft",
|
||||
is_live=0,
|
||||
classification=request.classification,
|
||||
conditions=request.conditions.dict(),
|
||||
source_citation=request.source_citation.strip(),
|
||||
rationale=request.rationale,
|
||||
created_by=request.created_by,
|
||||
)
|
||||
self.db.add(version)
|
||||
self.db.flush()
|
||||
_log_approval(self.db, version.id, action="created", approver=request.created_by)
|
||||
self.db.commit()
|
||||
self.db.refresh(version)
|
||||
return _version_to_response(version)
|
||||
|
||||
def update_version(
|
||||
self, version_id: str, request: RuleVersionUpdate
|
||||
) -> RuleVersionResponse:
|
||||
v = self._version_or_raise(version_id)
|
||||
if v.status != "draft":
|
||||
raise ValidationError(
|
||||
f"Only draft versions can be edited (current: {v.status})"
|
||||
)
|
||||
|
||||
if request.classification is not None:
|
||||
if request.classification not in ("required", "recommended", "optional"):
|
||||
raise ValidationError(f"Invalid classification '{request.classification}'")
|
||||
v.classification = request.classification
|
||||
if request.conditions is not None:
|
||||
v.conditions = request.conditions.dict()
|
||||
if request.source_citation is not None:
|
||||
v.source_citation = request.source_citation.strip()
|
||||
if request.rationale is not None:
|
||||
v.rationale = request.rationale
|
||||
if request.change_summary is not None:
|
||||
v.change_summary = request.change_summary
|
||||
|
||||
v.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(v)
|
||||
return _version_to_response(v)
|
||||
|
||||
# ----- Lifecycle -----
|
||||
|
||||
def submit_for_review(
|
||||
self, version_id: str, request: SubmitForReviewRequest
|
||||
) -> RuleVersionResponse:
|
||||
if not request.change_summary or not request.change_summary.strip():
|
||||
raise ValidationError("change_summary is required when submitting for review")
|
||||
|
||||
# change_summary erst persistieren
|
||||
v = self._version_or_raise(version_id)
|
||||
v.change_summary = request.change_summary.strip()
|
||||
self.db.flush()
|
||||
|
||||
return _transition(
|
||||
db=self.db,
|
||||
version_id=version_id,
|
||||
from_statuses=["draft"],
|
||||
to_status="review",
|
||||
action="submitted",
|
||||
approver=request.submitter,
|
||||
comment=request.comment,
|
||||
extra_updates={
|
||||
"submitted_by": request.submitter,
|
||||
"submitted_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
|
||||
def approve(
|
||||
self, version_id: str, request: ApprovalActionRequest
|
||||
) -> RuleVersionResponse:
|
||||
return _transition(
|
||||
db=self.db,
|
||||
version_id=version_id,
|
||||
from_statuses=["review"],
|
||||
to_status="approved",
|
||||
action="approved",
|
||||
approver=request.approver,
|
||||
comment=request.comment,
|
||||
extra_updates={
|
||||
"approved_by": request.approver,
|
||||
"approved_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
|
||||
def publish(
|
||||
self, version_id: str, request: ApprovalActionRequest
|
||||
) -> RuleVersionResponse:
|
||||
# Auf 'approved' → publish: vorige published Version archivieren, aktuelle live machen
|
||||
v = self._version_or_raise(version_id)
|
||||
if v.status != "approved":
|
||||
raise ValidationError(
|
||||
f"Only approved versions can be published (current: {v.status})"
|
||||
)
|
||||
|
||||
# Vorige live demoten
|
||||
prev_live = (
|
||||
self.db.query(TemplateRuleVersionDB)
|
||||
.filter(TemplateRuleVersionDB.rule_id == v.rule_id)
|
||||
.filter(TemplateRuleVersionDB.is_live == 1)
|
||||
.first()
|
||||
)
|
||||
if prev_live:
|
||||
prev_live.status = "archived"
|
||||
prev_live.is_live = 0
|
||||
prev_live.updated_at = datetime.now(timezone.utc)
|
||||
_log_approval(self.db, prev_live.id, action="archived", approver=request.approver)
|
||||
|
||||
# Aktuelle live machen
|
||||
now = datetime.now(timezone.utc)
|
||||
v.status = "published"
|
||||
v.is_live = 1
|
||||
v.published_by = request.approver
|
||||
v.published_at = now
|
||||
v.updated_at = now
|
||||
|
||||
# current_version_id auf rule setzen
|
||||
rule = self._rule_or_raise(str(v.rule_id))
|
||||
rule.current_version_id = v.id
|
||||
rule.updated_at = now
|
||||
|
||||
_log_approval(self.db, v.id, action="published", approver=request.approver, comment=request.comment)
|
||||
self.db.commit()
|
||||
self.db.refresh(v)
|
||||
return _version_to_response(v)
|
||||
|
||||
def reject(
|
||||
self, version_id: str, request: RejectActionRequest
|
||||
) -> RuleVersionResponse:
|
||||
if not request.rejection_reason or not request.rejection_reason.strip():
|
||||
raise ValidationError("rejection_reason is required")
|
||||
|
||||
return _transition(
|
||||
db=self.db,
|
||||
version_id=version_id,
|
||||
from_statuses=["review"],
|
||||
to_status="rejected",
|
||||
action="rejected",
|
||||
approver=request.rejector,
|
||||
comment=request.comment,
|
||||
extra_updates={
|
||||
"rejected_by": request.rejector,
|
||||
"rejected_at": datetime.now(timezone.utc),
|
||||
"rejection_reason": request.rejection_reason.strip(),
|
||||
},
|
||||
)
|
||||
|
||||
def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]:
|
||||
self._version_or_raise(version_id)
|
||||
entries = (
|
||||
self.db.query(TemplateRuleApprovalDB)
|
||||
.filter(TemplateRuleApprovalDB.version_id == version_id)
|
||||
.order_by(TemplateRuleApprovalDB.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ApprovalHistoryEntry(
|
||||
id=str(e.id),
|
||||
version_id=str(e.version_id),
|
||||
action=e.action,
|
||||
approver=e.approver,
|
||||
comment=e.comment,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
# ----- Tenant-Overrides -----
|
||||
|
||||
def list_overrides(self, tenant_id: str) -> list[OverrideResponse]:
|
||||
os = (
|
||||
self.db.query(TenantRuleOverrideDB)
|
||||
.filter(TenantRuleOverrideDB.tenant_id == tenant_id)
|
||||
.order_by(TenantRuleOverrideDB.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_override_to_response(o) for o in os]
|
||||
|
||||
def upsert_override(
|
||||
self, tenant_id: str, request: OverrideCreate
|
||||
) -> OverrideResponse:
|
||||
self._rule_or_raise(request.rule_id)
|
||||
|
||||
if request.override_classification is not None:
|
||||
if request.override_classification not in ("required", "recommended", "optional"):
|
||||
raise ValidationError(
|
||||
f"Invalid override_classification '{request.override_classification}'"
|
||||
)
|
||||
if not request.reason or not request.reason.strip():
|
||||
raise ValidationError("reason is required")
|
||||
|
||||
existing = (
|
||||
self.db.query(TenantRuleOverrideDB)
|
||||
.filter(TenantRuleOverrideDB.tenant_id == tenant_id)
|
||||
.filter(TenantRuleOverrideDB.rule_id == request.rule_id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.override_classification = request.override_classification
|
||||
existing.reason = request.reason.strip()
|
||||
existing.created_by = request.created_by
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return _override_to_response(existing)
|
||||
|
||||
override = TenantRuleOverrideDB(
|
||||
tenant_id=tenant_id,
|
||||
rule_id=request.rule_id,
|
||||
override_classification=request.override_classification,
|
||||
reason=request.reason.strip(),
|
||||
created_by=request.created_by,
|
||||
)
|
||||
self.db.add(override)
|
||||
self.db.commit()
|
||||
self.db.refresh(override)
|
||||
return _override_to_response(override)
|
||||
|
||||
def delete_override(self, tenant_id: str, override_id: str) -> None:
|
||||
o = (
|
||||
self.db.query(TenantRuleOverrideDB)
|
||||
.filter(TenantRuleOverrideDB.id == override_id)
|
||||
.filter(TenantRuleOverrideDB.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not o:
|
||||
raise NotFoundError(f"Override {override_id} not found for tenant {tenant_id}")
|
||||
self.db.delete(o)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TemplateRuleService",
|
||||
"_rule_to_response",
|
||||
"_version_to_response",
|
||||
"_override_to_response",
|
||||
"_transition",
|
||||
"_log_approval",
|
||||
]
|
||||
Reference in New Issue
Block a user