diff --git a/backend/app/api/governance.py b/backend/app/api/governance.py new file mode 100644 index 00000000..fa920397 --- /dev/null +++ b/backend/app/api/governance.py @@ -0,0 +1,280 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Form, Header +from sqlalchemy.orm import Session + +from app.core.response import success_response +from app.db import get_db_session +from app.services.audit_log_service import AuditLogService +from app.services.auth_service import AuthService +from app.services.governance_service import GovernanceService + +router = APIRouter(prefix="/governance", tags=["governance"]) +ROUTE_PERMISSION_SCOPE = "governance_management" +ROUTE_PERMISSION_ACTIONS = { + "assets_list": "governance:assets:list", + "asset_detail": "governance:assets:detail", + "assignments_list": "governance:assignments:list", + "assignment_save": "governance:assignments:save", + "decision_submit": "governance:decisions:submit", + "evidence_save": "governance:evidences:save", + "release_summary": "governance:releases:summary", + "release_detail": "governance:releases:detail", + "formal_launch_import": "governance:imports:formal_launch", +} + + +def get_governance_service(db: Session = Depends(get_db_session)) -> GovernanceService: + return GovernanceService(db) + + +def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogService: + return AuditLogService(db) + + +def require_current_user( + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db_session), +): + return AuthService(db).require_current_user(authorization) + + +@router.get("/assets") +def list_governance_assets( + page: int = 1, + page_size: int = 20, + asset_type: Optional[str] = None, + governance_status: Optional[str] = None, + review_status: Optional[str] = None, + production_status: Optional[str] = None, + assignment_role: Optional[str] = None, + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + governance_service.list_assets( + current_user=current_user, + page=page, + page_size=page_size, + asset_type=asset_type, + governance_status=governance_status, + review_status=review_status, + production_status=production_status, + assignment_role=assignment_role, + ) + ) + + +@router.get("/assets/{governance_asset_id}") +def get_governance_asset_detail( + governance_asset_id: str, + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response(governance_service.get_asset_detail(current_user, governance_asset_id)) + + +@router.get("/assignments") +def list_governance_assignments( + page: int = 1, + page_size: int = 20, + assignment_role: Optional[str] = None, + status_filter: Optional[str] = None, + assignee_user_id: Optional[str] = None, + governance_asset_id: Optional[str] = None, + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response( + governance_service.list_assignments( + current_user=current_user, + page=page, + page_size=page_size, + assignment_role=assignment_role, + status_filter=status_filter, + assignee_user_id=assignee_user_id, + governance_asset_id=governance_asset_id, + ) + ) + + +@router.post("/assignments") +def save_governance_assignment( + governance_asset_id: str = Form(...), + assignment_role: str = Form(...), + assignee_user_id: Optional[str] = Form(default=None), + assignee_name: Optional[str] = Form(default=None), + due_at: Optional[str] = Form(default=None), + status_value: Optional[str] = Form(default=None), + priority: Optional[str] = Form(default=None), + notes: Optional[str] = Form(default=None), + assignment_id: Optional[str] = Form(default=None), + is_active: bool = Form(default=True), + governance_service: GovernanceService = Depends(get_governance_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = governance_service.save_assignment( + current_user=current_user, + governance_asset_id=governance_asset_id, + assignment_role=assignment_role, + assignee_user_id=assignee_user_id, + assignee_name=assignee_name, + due_at=due_at, + status_value=status_value, + priority=priority, + notes=notes, + assignment_id=assignment_id, + is_active=is_active, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="save_governance_assignment", + resource_type="governance_assignment", + resource_id=result["id"], + detail_json={ + "governance_asset_id": result["governance_asset_id"], + "assignment_role": result["assignment_role"], + "assignee_user_id": result["assignee_user_id"], + "assignee_name": result["assignee_name"], + "status": result["status"], + "priority": result["priority"], + "is_active": result["is_active"], + }, + ) + return success_response(result) + + +@router.post("/decisions") +def submit_governance_decision( + governance_asset_id: str = Form(...), + assignment_id: Optional[str] = Form(default=None), + decision_role: str = Form(...), + decision_result: str = Form(...), + decision_summary: Optional[str] = Form(default=None), + required_changes: Optional[str] = Form(default=None), + decided_by_name: Optional[str] = Form(default=None), + decision_at: Optional[str] = Form(default=None), + metadata_json: Optional[str] = Form(default=None), + governance_service: GovernanceService = Depends(get_governance_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = governance_service.submit_decision( + current_user=current_user, + governance_asset_id=governance_asset_id, + assignment_id=assignment_id, + decision_role=decision_role, + decision_result=decision_result, + decision_summary=decision_summary, + required_changes=required_changes, + decided_by_name=decided_by_name, + decision_at=decision_at, + metadata_json=metadata_json, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="submit_governance_decision", + resource_type="governance_decision", + resource_id=result["decision"]["id"], + detail_json={ + "governance_asset_id": governance_asset_id, + "assignment_id": assignment_id, + "decision_role": result["decision"]["decision_role"], + "decision_result": result["decision"]["decision_result"], + }, + ) + return success_response(result) + + +@router.post("/evidences") +def save_governance_evidence( + governance_asset_id: str = Form(...), + decision_id: Optional[str] = Form(default=None), + file_id: Optional[str] = Form(default=None), + evidence_type: Optional[str] = Form(default=None), + storage_type: Optional[str] = Form(default=None), + external_url: Optional[str] = Form(default=None), + file_name: Optional[str] = Form(default=None), + checksum: Optional[str] = Form(default=None), + status_value: Optional[str] = Form(default=None), + reviewer_name: Optional[str] = Form(default=None), + collected_at: Optional[str] = Form(default=None), + metadata_json: Optional[str] = Form(default=None), + governance_service: GovernanceService = Depends(get_governance_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = governance_service.save_evidence( + current_user=current_user, + governance_asset_id=governance_asset_id, + decision_id=decision_id, + file_id=file_id, + evidence_type=evidence_type, + storage_type=storage_type, + external_url=external_url, + file_name=file_name, + checksum=checksum, + status_value=status_value, + reviewer_name=reviewer_name, + collected_at=collected_at, + metadata_json=metadata_json, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="save_governance_evidence", + resource_type="governance_evidence", + resource_id=result["evidence"]["id"], + detail_json={ + "governance_asset_id": governance_asset_id, + "decision_id": decision_id, + "status": result["evidence"]["status"], + "storage_type": result["evidence"]["storage_type"], + "external_url": result["evidence"]["external_url"], + "file_id": result["evidence"]["file_id"], + }, + ) + return success_response(result) + + +@router.get("/releases/summary") +def get_governance_release_summary( + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response(governance_service.get_release_summary(current_user)) + + +@router.get("/releases/{release_batch_id}") +def get_governance_release_detail( + release_batch_id: str, + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response(governance_service.get_release_detail(current_user, release_batch_id)) + + +@router.post("/imports/formal-launch") +def import_formal_launch_governance( + batch_name: Optional[str] = Form(default=None), + governance_service: GovernanceService = Depends(get_governance_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = governance_service.import_formal_launch(current_user, batch_name=batch_name) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="import_formal_launch_governance", + resource_type="governance_import", + resource_id=result["release_batch"]["id"], + detail_json={ + "batch_name": result["release_batch"]["batch_name"], + "status": result["release_batch"]["status"], + "imported": result["imported"], + }, + ) + return success_response(result) diff --git a/backend/app/main.py b/backend/app/main.py index 2f4f1526..e379a531 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from app.api.chat import router as chat_router from app.api.contracts import router as contracts_router from app.api.conversations import router as conversations_router from app.api.files import router as files_router +from app.api.governance import router as governance_router from app.api.knowledge_bases import router as knowledge_bases_router from app.api.review_rules import router as review_rules_router from app.api.reviews import router as reviews_router @@ -34,6 +35,7 @@ app.include_router(chat_router, prefix=settings.api_v1_prefix) app.include_router(contracts_router, prefix=settings.api_v1_prefix) app.include_router(conversations_router, prefix=settings.api_v1_prefix) app.include_router(files_router, prefix=settings.api_v1_prefix) +app.include_router(governance_router, prefix=settings.api_v1_prefix) app.include_router(knowledge_bases_router, prefix=settings.api_v1_prefix) app.include_router(review_rules_router, prefix=settings.api_v1_prefix) app.include_router(templates_router, prefix=settings.api_v1_prefix) diff --git a/backend/app/repositories/governance_asset_repository.py b/backend/app/repositories/governance_asset_repository.py new file mode 100644 index 00000000..a3e8db90 --- /dev/null +++ b/backend/app/repositories/governance_asset_repository.py @@ -0,0 +1,117 @@ +from typing import Iterable, List, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.models.governance_asset import GovernanceAsset + + +class GovernanceAssetRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, asset: GovernanceAsset) -> GovernanceAsset: + self.db.add(asset) + self.db.commit() + self.db.refresh(asset) + return asset + + def update(self, asset: GovernanceAsset) -> GovernanceAsset: + self.db.add(asset) + self.db.commit() + self.db.refresh(asset) + return asset + + def get_by_id(self, asset_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceAsset]: + query = self.db.query(GovernanceAsset).filter(GovernanceAsset.id == asset_id) + if tenant_id is not None: + query = query.filter(GovernanceAsset.tenant_id == tenant_id) + return query.first() + + def get_by_identity(self, tenant_id: str, asset_type: str, source_asset_id: str) -> Optional[GovernanceAsset]: + return ( + self.db.query(GovernanceAsset) + .filter( + GovernanceAsset.tenant_id == tenant_id, + GovernanceAsset.asset_type == asset_type, + GovernanceAsset.asset_id == source_asset_id, + ) + .first() + ) + + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + asset_ids: Optional[Iterable[str]] = None, + asset_type: Optional[str] = None, + governance_status: Optional[str] = None, + review_status: Optional[str] = None, + production_status: Optional[str] = None, + ) -> List[GovernanceAsset]: + query = self.db.query(GovernanceAsset).filter(GovernanceAsset.tenant_id == tenant_id) + if asset_ids is not None: + normalized_asset_ids = [item for item in asset_ids if item] + if not normalized_asset_ids: + return [] + query = query.filter(GovernanceAsset.id.in_(normalized_asset_ids)) + if asset_type: + query = query.filter(GovernanceAsset.asset_type == asset_type) + if governance_status: + query = query.filter(GovernanceAsset.governance_status == governance_status) + if review_status: + query = query.filter(GovernanceAsset.review_status == review_status) + if production_status: + query = query.filter(GovernanceAsset.production_status == production_status) + return ( + query.order_by(GovernanceAsset.updated_at.desc(), GovernanceAsset.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + def count( + self, + tenant_id: str, + asset_ids: Optional[Iterable[str]] = None, + asset_type: Optional[str] = None, + governance_status: Optional[str] = None, + review_status: Optional[str] = None, + production_status: Optional[str] = None, + ) -> int: + query = self.db.query(GovernanceAsset).filter(GovernanceAsset.tenant_id == tenant_id) + if asset_ids is not None: + normalized_asset_ids = [item for item in asset_ids if item] + if not normalized_asset_ids: + return 0 + query = query.filter(GovernanceAsset.id.in_(normalized_asset_ids)) + if asset_type: + query = query.filter(GovernanceAsset.asset_type == asset_type) + if governance_status: + query = query.filter(GovernanceAsset.governance_status == governance_status) + if review_status: + query = query.filter(GovernanceAsset.review_status == review_status) + if production_status: + query = query.filter(GovernanceAsset.production_status == production_status) + return query.count() + + def count_grouped_by_status(self, tenant_id: str, column_name: str) -> dict[str, int]: + column = getattr(GovernanceAsset, column_name) + rows = ( + self.db.query(column, func.count(GovernanceAsset.id)) + .filter(GovernanceAsset.tenant_id == tenant_id) + .group_by(column) + .all() + ) + return {status or "unknown": count for status, count in rows} + + def list_by_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> List[GovernanceAsset]: + asset_ids = [item for item in governance_asset_ids if item] + if not asset_ids: + return [] + return ( + self.db.query(GovernanceAsset) + .filter(GovernanceAsset.tenant_id == tenant_id, GovernanceAsset.id.in_(asset_ids)) + .all() + ) diff --git a/backend/app/repositories/governance_evidence_repository.py b/backend/app/repositories/governance_evidence_repository.py new file mode 100644 index 00000000..32e8a478 --- /dev/null +++ b/backend/app/repositories/governance_evidence_repository.py @@ -0,0 +1,67 @@ +from typing import Iterable, List, Optional + +from sqlalchemy.orm import Session + +from app.models.governance_evidence import GovernanceEvidence + + +class GovernanceEvidenceRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, evidence: GovernanceEvidence) -> GovernanceEvidence: + self.db.add(evidence) + self.db.commit() + self.db.refresh(evidence) + return evidence + + def update(self, evidence: GovernanceEvidence) -> GovernanceEvidence: + self.db.add(evidence) + self.db.commit() + self.db.refresh(evidence) + return evidence + + def get_by_id(self, evidence_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceEvidence]: + query = self.db.query(GovernanceEvidence).filter(GovernanceEvidence.id == evidence_id) + if tenant_id is not None: + query = query.filter(GovernanceEvidence.tenant_id == tenant_id) + return query.first() + + def get_latest_by_asset(self, tenant_id: str, governance_asset_id: str) -> Optional[GovernanceEvidence]: + return ( + self.db.query(GovernanceEvidence) + .filter( + GovernanceEvidence.tenant_id == tenant_id, + GovernanceEvidence.governance_asset_id == governance_asset_id, + ) + .order_by(GovernanceEvidence.collected_at.desc().nullslast(), GovernanceEvidence.created_at.desc()) + .first() + ) + + def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceEvidence]: + return ( + self.db.query(GovernanceEvidence) + .filter( + GovernanceEvidence.tenant_id == tenant_id, + GovernanceEvidence.governance_asset_id == governance_asset_id, + ) + .order_by(GovernanceEvidence.collected_at.desc().nullslast(), GovernanceEvidence.created_at.desc()) + .all() + ) + + def count_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> dict[str, int]: + asset_ids = [item for item in governance_asset_ids if item] + if not asset_ids: + return {} + rows = ( + self.db.query(GovernanceEvidence.governance_asset_id, GovernanceEvidence.id) + .filter( + GovernanceEvidence.tenant_id == tenant_id, + GovernanceEvidence.governance_asset_id.in_(asset_ids), + ) + .all() + ) + counts: dict[str, int] = {} + for governance_asset_id, _ in rows: + counts[governance_asset_id] = counts.get(governance_asset_id, 0) + 1 + return counts diff --git a/backend/app/repositories/governance_gate_run_repository.py b/backend/app/repositories/governance_gate_run_repository.py new file mode 100644 index 00000000..c7d1d8fd --- /dev/null +++ b/backend/app/repositories/governance_gate_run_repository.py @@ -0,0 +1,27 @@ +from typing import List + +from sqlalchemy.orm import Session + +from app.models.governance_gate_run import GovernanceGateRun + + +class GovernanceGateRunRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, gate_run: GovernanceGateRun) -> GovernanceGateRun: + self.db.add(gate_run) + self.db.commit() + self.db.refresh(gate_run) + return gate_run + + def list_by_release_batch_id(self, tenant_id: str, release_batch_id: str) -> List[GovernanceGateRun]: + return ( + self.db.query(GovernanceGateRun) + .filter( + GovernanceGateRun.tenant_id == tenant_id, + GovernanceGateRun.release_batch_id == release_batch_id, + ) + .order_by(GovernanceGateRun.executed_at.desc(), GovernanceGateRun.created_at.desc()) + .all() + ) diff --git a/backend/app/repositories/governance_release_batch_repository.py b/backend/app/repositories/governance_release_batch_repository.py new file mode 100644 index 00000000..2e903036 --- /dev/null +++ b/backend/app/repositories/governance_release_batch_repository.py @@ -0,0 +1,37 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.models.governance_release_batch import GovernanceReleaseBatch + + +class GovernanceReleaseBatchRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, release_batch: GovernanceReleaseBatch) -> GovernanceReleaseBatch: + self.db.add(release_batch) + self.db.commit() + self.db.refresh(release_batch) + return release_batch + + def update(self, release_batch: GovernanceReleaseBatch) -> GovernanceReleaseBatch: + self.db.add(release_batch) + self.db.commit() + self.db.refresh(release_batch) + return release_batch + + def get_by_id(self, release_batch_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceReleaseBatch]: + query = self.db.query(GovernanceReleaseBatch).filter(GovernanceReleaseBatch.id == release_batch_id) + if tenant_id is not None: + query = query.filter(GovernanceReleaseBatch.tenant_id == tenant_id) + return query.first() + + def list_recent(self, tenant_id: str, limit: int = 10) -> List[GovernanceReleaseBatch]: + return ( + self.db.query(GovernanceReleaseBatch) + .filter(GovernanceReleaseBatch.tenant_id == tenant_id) + .order_by(GovernanceReleaseBatch.started_at.desc(), GovernanceReleaseBatch.created_at.desc()) + .limit(limit) + .all() + ) diff --git a/backend/app/repositories/governance_review_assignment_repository.py b/backend/app/repositories/governance_review_assignment_repository.py new file mode 100644 index 00000000..c0782ce3 --- /dev/null +++ b/backend/app/repositories/governance_review_assignment_repository.py @@ -0,0 +1,122 @@ +from typing import Iterable, List, Optional + +from sqlalchemy.orm import Session + +from app.models.governance_review_assignment import GovernanceReviewAssignment + + +class GovernanceReviewAssignmentRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, assignment: GovernanceReviewAssignment) -> GovernanceReviewAssignment: + self.db.add(assignment) + self.db.commit() + self.db.refresh(assignment) + return assignment + + def update(self, assignment: GovernanceReviewAssignment) -> GovernanceReviewAssignment: + self.db.add(assignment) + self.db.commit() + self.db.refresh(assignment) + return assignment + + def get_by_id(self, assignment_id: str, tenant_id: Optional[str] = None) -> Optional[GovernanceReviewAssignment]: + query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.id == assignment_id) + if tenant_id is not None: + query = query.filter(GovernanceReviewAssignment.tenant_id == tenant_id) + return query.first() + + def get_active_by_asset_role(self, tenant_id: str, governance_asset_id: str, assignment_role: str) -> Optional[GovernanceReviewAssignment]: + return ( + self.db.query(GovernanceReviewAssignment) + .filter( + GovernanceReviewAssignment.tenant_id == tenant_id, + GovernanceReviewAssignment.governance_asset_id == governance_asset_id, + GovernanceReviewAssignment.assignment_role == assignment_role, + GovernanceReviewAssignment.is_active.is_(True), + ) + .order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc()) + .first() + ) + + def list_paginated( + self, + tenant_id: str, + page: int, + page_size: int, + assignment_role: Optional[str] = None, + status: Optional[str] = None, + assignee_user_id: Optional[str] = None, + governance_asset_id: Optional[str] = None, + ) -> List[GovernanceReviewAssignment]: + query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.tenant_id == tenant_id) + if assignment_role: + query = query.filter(GovernanceReviewAssignment.assignment_role == assignment_role) + if status: + query = query.filter(GovernanceReviewAssignment.status == status) + if assignee_user_id: + query = query.filter(GovernanceReviewAssignment.assignee_user_id == assignee_user_id) + if governance_asset_id: + query = query.filter(GovernanceReviewAssignment.governance_asset_id == governance_asset_id) + return ( + query.order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + def count( + self, + tenant_id: str, + assignment_role: Optional[str] = None, + status: Optional[str] = None, + assignee_user_id: Optional[str] = None, + governance_asset_id: Optional[str] = None, + ) -> int: + query = self.db.query(GovernanceReviewAssignment).filter(GovernanceReviewAssignment.tenant_id == tenant_id) + if assignment_role: + query = query.filter(GovernanceReviewAssignment.assignment_role == assignment_role) + if status: + query = query.filter(GovernanceReviewAssignment.status == status) + if assignee_user_id: + query = query.filter(GovernanceReviewAssignment.assignee_user_id == assignee_user_id) + if governance_asset_id: + query = query.filter(GovernanceReviewAssignment.governance_asset_id == governance_asset_id) + return query.count() + + def list_active_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> List[GovernanceReviewAssignment]: + asset_ids = [item for item in governance_asset_ids if item] + if not asset_ids: + return [] + return ( + self.db.query(GovernanceReviewAssignment) + .filter( + GovernanceReviewAssignment.tenant_id == tenant_id, + GovernanceReviewAssignment.governance_asset_id.in_(asset_ids), + GovernanceReviewAssignment.is_active.is_(True), + ) + .all() + ) + + def list_active_by_role(self, tenant_id: str, assignment_role: str) -> List[GovernanceReviewAssignment]: + return ( + self.db.query(GovernanceReviewAssignment) + .filter( + GovernanceReviewAssignment.tenant_id == tenant_id, + GovernanceReviewAssignment.assignment_role == assignment_role, + GovernanceReviewAssignment.is_active.is_(True), + ) + .all() + ) + + def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceReviewAssignment]: + return ( + self.db.query(GovernanceReviewAssignment) + .filter( + GovernanceReviewAssignment.tenant_id == tenant_id, + GovernanceReviewAssignment.governance_asset_id == governance_asset_id, + ) + .order_by(GovernanceReviewAssignment.updated_at.desc(), GovernanceReviewAssignment.created_at.desc()) + .all() + ) diff --git a/backend/app/repositories/governance_review_decision_repository.py b/backend/app/repositories/governance_review_decision_repository.py new file mode 100644 index 00000000..983ce011 --- /dev/null +++ b/backend/app/repositories/governance_review_decision_repository.py @@ -0,0 +1,56 @@ +from typing import Iterable, List, Optional + +from sqlalchemy.orm import Session + +from app.models.governance_review_decision import GovernanceReviewDecision + + +class GovernanceReviewDecisionRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, decision: GovernanceReviewDecision) -> GovernanceReviewDecision: + self.db.add(decision) + self.db.commit() + self.db.refresh(decision) + return decision + + def get_latest_by_asset_role(self, tenant_id: str, governance_asset_id: str, decision_role: str) -> Optional[GovernanceReviewDecision]: + return ( + self.db.query(GovernanceReviewDecision) + .filter( + GovernanceReviewDecision.tenant_id == tenant_id, + GovernanceReviewDecision.governance_asset_id == governance_asset_id, + GovernanceReviewDecision.decision_role == decision_role, + ) + .order_by(GovernanceReviewDecision.decision_at.desc().nullslast(), GovernanceReviewDecision.created_at.desc()) + .first() + ) + + def list_by_asset_id(self, tenant_id: str, governance_asset_id: str) -> List[GovernanceReviewDecision]: + return ( + self.db.query(GovernanceReviewDecision) + .filter( + GovernanceReviewDecision.tenant_id == tenant_id, + GovernanceReviewDecision.governance_asset_id == governance_asset_id, + ) + .order_by(GovernanceReviewDecision.decision_at.desc().nullslast(), GovernanceReviewDecision.created_at.desc()) + .all() + ) + + def count_by_asset_ids(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> dict[str, int]: + asset_ids = [item for item in governance_asset_ids if item] + if not asset_ids: + return {} + rows = ( + self.db.query(GovernanceReviewDecision.governance_asset_id, GovernanceReviewDecision.id) + .filter( + GovernanceReviewDecision.tenant_id == tenant_id, + GovernanceReviewDecision.governance_asset_id.in_(asset_ids), + ) + .all() + ) + counts: dict[str, int] = {} + for governance_asset_id, _ in rows: + counts[governance_asset_id] = counts.get(governance_asset_id, 0) + 1 + return counts diff --git a/backend/app/services/governance_service.py b/backend/app/services/governance_service.py new file mode 100644 index 00000000..75d09c64 --- /dev/null +++ b/backend/app/services/governance_service.py @@ -0,0 +1,1011 @@ +import csv +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, Optional +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.governance_asset import GovernanceAsset +from app.models.governance_evidence import GovernanceEvidence +from app.models.governance_gate_run import GovernanceGateRun +from app.models.governance_release_batch import GovernanceReleaseBatch +from app.models.governance_review_assignment import GovernanceReviewAssignment +from app.models.governance_review_decision import GovernanceReviewDecision +from app.repositories.governance_asset_repository import GovernanceAssetRepository +from app.repositories.governance_evidence_repository import GovernanceEvidenceRepository +from app.repositories.governance_gate_run_repository import GovernanceGateRunRepository +from app.repositories.governance_release_batch_repository import GovernanceReleaseBatchRepository +from app.repositories.governance_review_assignment_repository import GovernanceReviewAssignmentRepository +from app.repositories.governance_review_decision_repository import GovernanceReviewDecisionRepository + + +class GovernanceService: + def __init__(self, db: Session): + self.db = db + self.asset_repository = GovernanceAssetRepository(db) + self.assignment_repository = GovernanceReviewAssignmentRepository(db) + self.decision_repository = GovernanceReviewDecisionRepository(db) + self.evidence_repository = GovernanceEvidenceRepository(db) + self.release_batch_repository = GovernanceReleaseBatchRepository(db) + self.gate_run_repository = GovernanceGateRunRepository(db) + self.repo_root = Path(__file__).resolve().parents[3] + + def list_assets( + self, + current_user: Dict[str, Any], + page: int = 1, + page_size: int = 20, + asset_type: Optional[str] = None, + governance_status: Optional[str] = None, + review_status: Optional[str] = None, + production_status: Optional[str] = None, + assignment_role: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + normalized_page = max(page, 1) + normalized_page_size = max(min(page_size, 100), 1) + asset_ids_filter = None + if assignment_role: + role = assignment_role.strip() + asset_ids_filter = [item.governance_asset_id for item in self.assignment_repository.list_active_by_role(tenant_id, role)] + assets = self.asset_repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + asset_ids_filter, + asset_type.strip() if asset_type else None, + governance_status.strip() if governance_status else None, + review_status.strip() if review_status else None, + production_status.strip() if production_status else None, + ) + assignment_map = self._group_assignments_by_asset(tenant_id, [asset.id for asset in assets]) + decision_counts = self.decision_repository.count_by_asset_ids(tenant_id, [asset.id for asset in assets]) + evidence_counts = self.evidence_repository.count_by_asset_ids(tenant_id, [asset.id for asset in assets]) + return { + "items": [ + self._to_asset_item( + asset, + assignment_map.get(asset.id, []), + decision_counts.get(asset.id, 0), + evidence_counts.get(asset.id, 0), + ) + for asset in assets + ], + "page": normalized_page, + "page_size": normalized_page_size, + "total": self.asset_repository.count( + tenant_id, + asset_ids_filter, + asset_type.strip() if asset_type else None, + governance_status.strip() if governance_status else None, + review_status.strip() if review_status else None, + production_status.strip() if production_status else None, + ), + "summary": self._build_asset_summary(tenant_id), + } + + def get_asset_detail(self, current_user: Dict[str, Any], governance_asset_id: str) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, governance_asset_id) + assignments = self.assignment_repository.list_by_asset_id(current_user["tenant_id"], governance_asset_id) + decisions = self.decision_repository.list_by_asset_id(current_user["tenant_id"], governance_asset_id) + evidences = self.evidence_repository.list_by_asset_id(current_user["tenant_id"], governance_asset_id) + return { + "asset": self._to_asset_item(asset, assignments, len(decisions), len(evidences)), + "assignments": [self._to_assignment_item(item) for item in assignments], + "decisions": [self._to_decision_item(item) for item in decisions], + "evidences": [self._to_evidence_item(item) for item in evidences], + } + + def list_assignments( + self, + current_user: Dict[str, Any], + page: int = 1, + page_size: int = 20, + assignment_role: Optional[str] = None, + status_filter: Optional[str] = None, + assignee_user_id: Optional[str] = None, + governance_asset_id: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + normalized_page = max(page, 1) + normalized_page_size = max(min(page_size, 100), 1) + assignments = self.assignment_repository.list_paginated( + tenant_id, + normalized_page, + normalized_page_size, + assignment_role.strip() if assignment_role else None, + status_filter.strip() if status_filter else None, + assignee_user_id.strip() if assignee_user_id else None, + governance_asset_id.strip() if governance_asset_id else None, + ) + asset_map = {asset.id: asset for asset in self.asset_repository.list_by_ids(tenant_id, [item.governance_asset_id for item in assignments])} + items = [] + for assignment in assignments: + item = self._to_assignment_item(assignment) + item["asset_name"] = asset_map.get(assignment.governance_asset_id).asset_name if asset_map.get(assignment.governance_asset_id) else None + item["asset_type"] = asset_map.get(assignment.governance_asset_id).asset_type if asset_map.get(assignment.governance_asset_id) else None + items.append(item) + return { + "items": items, + "page": normalized_page, + "page_size": normalized_page_size, + "total": self.assignment_repository.count( + tenant_id, + assignment_role.strip() if assignment_role else None, + status_filter.strip() if status_filter else None, + assignee_user_id.strip() if assignee_user_id else None, + governance_asset_id.strip() if governance_asset_id else None, + ), + } + + def save_assignment( + self, + current_user: Dict[str, Any], + governance_asset_id: str, + assignment_role: str, + assignee_user_id: Optional[str], + assignee_name: Optional[str], + due_at: Optional[str], + status_value: Optional[str], + priority: Optional[str], + notes: Optional[str], + assignment_id: Optional[str] = None, + is_active: bool = True, + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, governance_asset_id) + tenant_id = current_user["tenant_id"] + normalized_role = assignment_role.strip() + assignment = None + if assignment_id: + assignment = self.assignment_repository.get_by_id(assignment_id, tenant_id) + if assignment is None: + assignment = self.assignment_repository.get_active_by_asset_role(tenant_id, governance_asset_id, normalized_role) + if assignment is None: + assignment = GovernanceReviewAssignment( + id=f"govasg-{uuid4().hex[:12]}", + tenant_id=tenant_id, + governance_asset_id=governance_asset_id, + assignment_role=normalized_role, + assignee_user_id=assignee_user_id.strip() if assignee_user_id else None, + assignee_name=assignee_name.strip() if assignee_name else None, + due_at=self._parse_datetime(due_at), + status=(status_value or "pending").strip(), + priority=(priority or "normal").strip(), + is_active=is_active, + notes=notes.strip() if notes else None, + ) + saved = self.assignment_repository.create(assignment) + else: + assignment.assignment_role = normalized_role + assignment.assignee_user_id = assignee_user_id.strip() if assignee_user_id else None + assignment.assignee_name = assignee_name.strip() if assignee_name else None + assignment.due_at = self._parse_datetime(due_at) + assignment.status = (status_value or assignment.status or "pending").strip() + assignment.priority = (priority or assignment.priority or "normal").strip() + assignment.is_active = is_active + assignment.notes = notes.strip() if notes else None + saved = self.assignment_repository.update(assignment) + + refreshed_asset = self._refresh_asset_status(asset) + item = self._to_assignment_item(saved) + item["asset_name"] = refreshed_asset.asset_name + item["asset_type"] = refreshed_asset.asset_type + return item + + def submit_decision( + self, + current_user: Dict[str, Any], + governance_asset_id: str, + assignment_id: Optional[str], + decision_role: str, + decision_result: str, + decision_summary: Optional[str], + required_changes: Optional[str], + decided_by_name: Optional[str], + decision_at: Optional[str], + metadata_json: Optional[str], + ) -> dict: + asset = self._require_asset(current_user, governance_asset_id) + tenant_id = current_user["tenant_id"] + assignment = None + if assignment_id: + assignment = self.assignment_repository.get_by_id(assignment_id, tenant_id) + if assignment is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="assignment not found") + self._require_decision_access(current_user, assignment) + decision = GovernanceReviewDecision( + id=f"govdec-{uuid4().hex[:12]}", + tenant_id=tenant_id, + governance_asset_id=governance_asset_id, + assignment_id=assignment.id if assignment else None, + decision_role=decision_role.strip(), + decision_result=decision_result.strip(), + decision_summary=decision_summary.strip() if decision_summary else None, + required_changes=required_changes.strip() if required_changes else None, + decided_by_user_id=current_user.get("id"), + decided_by_name=(decided_by_name or current_user.get("display_name") or current_user.get("username") or current_user.get("id")), + decision_at=self._parse_datetime(decision_at) or datetime.now(timezone.utc), + metadata_json=self._parse_json(metadata_json), + ) + saved = self.decision_repository.create(decision) + if assignment is not None: + assignment.status = self._map_assignment_status_from_decision(saved.decision_result) + self.assignment_repository.update(assignment) + refreshed_asset = self._refresh_asset_status(asset) + return { + "decision": self._to_decision_item(saved), + "asset": self._to_asset_item(refreshed_asset, self.assignment_repository.list_by_asset_id(tenant_id, governance_asset_id), len(self.decision_repository.list_by_asset_id(tenant_id, governance_asset_id)), len(self.evidence_repository.list_by_asset_id(tenant_id, governance_asset_id))), + } + + def save_evidence( + self, + current_user: Dict[str, Any], + governance_asset_id: str, + decision_id: Optional[str], + file_id: Optional[str], + evidence_type: Optional[str], + storage_type: Optional[str], + external_url: Optional[str], + file_name: Optional[str], + checksum: Optional[str], + status_value: Optional[str], + reviewer_name: Optional[str], + collected_at: Optional[str], + metadata_json: Optional[str], + ) -> dict: + self._require_admin(current_user) + asset = self._require_asset(current_user, governance_asset_id) + tenant_id = current_user["tenant_id"] + normalized_status = (status_value or "template").strip() + normalized_storage_type = (storage_type or ("external" if external_url else "internal")).strip() + if normalized_storage_type == "external" and not (external_url and external_url.strip()): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="external_url is required for external evidence") + if normalized_status.lower() in {"ready", "approved", "signed"} and not (file_id or (external_url and external_url.strip())): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ready evidence must include file_id or external_url") + evidence = GovernanceEvidence( + id=f"govev-{uuid4().hex[:12]}", + tenant_id=tenant_id, + governance_asset_id=governance_asset_id, + decision_id=decision_id.strip() if decision_id else None, + file_id=file_id.strip() if file_id else None, + evidence_type=(evidence_type or "document").strip(), + storage_type=normalized_storage_type, + external_url=external_url.strip() if external_url else None, + file_name=file_name.strip() if file_name else None, + checksum=checksum.strip() if checksum else None, + status=normalized_status, + reviewer_name=(reviewer_name or current_user.get("display_name") or current_user.get("username") or current_user.get("id")), + collected_at=self._parse_datetime(collected_at) or datetime.now(timezone.utc), + metadata_json=self._parse_json(metadata_json), + ) + saved = self.evidence_repository.create(evidence) + refreshed_asset = self._refresh_asset_status(asset) + return { + "evidence": self._to_evidence_item(saved), + "asset": self._to_asset_item(refreshed_asset, self.assignment_repository.list_by_asset_id(tenant_id, governance_asset_id), len(self.decision_repository.list_by_asset_id(tenant_id, governance_asset_id)), len(self.evidence_repository.list_by_asset_id(tenant_id, governance_asset_id))), + } + + def get_release_summary(self, current_user: Dict[str, Any]) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + recent_batches = self.release_batch_repository.list_recent(tenant_id, limit=10) + summary = self._build_asset_summary(tenant_id) + return { + "summary": summary, + "recent_batches": [self._to_release_batch_item(item) for item in recent_batches], + } + + def get_release_detail(self, current_user: Dict[str, Any], release_batch_id: str) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + release_batch = self.release_batch_repository.get_by_id(release_batch_id, tenant_id) + if release_batch is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="release batch not found") + gate_runs = self.gate_run_repository.list_by_release_batch_id(tenant_id, release_batch_id) + return { + "release_batch": self._to_release_batch_item(release_batch), + "gate_runs": [self._to_gate_run_item(item) for item in gate_runs], + } + + def import_formal_launch(self, current_user: Dict[str, Any], batch_name: Optional[str] = None) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + imported = { + "assets": 0, + "assignments": 0, + "decisions": 0, + "evidences": 0, + } + + review_task_rows = self._read_csv_rows(self.repo_root / "project-docs" / "formal-launch-ops" / "reviewers" / "real-review-task-package.csv") + material_rows = self._read_csv_rows(self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-material-signoff-register.csv") + template_rows = self._read_csv_rows(self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-template-signoff-register.csv") + knowledge_rows = self._read_csv_rows(self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-knowledge-signoff-register.csv") + + asset_map: dict[tuple[str, str], GovernanceAsset] = {} + + for row in review_task_rows: + asset, asset_created = self._upsert_governance_asset( + tenant_id, + asset_type=(row.get("asset_type") or "unknown").strip(), + asset_id=(row.get("asset_id") or "").strip(), + asset_name=(row.get("asset_name") or row.get("asset_id") or "").strip(), + source_path=(row.get("asset_path") or "").strip() or None, + source_table="formal_launch_review_tasks", + metadata={ + "review_track": row.get("review_track"), + "required_reviewer_role": row.get("required_reviewer_role"), + "source_id": row.get("source_id"), + }, + ) + asset_map[(asset.asset_type, asset.asset_id)] = asset + imported["assets"] += 1 if asset_created else 0 + for assignment_role in self._expand_assignment_roles((row.get("review_track") or "").strip(), (row.get("required_reviewer_role") or "").strip()): + saved = self._upsert_assignment_for_import( + tenant_id=tenant_id, + asset=asset, + assignment_role=assignment_role, + assignee_name=(row.get("assigned_reviewer") or "").strip() or None, + due_at=(row.get("due_date") or "").strip() or None, + status_value=(row.get("review_status") or "pending").strip() or "pending", + notes=(row.get("notes") or "").strip() or None, + ) + if saved: + imported["assignments"] += 1 + if self._should_import_decision((row.get("approval_result") or "").strip()): + decision = self._import_decision_row( + current_user=current_user, + asset=asset, + decision_role="review_task", + decision_result=(row.get("approval_result") or "").strip(), + decision_summary=(row.get("notes") or "").strip() or None, + required_changes=(row.get("required_changes") or "").strip() or None, + decided_by_name=(row.get("assigned_reviewer") or "").strip() or None, + decision_at=(row.get("due_date") or "").strip() or None, + ) + imported["decisions"] += 1 if decision else 0 + evidence = self._import_evidence_row( + current_user=current_user, + asset=asset, + decision_id=None, + evidence_path=(row.get("evidence_path") or "").strip() or None, + reviewer_name=(row.get("assigned_reviewer") or "").strip() or None, + ) + imported["evidences"] += 1 if evidence else 0 + + for row in material_rows: + asset, asset_created = self._upsert_governance_asset( + tenant_id, + asset_type="production_material", + asset_id=(row.get("material_id") or "").strip(), + asset_name=(row.get("material_name") or row.get("material_id") or "").strip(), + source_path=(row.get("file_path") or "").strip() or None, + source_table="formal_launch_material_signoff", + metadata={"signoff_role": row.get("signoff_role")}, + ) + asset_map[(asset.asset_type, asset.asset_id)] = asset + imported["assets"] += 1 if asset_created else 0 + assignment_created = self._upsert_assignment_for_import( + tenant_id=tenant_id, + asset=asset, + assignment_role=self._normalize_assignment_role((row.get("signoff_role") or "").strip() or "material_signoff"), + assignee_name=(row.get("signer_name") or row.get("owner") or "").strip() or None, + due_at=(row.get("signoff_date") or "").strip() or None, + status_value=(row.get("status") or row.get("review_status") or "pending").strip() or "pending", + notes=(row.get("notes") or "").strip() or None, + ) + imported["assignments"] += 1 if assignment_created else 0 + if self._should_import_decision((row.get("signoff_result") or "").strip()): + self._import_decision_row( + current_user=current_user, + asset=asset, + decision_role="material_signoff", + decision_result=(row.get("signoff_result") or "").strip(), + decision_summary=(row.get("notes") or "").strip() or None, + required_changes=None, + decided_by_name=(row.get("signer_name") or row.get("owner") or "").strip() or None, + decision_at=(row.get("signoff_date") or "").strip() or None, + ) + imported["decisions"] += 1 + evidence = self._import_evidence_row( + current_user=current_user, + asset=asset, + decision_id=None, + evidence_path=(row.get("evidence_path") or "").strip() or None, + reviewer_name=(row.get("signer_name") or row.get("owner") or "").strip() or None, + ) + imported["evidences"] += 1 if evidence else 0 + + for row in template_rows: + asset = asset_map.get(("contract_template", (row.get("template_id") or "").strip())) + if asset is None: + asset, asset_created = self._upsert_governance_asset( + tenant_id, + asset_type="contract_template", + asset_id=(row.get("template_id") or "").strip(), + asset_name=(row.get("template_name") or row.get("template_id") or "").strip(), + source_path=(row.get("template_path") or "").strip() or None, + source_table="formal_launch_template_signoff", + metadata={"contract_type": row.get("contract_type")}, + ) + imported["assets"] += 1 if asset_created else 0 + legal_assignment_created = self._upsert_assignment_for_import( + tenant_id=tenant_id, + asset=asset, + assignment_role="legal_reviewer", + assignee_name=(row.get("legal_reviewer") or "").strip() or None, + due_at=(row.get("signoff_date") or "").strip() or None, + status_value=(row.get("legal_signoff_result") or "pending").strip() or "pending", + notes=(row.get("notes") or "").strip() or None, + ) + business_assignment_created = self._upsert_assignment_for_import( + tenant_id=tenant_id, + asset=asset, + assignment_role="business_reviewer", + assignee_name=(row.get("business_reviewer") or "").strip() or None, + due_at=(row.get("signoff_date") or "").strip() or None, + status_value=(row.get("business_signoff_result") or "pending").strip() or "pending", + notes=(row.get("notes") or "").strip() or None, + ) + imported["assignments"] += 1 if legal_assignment_created else 0 + imported["assignments"] += 1 if business_assignment_created else 0 + if self._should_import_decision((row.get("production_approval_result") or "").strip()): + self._import_decision_row( + current_user=current_user, + asset=asset, + decision_role="production_approver", + decision_result=(row.get("production_approval_result") or "").strip(), + decision_summary=(row.get("notes") or "").strip() or None, + required_changes=None, + decided_by_name=(row.get("business_reviewer") or row.get("legal_reviewer") or "").strip() or None, + decision_at=(row.get("signoff_date") or "").strip() or None, + ) + imported["decisions"] += 1 + evidence = self._import_evidence_row( + current_user=current_user, + asset=asset, + decision_id=None, + evidence_path=(row.get("evidence_path") or "").strip() or None, + reviewer_name=(row.get("business_reviewer") or row.get("legal_reviewer") or "").strip() or None, + ) + imported["evidences"] += 1 if evidence else 0 + + for row in knowledge_rows: + asset = asset_map.get((((row.get("asset_type") or "").strip() or "legal_knowledge_card"), (row.get("asset_id") or "").strip())) + if asset is None: + asset, asset_created = self._upsert_governance_asset( + tenant_id, + asset_type=(row.get("asset_type") or "legal_knowledge_card").strip(), + asset_id=(row.get("asset_id") or "").strip(), + asset_name=(row.get("asset_name") or row.get("asset_id") or "").strip(), + source_path=(row.get("asset_path") or "").strip() or None, + source_table="formal_launch_knowledge_signoff", + metadata={}, + ) + imported["assets"] += 1 if asset_created else 0 + assignment_created = self._upsert_assignment_for_import( + tenant_id=tenant_id, + asset=asset, + assignment_role="legal_reviewer", + assignee_name=(row.get("reviewer") or "").strip() or None, + due_at=(row.get("signoff_date") or "").strip() or None, + status_value=(row.get("legal_signoff_result") or "pending").strip() or "pending", + notes=(row.get("notes") or "").strip() or None, + ) + imported["assignments"] += 1 if assignment_created else 0 + if self._should_import_decision((row.get("production_approval_result") or "").strip()): + self._import_decision_row( + current_user=current_user, + asset=asset, + decision_role="legal_reviewer", + decision_result=(row.get("production_approval_result") or "").strip(), + decision_summary=(row.get("notes") or "").strip() or None, + required_changes=None, + decided_by_name=(row.get("reviewer") or "").strip() or None, + decision_at=(row.get("signoff_date") or "").strip() or None, + ) + imported["decisions"] += 1 + evidence = self._import_evidence_row( + current_user=current_user, + asset=asset, + decision_id=None, + evidence_path=(row.get("evidence_path") or "").strip() or None, + reviewer_name=(row.get("reviewer") or "").strip() or None, + ) + imported["evidences"] += 1 if evidence else 0 + + summary = self._build_asset_summary(tenant_id) + release_batch = GovernanceReleaseBatch( + id=f"govrel-{uuid4().hex[:12]}", + tenant_id=tenant_id, + batch_name=(batch_name or f"formal-launch-import-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}").strip(), + target_env="formal_launch", + release_version=None, + release_commit=None, + status=self._derive_release_status(summary), + started_by_user_id=current_user.get("id"), + started_by_name=current_user.get("display_name") or current_user.get("username") or current_user.get("id"), + summary_json=summary, + notes="Imported from formal-launch CSV and signoff registers.", + ) + saved_batch = self.release_batch_repository.create(release_batch) + gate_run = GovernanceGateRun( + id=f"govgate-{uuid4().hex[:12]}", + tenant_id=tenant_id, + release_batch_id=saved_batch.id, + gate_name="formal_launch_import", + gate_stage="formal_launch", + status=saved_batch.status, + executed_by_user_id=current_user.get("id"), + executed_by_name=current_user.get("display_name") or current_user.get("username") or current_user.get("id"), + artifact_path="project-docs/formal-launch-ops", + summary_json=summary, + blocking_items_json=summary.get("blocking_items"), + ) + saved_gate_run = self.gate_run_repository.create(gate_run) + return { + "imported": imported, + "summary": summary, + "release_batch": self._to_release_batch_item(saved_batch), + "gate_run": self._to_gate_run_item(saved_gate_run), + } + + def _require_admin(self, current_user: Dict[str, Any]) -> None: + if current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + def _require_asset(self, current_user: Dict[str, Any], governance_asset_id: str) -> GovernanceAsset: + asset = self.asset_repository.get_by_id(governance_asset_id, current_user["tenant_id"]) + if asset is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="governance asset not found") + return asset + + def _require_decision_access(self, current_user: Dict[str, Any], assignment: Optional[GovernanceReviewAssignment]) -> None: + if current_user.get("role") == "admin": + return + if assignment is not None and assignment.assignee_user_id and assignment.assignee_user_id == current_user.get("id"): + return + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden") + + def _to_asset_item( + self, + asset: GovernanceAsset, + assignments: list[GovernanceReviewAssignment], + decision_count: int, + evidence_count: int, + ) -> dict: + return { + "id": asset.id, + "tenant_id": asset.tenant_id, + "asset_type": asset.asset_type, + "asset_id": asset.asset_id, + "asset_name": asset.asset_name, + "source_table": asset.source_table, + "source_path": asset.source_path, + "governance_status": asset.governance_status, + "review_status": asset.review_status, + "production_status": asset.production_status, + "latest_decision_result": asset.latest_decision_result, + "latest_evidence_status": asset.latest_evidence_status, + "metadata": asset.metadata_json or {}, + "notes": asset.notes, + "active_assignment_roles": sorted({item.assignment_role for item in assignments if item.is_active}), + "decision_count": decision_count, + "evidence_count": evidence_count, + "created_at": asset.created_at.isoformat(), + "updated_at": asset.updated_at.isoformat(), + } + + def _to_assignment_item(self, assignment: GovernanceReviewAssignment) -> dict: + return { + "id": assignment.id, + "tenant_id": assignment.tenant_id, + "governance_asset_id": assignment.governance_asset_id, + "assignment_role": assignment.assignment_role, + "assignee_user_id": assignment.assignee_user_id, + "assignee_name": assignment.assignee_name, + "due_at": assignment.due_at.isoformat() if assignment.due_at else None, + "status": assignment.status, + "priority": assignment.priority, + "is_active": assignment.is_active, + "notes": assignment.notes, + "created_at": assignment.created_at.isoformat(), + "updated_at": assignment.updated_at.isoformat(), + } + + def _to_decision_item(self, decision: GovernanceReviewDecision) -> dict: + return { + "id": decision.id, + "tenant_id": decision.tenant_id, + "governance_asset_id": decision.governance_asset_id, + "assignment_id": decision.assignment_id, + "decision_role": decision.decision_role, + "decision_result": decision.decision_result, + "decision_summary": decision.decision_summary, + "required_changes": decision.required_changes, + "decided_by_user_id": decision.decided_by_user_id, + "decided_by_name": decision.decided_by_name, + "decision_at": decision.decision_at.isoformat() if decision.decision_at else None, + "expires_at": decision.expires_at.isoformat() if decision.expires_at else None, + "metadata": decision.metadata_json or {}, + "created_at": decision.created_at.isoformat(), + "updated_at": decision.updated_at.isoformat(), + } + + def _to_evidence_item(self, evidence: GovernanceEvidence) -> dict: + return { + "id": evidence.id, + "tenant_id": evidence.tenant_id, + "governance_asset_id": evidence.governance_asset_id, + "decision_id": evidence.decision_id, + "file_id": evidence.file_id, + "evidence_type": evidence.evidence_type, + "storage_type": evidence.storage_type, + "external_url": evidence.external_url, + "file_name": evidence.file_name, + "checksum": evidence.checksum, + "status": evidence.status, + "reviewer_name": evidence.reviewer_name, + "collected_at": evidence.collected_at.isoformat() if evidence.collected_at else None, + "metadata": evidence.metadata_json or {}, + "created_at": evidence.created_at.isoformat(), + "updated_at": evidence.updated_at.isoformat(), + } + + def _to_release_batch_item(self, release_batch: GovernanceReleaseBatch) -> dict: + return { + "id": release_batch.id, + "tenant_id": release_batch.tenant_id, + "batch_name": release_batch.batch_name, + "target_env": release_batch.target_env, + "release_version": release_batch.release_version, + "release_commit": release_batch.release_commit, + "status": release_batch.status, + "started_by_user_id": release_batch.started_by_user_id, + "started_by_name": release_batch.started_by_name, + "started_at": release_batch.started_at.isoformat(), + "completed_at": release_batch.completed_at.isoformat() if release_batch.completed_at else None, + "summary": release_batch.summary_json or {}, + "notes": release_batch.notes, + } + + def _to_gate_run_item(self, gate_run: GovernanceGateRun) -> dict: + return { + "id": gate_run.id, + "tenant_id": gate_run.tenant_id, + "release_batch_id": gate_run.release_batch_id, + "gate_name": gate_run.gate_name, + "gate_stage": gate_run.gate_stage, + "status": gate_run.status, + "executed_by_user_id": gate_run.executed_by_user_id, + "executed_by_name": gate_run.executed_by_name, + "executed_at": gate_run.executed_at.isoformat(), + "artifact_path": gate_run.artifact_path, + "summary": gate_run.summary_json or {}, + "blocking_items": gate_run.blocking_items_json or [], + } + + def _parse_json(self, payload: Optional[str]) -> Optional[dict[str, Any]]: + if not payload or not payload.strip(): + return None + try: + parsed = json.loads(payload) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="metadata_json must be valid JSON") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="metadata_json must be a JSON object") + return parsed + + def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]: + if not value or not value.strip(): + return None + normalized = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + try: + parsed = datetime.strptime(normalized, "%Y-%m-%d") + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid datetime format") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + def _refresh_asset_status(self, asset: GovernanceAsset) -> GovernanceAsset: + assignments = self.assignment_repository.list_by_asset_id(asset.tenant_id, asset.id) + decisions = self.decision_repository.list_by_asset_id(asset.tenant_id, asset.id) + evidences = self.evidence_repository.list_by_asset_id(asset.tenant_id, asset.id) + + active_assignments = [item for item in assignments if item.is_active] + decision_by_role: dict[str, GovernanceReviewDecision] = {} + for decision in decisions: + if decision.decision_role not in decision_by_role: + decision_by_role[decision.decision_role] = decision + + latest_evidence = evidences[0] if evidences else None + latest_decision = decisions[0] if decisions else None + + if any(decision.decision_result == "rejected" for decision in decision_by_role.values()): + asset.review_status = "rejected" + asset.governance_status = "blocked" + asset.production_status = "blocked" + elif any(decision.decision_result == "changes_required" for decision in decision_by_role.values()): + asset.review_status = "changes_required" + asset.governance_status = "in_revision" + asset.production_status = "blocked" + elif active_assignments and all(self._role_has_approved_decision(item.assignment_role, decision_by_role) for item in active_assignments): + asset.review_status = "approved" + asset.governance_status = "approved" + if self._production_ready(active_assignments, decision_by_role, latest_evidence): + asset.production_status = "ready" + else: + asset.production_status = "blocked" + elif active_assignments: + asset.review_status = "in_review" + asset.governance_status = "reviewing" + asset.production_status = "blocked" + else: + asset.review_status = "pending" + asset.governance_status = "draft" + asset.production_status = "blocked" + + asset.latest_decision_result = latest_decision.decision_result if latest_decision else None + asset.latest_evidence_status = latest_evidence.status if latest_evidence else None + return self.asset_repository.update(asset) + + def _role_has_approved_decision(self, assignment_role: str, decision_by_role: dict[str, GovernanceReviewDecision]) -> bool: + decision = decision_by_role.get(assignment_role) + return bool(decision and decision.decision_result == "approved") + + def _production_ready( + self, + active_assignments: list[GovernanceReviewAssignment], + decision_by_role: dict[str, GovernanceReviewDecision], + latest_evidence: Optional[GovernanceEvidence], + ) -> bool: + if latest_evidence is None or latest_evidence.status not in {"ready", "approved", "signed"}: + return False + if any(item.assignment_role == "production_approver" for item in active_assignments): + return self._role_has_approved_decision("production_approver", decision_by_role) + return all(self._role_has_approved_decision(item.assignment_role, decision_by_role) for item in active_assignments) + + def _build_asset_summary(self, tenant_id: str) -> dict: + total_assets = self.asset_repository.count(tenant_id) + governance_counts = self.asset_repository.count_grouped_by_status(tenant_id, "governance_status") + review_counts = self.asset_repository.count_grouped_by_status(tenant_id, "review_status") + production_counts = self.asset_repository.count_grouped_by_status(tenant_id, "production_status") + pending_assignments = self.assignment_repository.count(tenant_id, status="pending") + in_review_assignments = self.assignment_repository.count(tenant_id, status="in_review") + blocking_items: list[dict[str, Any]] = [] + if pending_assignments: + blocking_items.append({"type": "assignment", "status": "pending", "count": pending_assignments}) + blocked_assets = production_counts.get("blocked", 0) + if blocked_assets: + blocking_items.append({"type": "asset", "status": "blocked", "count": blocked_assets}) + return { + "total_assets": total_assets, + "governance_status": governance_counts, + "review_status": review_counts, + "production_status": production_counts, + "pending_assignments": pending_assignments, + "in_review_assignments": in_review_assignments, + "blocking_items": blocking_items, + } + + def _derive_release_status(self, summary: dict) -> str: + if summary.get("total_assets", 0) == 0: + return "draft" + if summary.get("blocking_items"): + return "blocked" + if summary.get("production_status", {}).get("ready", 0) > 0: + return "approved" + return "in_review" + + def _group_assignments_by_asset(self, tenant_id: str, governance_asset_ids: Iterable[str]) -> dict[str, list[GovernanceReviewAssignment]]: + grouped: dict[str, list[GovernanceReviewAssignment]] = {} + for item in self.assignment_repository.list_active_by_asset_ids(tenant_id, governance_asset_ids): + grouped.setdefault(item.governance_asset_id, []).append(item) + return grouped + + def _upsert_governance_asset( + self, + tenant_id: str, + asset_type: str, + asset_id: str, + asset_name: str, + source_path: Optional[str], + source_table: Optional[str], + metadata: Optional[dict[str, Any]] = None, + ) -> tuple[GovernanceAsset, bool]: + asset = self.asset_repository.get_by_identity(tenant_id, asset_type, asset_id) + if asset is None: + asset = GovernanceAsset( + id=f"govast-{uuid4().hex[:12]}", + tenant_id=tenant_id, + asset_type=asset_type, + asset_id=asset_id, + asset_name=asset_name, + source_table=source_table, + source_path=source_path, + governance_status="draft", + review_status="pending", + production_status="blocked", + metadata_json=metadata or {}, + ) + return self.asset_repository.create(asset), True + asset.asset_name = asset_name + asset.source_table = source_table + asset.source_path = source_path + asset.metadata_json = {**(asset.metadata_json or {}), **(metadata or {})} + return self.asset_repository.update(asset), False + + def _expand_assignment_roles(self, review_track: str, required_reviewer_role: str) -> list[str]: + roles: list[str] = [] + normalized_track = review_track.lower() + if "legal" in normalized_track or "法务" in required_reviewer_role: + roles.append("legal_reviewer") + if "business" in normalized_track or "业务" in required_reviewer_role: + roles.append("business_reviewer") + if "ops" in normalized_track or "运营" in required_reviewer_role or "交付" in required_reviewer_role: + roles.append("ops_reviewer") + if "final" in normalized_track or "批准" in required_reviewer_role: + roles.append("production_approver") + return roles or [self._normalize_assignment_role(required_reviewer_role or "reviewer")] + + def _normalize_assignment_role(self, role_value: str) -> str: + normalized = role_value.strip().lower() + mapping = { + "法务负责人": "legal_reviewer", + "法务复核人": "legal_reviewer", + "业务复核人": "business_reviewer", + "交付负责人": "ops_reviewer", + "运维负责人": "ops_reviewer", + "运营负责人": "ops_reviewer", + "上线批准负责人": "production_approver", + "material_signoff": "material_signoff", + } + return mapping.get(role_value.strip(), normalized.replace(" ", "_") or "reviewer") + + def _upsert_assignment_for_import( + self, + tenant_id: str, + asset: GovernanceAsset, + assignment_role: str, + assignee_name: Optional[str], + due_at: Optional[str], + status_value: str, + notes: Optional[str], + ) -> bool: + existing = self.assignment_repository.get_active_by_asset_role(tenant_id, asset.id, assignment_role) + if existing is None: + assignment = GovernanceReviewAssignment( + id=f"govasg-{uuid4().hex[:12]}", + tenant_id=tenant_id, + governance_asset_id=asset.id, + assignment_role=assignment_role, + assignee_name=assignee_name, + due_at=self._parse_datetime(due_at), + status=self._normalize_import_status(status_value), + priority="normal", + is_active=True, + notes=notes, + ) + self.assignment_repository.create(assignment) + return True + existing.assignee_name = assignee_name or existing.assignee_name + existing.due_at = self._parse_datetime(due_at) or existing.due_at + existing.status = self._normalize_import_status(status_value) + existing.notes = notes or existing.notes + self.assignment_repository.update(existing) + return False + + def _normalize_import_status(self, status_value: str) -> str: + normalized = status_value.strip().lower() + mapping = { + "approved": "approved", + "pass": "approved", + "signed": "approved", + "completed": "approved", + "in_review": "in_review", + "processing": "in_review", + "pending": "pending", + "blocked": "blocked", + "rejected": "rejected", + "changes_required": "changes_required", + } + return mapping.get(normalized, normalized or "pending") + + def _should_import_decision(self, decision_result: str) -> bool: + return decision_result.strip().lower() in {"approved", "pass", "signed", "rejected", "changes_required"} + + def _import_decision_row( + self, + current_user: Dict[str, Any], + asset: GovernanceAsset, + decision_role: str, + decision_result: str, + decision_summary: Optional[str], + required_changes: Optional[str], + decided_by_name: Optional[str], + decision_at: Optional[str], + ) -> Optional[GovernanceReviewDecision]: + decision = GovernanceReviewDecision( + id=f"govdec-{uuid4().hex[:12]}", + tenant_id=current_user["tenant_id"], + governance_asset_id=asset.id, + assignment_id=None, + decision_role=decision_role, + decision_result=decision_result.strip().lower(), + decision_summary=decision_summary, + required_changes=required_changes, + decided_by_user_id=current_user.get("id"), + decided_by_name=decided_by_name or current_user.get("display_name") or current_user.get("username") or current_user.get("id"), + decision_at=self._parse_datetime(decision_at) or datetime.now(timezone.utc), + metadata_json={"source": "formal_launch_import"}, + ) + saved = self.decision_repository.create(decision) + self._refresh_asset_status(asset) + return saved + + def _import_evidence_row( + self, + current_user: Dict[str, Any], + asset: GovernanceAsset, + decision_id: Optional[str], + evidence_path: Optional[str], + reviewer_name: Optional[str], + ) -> Optional[GovernanceEvidence]: + if not evidence_path: + return None + status_value = self._resolve_import_evidence_status(evidence_path) + evidence = GovernanceEvidence( + id=f"govev-{uuid4().hex[:12]}", + tenant_id=current_user["tenant_id"], + governance_asset_id=asset.id, + decision_id=decision_id, + file_id=None, + evidence_type="document", + storage_type="external" if evidence_path.startswith("http") else "internal", + external_url=evidence_path if evidence_path.startswith("http") else None, + file_name=Path(evidence_path).name if not evidence_path.startswith("http") else None, + checksum=None, + status=status_value, + reviewer_name=reviewer_name, + collected_at=datetime.now(timezone.utc), + metadata_json={"source_path": evidence_path, "source": "formal_launch_import"}, + ) + saved = self.evidence_repository.create(evidence) + self._refresh_asset_status(asset) + return saved + + def _resolve_import_evidence_status(self, evidence_path: str) -> str: + if evidence_path.startswith("http://") or evidence_path.startswith("https://"): + return "ready" + full_path = self.repo_root / evidence_path + if not full_path.exists() or full_path.is_dir(): + return "missing" + content = full_path.read_text(encoding="utf-8", errors="ignore") + if "EVIDENCE_STATUS: TEMPLATE" in content or "TODO_REAL_REVIEWER" in content: + return "template" + return "ready" + + def _map_assignment_status_from_decision(self, decision_result: str) -> str: + normalized = decision_result.strip().lower() + if normalized in {"approved", "pass", "signed"}: + return "approved" + if normalized in {"rejected", "changes_required"}: + return normalized + return "in_review" + + def _read_csv_rows(self, path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8-sig", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] diff --git a/backend/tests/test_governance_api.py b/backend/tests/test_governance_api.py new file mode 100644 index 00000000..628c39c5 --- /dev/null +++ b/backend/tests/test_governance_api.py @@ -0,0 +1,303 @@ +import unittest + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.api.governance import get_audit_log_service, get_governance_service, require_current_user +from app.main import app + + +class StubAuditLogService: + def __init__(self): + self.events = [] + + def record_event(self, **kwargs): + self.events.append(kwargs) + return kwargs + + +class StubGovernanceService: + def list_assets(self, current_user, page, page_size, asset_type, governance_status, review_status, production_status, assignment_role): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "items": [ + { + "id": "govast-123", + "tenant_id": current_user["tenant_id"], + "asset_type": asset_type or "contract_template", + "asset_id": "TPL-SALES-GOODS-001", + "asset_name": "销售合同模板", + "source_table": "formal_launch_template_signoff", + "source_path": "project-docs/formal-launch-ops/signoff/production-template-signoff-register.csv", + "governance_status": governance_status or "reviewing", + "review_status": review_status or "pending", + "production_status": production_status or "blocked", + "latest_decision_result": None, + "latest_evidence_status": "template", + "metadata": {"contract_type": "sales"}, + "notes": None, + "active_assignment_roles": [assignment_role or "legal_reviewer"], + "decision_count": 0, + "evidence_count": 1, + "created_at": "2026-05-02T00:00:00+00:00", + "updated_at": "2026-05-02T00:00:00+00:00", + } + ], + "page": page, + "page_size": page_size, + "total": 1, + "summary": { + "total_assets": 1, + "governance_status": {"reviewing": 1}, + "review_status": {"pending": 1}, + "production_status": {"blocked": 1}, + "pending_assignments": 1, + "in_review_assignments": 0, + "blocking_items": [{"type": "assignment", "status": "pending", "count": 1}], + }, + } + + def get_asset_detail(self, current_user, governance_asset_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "asset": { + "id": governance_asset_id, + "asset_name": "销售合同模板", + }, + "assignments": [{"id": "govasg-123"}], + "decisions": [], + "evidences": [{"id": "govev-123"}], + } + + def list_assignments(self, current_user, page, page_size, assignment_role, status_filter, assignee_user_id, governance_asset_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "items": [ + { + "id": "govasg-123", + "tenant_id": current_user["tenant_id"], + "governance_asset_id": governance_asset_id or "govast-123", + "assignment_role": assignment_role or "legal_reviewer", + "assignee_user_id": assignee_user_id, + "assignee_name": "法务负责人A", + "due_at": "2026-05-03T00:00:00+00:00", + "status": status_filter or "pending", + "priority": "high", + "is_active": True, + "notes": None, + "created_at": "2026-05-02T00:00:00+00:00", + "updated_at": "2026-05-02T00:00:00+00:00", + "asset_name": "销售合同模板", + "asset_type": "contract_template", + } + ], + "page": page, + "page_size": page_size, + "total": 1, + } + + def save_assignment(self, current_user, governance_asset_id, assignment_role, assignee_user_id, assignee_name, due_at, status_value, priority, notes, assignment_id=None, is_active=True): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "id": assignment_id or "govasg-123", + "tenant_id": current_user["tenant_id"], + "governance_asset_id": governance_asset_id, + "assignment_role": assignment_role, + "assignee_user_id": assignee_user_id, + "assignee_name": assignee_name, + "due_at": due_at, + "status": status_value or "pending", + "priority": priority or "normal", + "is_active": is_active, + "notes": notes, + "created_at": "2026-05-02T00:00:00+00:00", + "updated_at": "2026-05-02T00:00:00+00:00", + "asset_name": "销售合同模板", + "asset_type": "contract_template", + } + + def submit_decision(self, current_user, governance_asset_id, assignment_id, decision_role, decision_result, decision_summary, required_changes, decided_by_name, decision_at, metadata_json): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "decision": { + "id": "govdec-123", + "governance_asset_id": governance_asset_id, + "assignment_id": assignment_id, + "decision_role": decision_role, + "decision_result": decision_result, + }, + "asset": { + "id": governance_asset_id, + "review_status": "approved" if decision_result == "approved" else "blocked", + }, + } + + def save_evidence(self, current_user, governance_asset_id, decision_id, file_id, evidence_type, storage_type, external_url, file_name, checksum, status_value, reviewer_name, collected_at, metadata_json): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "evidence": { + "id": "govev-123", + "governance_asset_id": governance_asset_id, + "decision_id": decision_id, + "file_id": file_id, + "storage_type": storage_type or "external", + "external_url": external_url, + "status": status_value or "ready", + }, + "asset": { + "id": governance_asset_id, + "latest_evidence_status": status_value or "ready", + }, + } + + def get_release_summary(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "summary": { + "total_assets": 10, + "blocking_items": [{"type": "assignment", "status": "pending", "count": 4}], + }, + "recent_batches": [ + { + "id": "govrel-123", + "batch_name": "formal-launch-import", + "status": "blocked", + } + ], + } + + def get_release_detail(self, current_user, release_batch_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "release_batch": {"id": release_batch_id, "status": "blocked"}, + "gate_runs": [{"id": "govgate-123", "gate_name": "formal_launch_import"}], + } + + def import_formal_launch(self, current_user, batch_name=None): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "imported": { + "assets": 10, + "assignments": 12, + "decisions": 0, + "evidences": 10, + }, + "summary": {"total_assets": 10}, + "release_batch": {"id": "govrel-123", "batch_name": batch_name or "formal-launch-import", "status": "blocked"}, + "gate_run": {"id": "govgate-123", "status": "blocked"}, + } + + +class GovernanceApiTestCase(unittest.TestCase): + def setUp(self): + self.audit_log_service = StubAuditLogService() + app.dependency_overrides[get_governance_service] = lambda: StubGovernanceService() + app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service + self.client = TestClient(app) + + def tearDown(self): + app.dependency_overrides.clear() + + def _set_admin(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + "display_name": "管理员", + } + + def test_admin_can_list_governance_assets(self): + self._set_admin() + + response = self.client.get("/api/v1/governance/assets", params={"asset_type": "contract_template"}) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["items"][0]["asset_name"], "销售合同模板") + self.assertEqual(body["summary"]["total_assets"], 1) + + def test_admin_can_list_governance_assignments(self): + self._set_admin() + + response = self.client.get("/api/v1/governance/assignments", params={"assignment_role": "legal_reviewer"}) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["items"][0]["assignment_role"], "legal_reviewer") + self.assertEqual(body["items"][0]["asset_name"], "销售合同模板") + + def test_admin_can_save_assignment(self): + self._set_admin() + + response = self.client.post( + "/api/v1/governance/assignments", + data={ + "governance_asset_id": "govast-123", + "assignment_role": "legal_reviewer", + "assignee_name": "法务负责人A", + "status_value": "pending", + "priority": "high", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["assignment_role"], "legal_reviewer") + self.assertEqual(self.audit_log_service.events[0]["action"], "save_governance_assignment") + + def test_admin_can_submit_decision(self): + self._set_admin() + + response = self.client.post( + "/api/v1/governance/decisions", + data={ + "governance_asset_id": "govast-123", + "assignment_id": "govasg-123", + "decision_role": "legal_reviewer", + "decision_result": "approved", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["decision"]["decision_result"], "approved") + self.assertEqual(self.audit_log_service.events[0]["action"], "submit_governance_decision") + + def test_admin_can_save_evidence(self): + self._set_admin() + + response = self.client.post( + "/api/v1/governance/evidences", + data={ + "governance_asset_id": "govast-123", + "external_url": "https://example.com/evidence/1", + "status_value": "ready", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["evidence"]["status"], "ready") + self.assertEqual(self.audit_log_service.events[0]["action"], "save_governance_evidence") + + def test_admin_can_get_release_summary_and_import(self): + self._set_admin() + + summary_response = self.client.get("/api/v1/governance/releases/summary") + import_response = self.client.post("/api/v1/governance/imports/formal-launch", data={"batch_name": "formal-launch-import"}) + + self.assertEqual(summary_response.status_code, 200) + self.assertEqual(summary_response.json()["data"]["recent_batches"][0]["status"], "blocked") + self.assertEqual(import_response.status_code, 200) + self.assertEqual(import_response.json()["data"]["release_batch"]["batch_name"], "formal-launch-import") + self.assertEqual(self.audit_log_service.events[-1]["action"], "import_formal_launch_governance") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_governance_service.py b/backend/tests/test_governance_service.py new file mode 100644 index 00000000..64998c34 --- /dev/null +++ b/backend/tests/test_governance_service.py @@ -0,0 +1,96 @@ +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db import Base +from app.services.governance_service import GovernanceService +import app.models # noqa: F401 + + +class GovernanceServiceTestCase(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + TestingSessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, future=True) + Base.metadata.create_all(self.engine) + self.db = TestingSessionLocal() + self.service = GovernanceService(self.db) + self.admin_user = { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + "display_name": "管理员", + "username": "admin", + } + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_assignment_decision_and_evidence_refresh_asset_status(self): + asset, _ = self.service._upsert_governance_asset( + tenant_id=self.admin_user["tenant_id"], + asset_type="contract_template", + asset_id="TPL-DEMO-001", + asset_name="演示模板", + source_path=None, + source_table="manual", + metadata={}, + ) + + assignment = self.service.save_assignment( + current_user=self.admin_user, + governance_asset_id=asset.id, + assignment_role="legal_reviewer", + assignee_user_id=None, + assignee_name="法务A", + due_at="2026-05-03", + status_value="pending", + priority="high", + notes=None, + ) + self.assertEqual(assignment["status"], "pending") + + self.service.save_evidence( + current_user=self.admin_user, + governance_asset_id=asset.id, + decision_id=None, + file_id=None, + evidence_type="document", + storage_type="external", + external_url="https://example.com/evidence/ready", + file_name="evidence.md", + checksum=None, + status_value="ready", + reviewer_name="法务A", + collected_at="2026-05-02", + metadata_json=None, + ) + + decision_result = self.service.submit_decision( + current_user=self.admin_user, + governance_asset_id=asset.id, + assignment_id=assignment["id"], + decision_role="legal_reviewer", + decision_result="approved", + decision_summary="通过", + required_changes=None, + decided_by_name="法务A", + decision_at="2026-05-02", + metadata_json=None, + ) + + self.assertEqual(decision_result["asset"]["review_status"], "approved") + self.assertEqual(decision_result["asset"]["production_status"], "ready") + + def test_import_formal_launch_creates_assets_and_release_batch(self): + result = self.service.import_formal_launch(self.admin_user, batch_name="formal-launch-import") + + self.assertGreater(result["imported"]["assets"], 0) + self.assertGreater(result["summary"]["total_assets"], 0) + self.assertEqual(result["release_batch"]["batch_name"], "formal-launch-import") + self.assertIn(result["release_batch"]["status"], {"blocked", "in_review", "approved"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_permission_metadata.py b/backend/tests/test_permission_metadata.py index 22c4ae66..b8fba0de 100644 --- a/backend/tests/test_permission_metadata.py +++ b/backend/tests/test_permission_metadata.py @@ -1,6 +1,6 @@ import unittest -from app.api import audit_logs, auth, chat, contracts, conversations, files, knowledge_bases, review_rules, reviews, system, templates, tenant_settings, users +from app.api import audit_logs, auth, chat, contracts, conversations, files, governance, knowledge_bases, review_rules, reviews, system, templates, tenant_settings, users class PermissionMetadataTestCase(unittest.TestCase): @@ -12,6 +12,7 @@ class PermissionMetadataTestCase(unittest.TestCase): contracts, conversations, files, + governance, knowledge_bases, review_rules, reviews, @@ -35,6 +36,7 @@ class PermissionMetadataTestCase(unittest.TestCase): contracts, conversations, files, + governance, knowledge_bases, review_rules, reviews, diff --git a/project-docs/formal-launch-ops/governance-productization-backlog.csv b/project-docs/formal-launch-ops/governance-productization-backlog.csv index 660fffa2..2d72c990 100644 --- a/project-docs/formal-launch-ops/governance-productization-backlog.csv +++ b/project-docs/formal-launch-ops/governance-productization-backlog.csv @@ -6,12 +6,12 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce 5A,P0,database,GOV-DB-005,新增发布批次与门禁结果表,当前上线批准状态只存在 PowerShell 产物中,新增 governance_release_batches 和 governance_gate_runs 表,GOV-DB-001;GOV-DB-003;GOV-DB-004,系统可追踪一次发布批次的门禁快照和结果,COMPLETED 5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,PLANNED 5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,PLANNED -5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,PLANNED -5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,PLANNED -5B,P0,backend_api,GOV-API-003,新增复核回填接口,当前复核结论只能通过CSV和脚本回填,新增 /governance/decisions 提交接口和批量导入接口,GOV-DB-003,真实复核人或管理员可提交复核结果,PLANNED -5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,PLANNED -5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,PLANNED -5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,PLANNED +5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,COMPLETED +5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,COMPLETED +5B,P0,backend_api,GOV-API-003,新增复核回填接口,当前复核结论只能通过CSV和脚本回填,新增 /governance/decisions 提交接口和批量导入接口,GOV-DB-003,真实复核人或管理员可提交复核结果,COMPLETED +5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,COMPLETED +5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,COMPLETED +5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,COMPLETED 5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,PLANNED 5C,P0,frontend,GOV-WEB-001,新增复核总控中心页面,当前没有统一治理控制台,新增 /governance 页面展示资产统计阻断项角色视图和发布批次,GOV-API-001;GOV-API-005,管理员可在单页看到当前正式上线治理全貌,PLANNED 5C,P0,frontend,GOV-WEB-002,新增复核人工作台页面,当前真实法务业务运营没有系统内待办页,新增 /governance/workbench 页面支持按角色查看待处理任务,GOV-API-001;GOV-API-002;GOV-API-003,复核人可只看到与自己相关的任务包,PLANNED diff --git a/project-docs/正式上线运营准备阶段5B后端治理接口执行记录-2026-05-02.md b/project-docs/正式上线运营准备阶段5B后端治理接口执行记录-2026-05-02.md new file mode 100644 index 00000000..32d6afc7 --- /dev/null +++ b/project-docs/正式上线运营准备阶段5B后端治理接口执行记录-2026-05-02.md @@ -0,0 +1,120 @@ +# 正式上线运营准备阶段 5B 后端治理接口执行记录(2026-05-02) + +## 本批目标 + +本批执行 `5B / GOV-API-001 ~ GOV-API-006`,目标是在已完成的治理域数据库骨架上,补齐系统内的治理接口,让“治理资产、复核任务、复核结论、证据、上线批准汇总、CSV 导入”不再只存在于仓库脚本中。 + +## 已完成接口 + +新增路由文件: + +- `backend/app/api/governance.py` + +并已注册到: + +- `backend/app/main.py` + +本批已完成接口: + +1. `GET /api/v1/governance/assets` + 支持治理资产列表、状态筛选和汇总摘要。 + +2. `GET /api/v1/governance/assets/{governance_asset_id}` + 返回单资产详情、分配、决议和证据。 + +3. `GET /api/v1/governance/assignments` + 返回复核分配列表。 + +4. `POST /api/v1/governance/assignments` + 创建或更新复核分配。 + +5. `POST /api/v1/governance/decisions` + 提交复核结论,并回写资产治理状态。 + +6. `POST /api/v1/governance/evidences` + 绑定证据,并回写资产证据状态。 + +7. `GET /api/v1/governance/releases/summary` + 返回当前治理汇总和最近发布批次。 + +8. `GET /api/v1/governance/releases/{release_batch_id}` + 返回指定发布批次和门禁执行记录。 + +9. `POST /api/v1/governance/imports/formal-launch` + 读取现有 formal-launch CSV 台账并导入数据库。 + +## 已完成仓储与服务 + +新增仓储: + +- `governance_asset_repository.py` +- `governance_review_assignment_repository.py` +- `governance_review_decision_repository.py` +- `governance_evidence_repository.py` +- `governance_release_batch_repository.py` +- `governance_gate_run_repository.py` + +新增服务: + +- `backend/app/services/governance_service.py` + +### 服务能力 + +`GovernanceService` 已实现: + +- 治理资产分页查询。 +- 单资产详情聚合。 +- 复核分配查询与保存。 +- 复核结论提交。 +- 证据登记。 +- 发布汇总查询。 +- formal-launch 台账导入。 + +## formal-launch 导入能力 + +导入接口会读取以下已有台账: + +- `real-review-task-package.csv` +- `production-material-signoff-register.csv` +- `production-template-signoff-register.csv` +- `production-knowledge-signoff-register.csv` + +导入后会自动: + +1. 建立治理资产。 +2. 建立复核分配。 +3. 导入已存在的决议状态。 +4. 读取证据路径并判定 `template / ready / missing`。 +5. 生成一条发布批次。 +6. 生成一条门禁快照记录。 + +## 已完成验证 + +执行测试: + +`backend/.venv/Scripts/python.exe -m unittest tests.test_governance_models tests.test_governance_api tests.test_governance_service tests.test_permission_metadata` + +结果: + +- 13 个测试全部通过。 + +覆盖内容: + +- 治理模型注册与外键。 +- 治理 API 路由与审计事件。 +- formal-launch 导入和发布批次生成。 +- 权限元数据暴露。 + +## 当前状态 + +`GOV-API-001 ~ GOV-API-006` 已完成,backlog 已更新为 `COMPLETED`。 + +尚未进入: + +- `GOV-API-007` 模板、规则、知识库的发布/回滚接口。 +- `5C` 前端复核总控中心、复核工作台、资产详情页、上线批准页。 +- `5D` 门禁切换为数据库读模式。 + +## 下一步建议 + +最自然的下一步是进入 `5C / GOV-WEB-001 ~ GOV-WEB-004`,先把复核总控中心、复核人工作台、资产详情页和上线批准页做起来,让刚完成的治理接口真正可用。