Files
breakpilot-compliance/backend-compliance/compliance/services/template_rule_service.py
T
Benjamin Admin bb183b0e75
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
feat(template-rules): backend foundation for profile-based document recommendations
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>
2026-06-06 23:13:50 +02:00

500 lines
17 KiB
Python

# 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",
]