feat(legal-docs): 5-stage lifecycle (draft → review_internal → review_client → approved → published)
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
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 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 14s
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) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
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 4s
CI / validate-canonical-controls (push) Successful in 11s
CI / loc-budget (push) Failing after 14s
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) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 30s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Phase 1 of the workspace-cutover initiative: compliance becomes the
single source of truth for documents. Step one is making the existing
compliance_legal_documents workflow rich enough to express the DSB→
Mandant approval pattern that the workspace's 5-stage UI needed.
Migration 148:
- Adds CHECK constraint on status (was free-form VARCHAR20)
- Allows: draft, review, review_internal, review_client, approved,
published, archived, rejected (legacy "review" kept for backward
compat — 0 existing rows so no backfill needed)
- Adds CHECK on approvals.action with extended values:
submitted_internal, submitted_client, approved_internal,
approved_client, rejected_internal, rejected_client
- Adds 6 new columns for the richer audit trail: submitted_by/at,
approved_internal_by/at, approved_client_by/at
Service:
- New methods submit_internal_review, approve_internal, approve_client
- submit_review / approve kept as backwards-compat aliases that map to
the new methods
- reject() now reads current status to log specific rejected_internal
or rejected_client action
- _version_to_response includes all new audit fields
Routes:
- POST /versions/{id}/submit-internal-review
- POST /versions/{id}/approve-internal (DSB sagt OK → Mandant ist dran)
- POST /versions/{id}/approve-client (Mandant sagt OK → approved)
- Existing submit-review / approve endpoints stay but map through aliases
Schema:
- VersionResponse extended with optional submitted_by/at,
approved_internal_by/at, approved_client_by/at fields
This unlocks Phase 2 (Generate-All in compliance generator), Phase 3
(Document-Library tab in admin), Phase 4 (workspace cutover — drop its
own document storage and route everything through this lifecycle).
[migration-approved]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,52 @@ async def publish_version(
|
|||||||
return service.publish(version_id, request)
|
return service.publish(version_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 5-stage Workflow (draft → review_internal → review_client → approved → published)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/versions/{version_id}/submit-internal-review",
|
||||||
|
response_model=VersionResponse,
|
||||||
|
)
|
||||||
|
async def submit_internal_review(
|
||||||
|
version_id: str,
|
||||||
|
request: ActionRequest,
|
||||||
|
service: LegalDocumentService = Depends(_get_doc_service),
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""draft (oder rejected) → review_internal: DSB-Phase beginnt."""
|
||||||
|
with translate_domain_errors():
|
||||||
|
return service.submit_internal_review(version_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/versions/{version_id}/approve-internal",
|
||||||
|
response_model=VersionResponse,
|
||||||
|
)
|
||||||
|
async def approve_internal(
|
||||||
|
version_id: str,
|
||||||
|
request: ActionRequest,
|
||||||
|
service: LegalDocumentService = Depends(_get_doc_service),
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""review_internal → review_client: DSB hat freigegeben, Mandant ist dran."""
|
||||||
|
with translate_domain_errors():
|
||||||
|
return service.approve_internal(version_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/versions/{version_id}/approve-client",
|
||||||
|
response_model=VersionResponse,
|
||||||
|
)
|
||||||
|
async def approve_client(
|
||||||
|
version_id: str,
|
||||||
|
request: ActionRequest,
|
||||||
|
service: LegalDocumentService = Depends(_get_doc_service),
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""review_client → approved: Mandant hat freigegeben."""
|
||||||
|
with translate_domain_errors():
|
||||||
|
return service.approve_client(version_id, request)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Approval History
|
# Approval History
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ class LegalDocumentDB(Base):
|
|||||||
|
|
||||||
|
|
||||||
class LegalDocumentVersionDB(Base):
|
class LegalDocumentVersionDB(Base):
|
||||||
"""Version of a legal document with Approval-Workflow status."""
|
"""Version of a legal document with 5-stage Approval-Workflow.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
draft → review_internal (DSB-Prüfung)
|
||||||
|
→ review_client (Mandanten-Prüfung)
|
||||||
|
→ approved → published
|
||||||
|
(Ablehnung: review_internal/review_client → rejected → zurück zu draft)
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = 'compliance_legal_document_versions'
|
__tablename__ = 'compliance_legal_document_versions'
|
||||||
|
|
||||||
@@ -53,10 +60,21 @@ class LegalDocumentVersionDB(Base):
|
|||||||
title = Column(String(300), nullable=False)
|
title = Column(String(300), nullable=False)
|
||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
summary = Column(Text)
|
summary = Column(Text)
|
||||||
status = Column(String(20), default='draft') # draft|review|approved|published|archived|rejected
|
status = Column(String(20), default='draft') # draft|review_internal|review_client|approved|published|archived|rejected (+ legacy 'review')
|
||||||
created_by = Column(String(200))
|
created_by = Column(String(200))
|
||||||
|
|
||||||
|
# Backwards-compat single approval (legacy 4-stage Flow)
|
||||||
approved_by = Column(String(200))
|
approved_by = Column(String(200))
|
||||||
approved_at = Column(DateTime)
|
approved_at = Column(DateTime)
|
||||||
|
|
||||||
|
# 5-stage Trail
|
||||||
|
submitted_by = Column(String(200))
|
||||||
|
submitted_at = Column(DateTime)
|
||||||
|
approved_internal_by = Column(String(200))
|
||||||
|
approved_internal_at = Column(DateTime)
|
||||||
|
approved_client_by = Column(String(200))
|
||||||
|
approved_client_at = Column(DateTime)
|
||||||
|
|
||||||
rejection_reason = Column(Text)
|
rejection_reason = Column(Text)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ class VersionResponse(BaseModel):
|
|||||||
summary: Optional[str]
|
summary: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
created_by: Optional[str]
|
created_by: Optional[str]
|
||||||
|
# Legacy single-approval (4-stage)
|
||||||
approved_by: Optional[str]
|
approved_by: Optional[str]
|
||||||
approved_at: Optional[datetime]
|
approved_at: Optional[datetime]
|
||||||
|
# 5-stage Trail
|
||||||
|
submitted_by: Optional[str] = None
|
||||||
|
submitted_at: Optional[datetime] = None
|
||||||
|
approved_internal_by: Optional[str] = None
|
||||||
|
approved_internal_at: Optional[datetime] = None
|
||||||
|
approved_client_by: Optional[str] = None
|
||||||
|
approved_client_at: Optional[datetime] = None
|
||||||
rejection_reason: Optional[str]
|
rejection_reason: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime]
|
updated_at: Optional[datetime]
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ def _version_to_response(v: LegalDocumentVersionDB) -> VersionResponse:
|
|||||||
created_by=v.created_by,
|
created_by=v.created_by,
|
||||||
approved_by=v.approved_by,
|
approved_by=v.approved_by,
|
||||||
approved_at=v.approved_at,
|
approved_at=v.approved_at,
|
||||||
|
submitted_by=getattr(v, "submitted_by", None),
|
||||||
|
submitted_at=getattr(v, "submitted_at", None),
|
||||||
|
approved_internal_by=getattr(v, "approved_internal_by", None),
|
||||||
|
approved_internal_at=getattr(v, "approved_internal_at", None),
|
||||||
|
approved_client_by=getattr(v, "approved_client_by", None),
|
||||||
|
approved_client_at=getattr(v, "approved_client_at", None),
|
||||||
rejection_reason=v.rejection_reason,
|
rejection_reason=v.rejection_reason,
|
||||||
created_at=v.created_at,
|
created_at=v.created_at,
|
||||||
updated_at=v.updated_at,
|
updated_at=v.updated_at,
|
||||||
@@ -259,38 +265,92 @@ class LegalDocumentService:
|
|||||||
return {"html": html_content, "filename": filename}
|
return {"html": html_content, "filename": filename}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Workflow transitions
|
# Workflow transitions (5-stage: draft → review_internal → review_client → approved → published)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
def submit_internal_review(
|
||||||
|
self, version_id: str, request: ActionRequest
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""draft (oder rejected) → review_internal: DSB-Pruefung beginnt."""
|
||||||
return _transition(
|
return _transition(
|
||||||
self.db, version_id, ["draft", "rejected"], "review", "submitted",
|
self.db, version_id, ["draft", "rejected"], "review_internal", "submitted_internal",
|
||||||
request.approver, request.comment,
|
|
||||||
)
|
|
||||||
|
|
||||||
def approve(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
|
||||||
return _transition(
|
|
||||||
self.db, version_id, ["review"], "approved", "approved",
|
|
||||||
request.approver, request.comment,
|
request.approver, request.comment,
|
||||||
extra_updates={
|
extra_updates={
|
||||||
|
"submitted_by": request.approver,
|
||||||
|
"submitted_at": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def approve_internal(
|
||||||
|
self, version_id: str, request: ActionRequest
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""review_internal → review_client: DSB hat geprueft, Mandant ist dran."""
|
||||||
|
return _transition(
|
||||||
|
self.db, version_id, ["review_internal"], "review_client", "approved_internal",
|
||||||
|
request.approver, request.comment,
|
||||||
|
extra_updates={
|
||||||
|
"approved_internal_by": request.approver,
|
||||||
|
"approved_internal_at": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def approve_client(
|
||||||
|
self, version_id: str, request: ActionRequest
|
||||||
|
) -> VersionResponse:
|
||||||
|
"""review_client → approved: Mandant hat freigegeben."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return _transition(
|
||||||
|
self.db, version_id, ["review_client"], "approved", "approved_client",
|
||||||
|
request.approver, request.comment,
|
||||||
|
extra_updates={
|
||||||
|
"approved_client_by": request.approver,
|
||||||
|
"approved_client_at": now,
|
||||||
|
# Legacy-Felder mitfuehren fuer alte Konsumenten
|
||||||
"approved_by": request.approver,
|
"approved_by": request.approver,
|
||||||
"approved_at": datetime.now(timezone.utc),
|
"approved_at": now,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def reject(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
def reject(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
||||||
|
"""Ablehnung aus review_internal oder review_client (oder legacy 'review') → rejected."""
|
||||||
|
v = (
|
||||||
|
self.db.query(LegalDocumentVersionDB)
|
||||||
|
.filter(LegalDocumentVersionDB.id == version_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
action = "rejected"
|
||||||
|
if v is not None:
|
||||||
|
if v.status == "review_internal":
|
||||||
|
action = "rejected_internal"
|
||||||
|
elif v.status == "review_client":
|
||||||
|
action = "rejected_client"
|
||||||
return _transition(
|
return _transition(
|
||||||
self.db, version_id, ["review"], "rejected", "rejected",
|
self.db, version_id,
|
||||||
|
["review", "review_internal", "review_client"],
|
||||||
|
"rejected", action,
|
||||||
request.approver, request.comment,
|
request.approver, request.comment,
|
||||||
extra_updates={"rejection_reason": request.comment},
|
extra_updates={"rejection_reason": request.comment},
|
||||||
)
|
)
|
||||||
|
|
||||||
def publish(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
def publish(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
||||||
|
"""approved → published."""
|
||||||
return _transition(
|
return _transition(
|
||||||
self.db, version_id, ["approved"], "published", "published",
|
self.db, version_id, ["approved"], "published", "published",
|
||||||
request.approver, request.comment,
|
request.approver, request.comment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Backwards-compat aliases for 4-stage callers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
||||||
|
"""Backwards-compat alias — leitet auf submit_internal_review um."""
|
||||||
|
return self.submit_internal_review(version_id, request)
|
||||||
|
|
||||||
|
def approve(self, version_id: str, request: ActionRequest) -> VersionResponse:
|
||||||
|
"""Backwards-compat alias — leitet auf approve_internal (DSB-Schritt)."""
|
||||||
|
return self.approve_internal(version_id, request)
|
||||||
|
|
||||||
def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]:
|
def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]:
|
||||||
version = (
|
version = (
|
||||||
self.db.query(LegalDocumentVersionDB)
|
self.db.query(LegalDocumentVersionDB)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- =========================================================
|
||||||
|
-- Migration 148: 5-stufiger Lifecycle für Legal Documents
|
||||||
|
--
|
||||||
|
-- Erweitert compliance_legal_document_versions.status um:
|
||||||
|
-- - review_internal (DSB-interne Pruefung, war vorher implizit 'review')
|
||||||
|
-- - review_client (Mandanten-Freigabe-Phase nach DSB-OK)
|
||||||
|
--
|
||||||
|
-- Plus CHECK-Constraint, der bisher fehlte (status war freier VARCHAR(20)).
|
||||||
|
-- 'review' bleibt erlaubt für Rückwärts-Kompatibilität.
|
||||||
|
--
|
||||||
|
-- Approvals-Tabelle bekommt neue action-Werte:
|
||||||
|
-- - submitted_internal | approved_internal
|
||||||
|
-- - submitted_client | approved_client
|
||||||
|
-- - rejected_internal | rejected_client (zur Differenzierung im Audit)
|
||||||
|
-- =========================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Status-CHECK falls noch nicht vorhanden
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'compliance.compliance_legal_document_versions'::regclass
|
||||||
|
AND conname = 'chk_legal_doc_version_status'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE compliance.compliance_legal_document_versions
|
||||||
|
ADD CONSTRAINT chk_legal_doc_version_status
|
||||||
|
CHECK (status IN (
|
||||||
|
'draft',
|
||||||
|
'review', -- backward-compat (deprecated; neuer Code nutzt review_internal/review_client)
|
||||||
|
'review_internal', -- DSB-Phase (NEU)
|
||||||
|
'review_client', -- Mandant-Phase (NEU)
|
||||||
|
'approved',
|
||||||
|
'published',
|
||||||
|
'archived',
|
||||||
|
'rejected'
|
||||||
|
));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Approvals-Action-CHECK falls noch nicht vorhanden
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'compliance.compliance_legal_document_approvals'::regclass
|
||||||
|
AND conname = 'chk_legal_doc_approval_action'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE compliance.compliance_legal_document_approvals
|
||||||
|
ADD CONSTRAINT chk_legal_doc_approval_action
|
||||||
|
CHECK (action IN (
|
||||||
|
'created', 'submitted',
|
||||||
|
'submitted_internal', 'submitted_client', -- NEU
|
||||||
|
'approved',
|
||||||
|
'approved_internal', 'approved_client', -- NEU
|
||||||
|
'rejected',
|
||||||
|
'rejected_internal', 'rejected_client', -- NEU
|
||||||
|
'published', 'archived'
|
||||||
|
));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Neue Felder für 5-stufige Audit-Trail-Anreicherung
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'compliance'
|
||||||
|
AND table_name = 'compliance_legal_document_versions'
|
||||||
|
AND column_name = 'approved_internal_by'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE compliance.compliance_legal_document_versions
|
||||||
|
ADD COLUMN approved_internal_by VARCHAR(200),
|
||||||
|
ADD COLUMN approved_internal_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN approved_client_by VARCHAR(200),
|
||||||
|
ADD COLUMN approved_client_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN submitted_by VARCHAR(200),
|
||||||
|
ADD COLUMN submitted_at TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
Reference in New Issue
Block a user