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