9b4be663f7
- Migration 111: 3 new tables (org_roles, document_reviews, document_role_mapping) with seed data mapping all 71 doc types to 7 compliance roles - org_role_routes.py: CRUD for roles, seed defaults, test email, mapping API - document_review_routes.py: Review lifecycle (create→send→approve/reject) with approval notification to all affected roles - Migration 112: SOP template (ISO 9001 structure, 21 placeholders) - Added standard_operating_procedure to TemplateType, doc-labels, presets [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
"""
|
|
FastAPI routes for Organizational Compliance Roles.
|
|
|
|
Manages the 7 standard compliance roles (DSB, GF, IT-Leiter, etc.)
|
|
and the document-to-role mapping that determines who reviews which documents.
|
|
|
|
Endpoints:
|
|
GET /org-roles — list roles for tenant/project
|
|
POST /org-roles — create/upsert a role
|
|
PUT /org-roles/{id} — update role details
|
|
DELETE /org-roles/{id} — remove a role
|
|
GET /org-roles/defaults — 7 standard role definitions
|
|
POST /org-roles/seed — seed default roles for a project
|
|
POST /org-roles/{id}/send-test — send test email to role
|
|
GET /org-roles/mapping — document-to-role mapping
|
|
PUT /org-roles/mapping — update mapping
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from .tenant_utils import get_tenant_id as _get_tenant_id
|
|
from .db_utils import row_to_dict as _row_to_dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/org-roles", tags=["org-roles"])
|
|
|
|
# =============================================================================
|
|
# Standard role definitions
|
|
# =============================================================================
|
|
|
|
DEFAULT_ROLES = [
|
|
{"role_key": "dsb", "role_label": "Datenschutzbeauftragter (DSB)"},
|
|
{"role_key": "gf", "role_label": "Geschaeftsfuehrung"},
|
|
{"role_key": "it_leiter", "role_label": "IT-Leiter / CISO"},
|
|
{"role_key": "hr_leitung", "role_label": "HR-Leitung"},
|
|
{"role_key": "marketing_leitung", "role_label": "Marketing-Leitung"},
|
|
{"role_key": "compliance_beauftragter", "role_label": "Compliance-Beauftragter"},
|
|
{"role_key": "einkauf", "role_label": "Einkauf / Vendor Management"},
|
|
]
|
|
|
|
# =============================================================================
|
|
# Schemas
|
|
# =============================================================================
|
|
|
|
|
|
class OrgRoleCreate(BaseModel):
|
|
role_key: str
|
|
role_label: str
|
|
person_name: Optional[str] = None
|
|
person_email: Optional[str] = None
|
|
department: Optional[str] = None
|
|
project_id: Optional[str] = None
|
|
|
|
|
|
class OrgRoleUpdate(BaseModel):
|
|
role_label: Optional[str] = None
|
|
person_name: Optional[str] = None
|
|
person_email: Optional[str] = None
|
|
department: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class MappingEntry(BaseModel):
|
|
document_type: str
|
|
role_key: str
|
|
is_primary: bool = True
|
|
|
|
|
|
class MappingUpdate(BaseModel):
|
|
entries: List[MappingEntry]
|
|
|
|
|
|
# =============================================================================
|
|
# Routes
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("")
|
|
def list_roles(
|
|
project_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("""
|
|
SELECT * FROM compliance_org_roles
|
|
WHERE tenant_id = :tid AND (project_id = :pid OR (:pid IS NULL AND project_id IS NULL))
|
|
ORDER BY role_key
|
|
""")
|
|
rows = db.execute(q, {"tid": tenant_id, "pid": project_id}).fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
@router.get("/defaults")
|
|
def get_defaults():
|
|
return DEFAULT_ROLES
|
|
|
|
|
|
@router.post("")
|
|
def create_role(
|
|
body: OrgRoleCreate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("""
|
|
INSERT INTO compliance_org_roles (tenant_id, project_id, role_key, role_label, person_name, person_email, department)
|
|
VALUES (:tid, :pid, :rk, :rl, :pn, :pe, :dept)
|
|
ON CONFLICT (tenant_id, project_id, role_key) DO UPDATE
|
|
SET role_label = EXCLUDED.role_label,
|
|
person_name = COALESCE(EXCLUDED.person_name, compliance_org_roles.person_name),
|
|
person_email = COALESCE(EXCLUDED.person_email, compliance_org_roles.person_email),
|
|
department = COALESCE(EXCLUDED.department, compliance_org_roles.department),
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
""")
|
|
row = db.execute(q, {
|
|
"tid": tenant_id, "pid": body.project_id, "rk": body.role_key,
|
|
"rl": body.role_label, "pn": body.person_name, "pe": body.person_email,
|
|
"dept": body.department,
|
|
}).fetchone()
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.put("/{role_id}")
|
|
def update_role(
|
|
role_id: str,
|
|
body: OrgRoleUpdate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
sets, params = [], {"rid": role_id, "tid": tenant_id}
|
|
for field in ["role_label", "person_name", "person_email", "department", "is_active"]:
|
|
val = getattr(body, field, None)
|
|
if val is not None:
|
|
sets.append(f"{field} = :{field}")
|
|
params[field] = val
|
|
if not sets:
|
|
raise HTTPException(400, "No fields to update")
|
|
sets.append("updated_at = NOW()")
|
|
q = text(f"UPDATE compliance_org_roles SET {', '.join(sets)} WHERE id = :rid AND tenant_id = :tid RETURNING *")
|
|
row = db.execute(q, params).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Role not found")
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.delete("/{role_id}")
|
|
def delete_role(
|
|
role_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("DELETE FROM compliance_org_roles WHERE id = :rid AND tenant_id = :tid")
|
|
result = db.execute(q, {"rid": role_id, "tid": tenant_id})
|
|
db.commit()
|
|
if result.rowcount == 0:
|
|
raise HTTPException(404, "Role not found")
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.post("/seed")
|
|
def seed_roles(
|
|
project_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
created = 0
|
|
for role in DEFAULT_ROLES:
|
|
q = text("""
|
|
INSERT INTO compliance_org_roles (tenant_id, project_id, role_key, role_label)
|
|
VALUES (:tid, :pid, :rk, :rl)
|
|
ON CONFLICT (tenant_id, project_id, role_key) DO NOTHING
|
|
""")
|
|
result = db.execute(q, {"tid": tenant_id, "pid": project_id, "rk": role["role_key"], "rl": role["role_label"]})
|
|
created += result.rowcount
|
|
db.commit()
|
|
return {"seeded": created, "total": len(DEFAULT_ROLES)}
|
|
|
|
|
|
@router.post("/{role_id}/send-test")
|
|
def send_test_email(
|
|
role_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("SELECT * FROM compliance_org_roles WHERE id = :rid AND tenant_id = :tid")
|
|
role = db.execute(q, {"rid": role_id, "tid": tenant_id}).fetchone()
|
|
if not role:
|
|
raise HTTPException(404, "Role not found")
|
|
role_dict = _row_to_dict(role)
|
|
if not role_dict.get("person_email"):
|
|
raise HTTPException(400, "No email configured for this role")
|
|
|
|
try:
|
|
from compliance.services.smtp_sender import send_email
|
|
result = send_email(
|
|
recipient=role_dict["person_email"],
|
|
subject=f"[BreakPilot] Test-E-Mail fuer {role_dict['role_label']}",
|
|
body_html=f"""
|
|
<h2>Test-E-Mail</h2>
|
|
<p>Diese E-Mail bestaetigt, dass die Zustellung an die Rolle
|
|
<strong>{role_dict['role_label']}</strong> funktioniert.</p>
|
|
<p>Empfaenger: {role_dict['person_name'] or 'N/A'} ({role_dict['person_email']})</p>
|
|
<p style="color:#888;font-size:12px;">Gesendet von BreakPilot Compliance SDK</p>
|
|
""",
|
|
)
|
|
return {"sent": True, "email": role_dict["person_email"], "result": result}
|
|
except Exception as e:
|
|
logger.error("Failed to send test email: %s", e)
|
|
raise HTTPException(500, f"Email sending failed: {e}")
|
|
|
|
|
|
# =============================================================================
|
|
# Document-to-Role Mapping
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/mapping")
|
|
def get_mapping(
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("""
|
|
SELECT * FROM compliance_document_role_mapping
|
|
WHERE tenant_id = :tid
|
|
ORDER BY document_type, role_key
|
|
""")
|
|
rows = db.execute(q, {"tid": tenant_id}).fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
@router.put("/mapping")
|
|
def update_mapping(
|
|
body: MappingUpdate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
for entry in body.entries:
|
|
q = text("""
|
|
INSERT INTO compliance_document_role_mapping (tenant_id, document_type, role_key, is_primary)
|
|
VALUES (:tid, :dt, :rk, :ip)
|
|
ON CONFLICT (tenant_id, document_type, role_key) DO UPDATE
|
|
SET is_primary = EXCLUDED.is_primary
|
|
""")
|
|
db.execute(q, {"tid": tenant_id, "dt": entry.document_type, "rk": entry.role_key, "ip": entry.is_primary})
|
|
db.commit()
|
|
return {"updated": len(body.entries)}
|