feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
Paket A — RAG Proxy: - NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung - UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls GET /regulations → dynamische suggestedQuestions POST /search → Qdrant-Ergebnisse mit score, title, reference Paket B — Security-Backlog + Quality: - NEU: migrations/014_security_backlog.sql + 015_quality.sql - NEU: compliance/api/security_backlog_routes.py — CRUD + Stats - NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats - UPDATE: security-backlog/page.tsx — mockItems → API - UPDATE: quality/page.tsx — mockMetrics/mockTests → API - UPDATE: compliance/api/__init__.py — Router-Registrierung - NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden) - NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden) Paket C — Notfallplan Incidents + Templates: - NEU: migrations/016_notfallplan_incidents.sql compliance_notfallplan_incidents + compliance_notfallplan_templates - UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates - UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API - UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden) Paket D — Loeschfristen localStorage → API: - NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...) - NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update - UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE, handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons - NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden) Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ from .escalation_routes import router as escalation_router
|
||||
from .consent_template_routes import router as consent_template_router
|
||||
from .notfallplan_routes import router as notfallplan_router
|
||||
from .obligation_routes import router as obligation_router
|
||||
from .security_backlog_routes import router as security_backlog_router
|
||||
from .quality_routes import router as quality_router
|
||||
from .loeschfristen_routes import router as loeschfristen_router
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(audit_router)
|
||||
@@ -33,6 +36,9 @@ router.include_router(escalation_router)
|
||||
router.include_router(consent_template_router)
|
||||
router.include_router(notfallplan_router)
|
||||
router.include_router(obligation_router)
|
||||
router.include_router(security_backlog_router)
|
||||
router.include_router(quality_router)
|
||||
router.include_router(loeschfristen_router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
@@ -51,4 +57,7 @@ __all__ = [
|
||||
"consent_template_router",
|
||||
"notfallplan_router",
|
||||
"obligation_router",
|
||||
"security_backlog_router",
|
||||
"quality_router",
|
||||
"loeschfristen_router",
|
||||
]
|
||||
|
||||
354
backend-compliance/compliance/api/loeschfristen_routes.py
Normal file
354
backend-compliance/compliance/api/loeschfristen_routes.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
FastAPI routes for Loeschfristen (Retention Policies).
|
||||
|
||||
Endpoints:
|
||||
GET /loeschfristen — list (filter: status, retention_driver, search; limit/offset)
|
||||
GET /loeschfristen/stats — total, active, draft, review_needed, archived, legal_holds_count
|
||||
POST /loeschfristen — create
|
||||
GET /loeschfristen/{id} — get single
|
||||
PUT /loeschfristen/{id} — full update
|
||||
PUT /loeschfristen/{id}/status — quick status update
|
||||
DELETE /loeschfristen/{id} — delete (204)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/loeschfristen", tags=["loeschfristen"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class LoeschfristCreate(BaseModel):
|
||||
policy_id: Optional[str] = None
|
||||
data_object_name: str
|
||||
description: Optional[str] = None
|
||||
affected_groups: Optional[List[Any]] = None
|
||||
data_categories: Optional[List[Any]] = None
|
||||
primary_purpose: Optional[str] = None
|
||||
deletion_trigger: str = "PURPOSE_END"
|
||||
retention_driver: Optional[str] = None
|
||||
retention_driver_detail: Optional[str] = None
|
||||
retention_duration: Optional[int] = None
|
||||
retention_unit: Optional[str] = None
|
||||
retention_description: Optional[str] = None
|
||||
start_event: Optional[str] = None
|
||||
has_active_legal_hold: bool = False
|
||||
legal_holds: Optional[List[Any]] = None
|
||||
storage_locations: Optional[List[Any]] = None
|
||||
deletion_method: Optional[str] = None
|
||||
deletion_method_detail: Optional[str] = None
|
||||
responsible_role: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
status: str = "DRAFT"
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
review_interval: Optional[str] = None
|
||||
tags: Optional[List[Any]] = None
|
||||
|
||||
|
||||
class LoeschfristUpdate(BaseModel):
|
||||
policy_id: Optional[str] = None
|
||||
data_object_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
affected_groups: Optional[List[Any]] = None
|
||||
data_categories: Optional[List[Any]] = None
|
||||
primary_purpose: Optional[str] = None
|
||||
deletion_trigger: Optional[str] = None
|
||||
retention_driver: Optional[str] = None
|
||||
retention_driver_detail: Optional[str] = None
|
||||
retention_duration: Optional[int] = None
|
||||
retention_unit: Optional[str] = None
|
||||
retention_description: Optional[str] = None
|
||||
start_event: Optional[str] = None
|
||||
has_active_legal_hold: Optional[bool] = None
|
||||
legal_holds: Optional[List[Any]] = None
|
||||
storage_locations: Optional[List[Any]] = None
|
||||
deletion_method: Optional[str] = None
|
||||
deletion_method_detail: Optional[str] = None
|
||||
responsible_role: Optional[str] = None
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
status: Optional[str] = None
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
review_interval: Optional[str] = None
|
||||
tags: Optional[List[Any]] = None
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
# JSONB fields that need CAST
|
||||
JSONB_FIELDS = {
|
||||
"affected_groups", "data_categories", "legal_holds",
|
||||
"storage_locations", "linked_vvt_activity_ids", "tags"
|
||||
}
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_loeschfristen(
|
||||
status: Optional[str] = Query(None),
|
||||
retention_driver: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List Loeschfristen with optional filters."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if retention_driver:
|
||||
where_clauses.append("retention_driver = :retention_driver")
|
||||
params["retention_driver"] = retention_driver
|
||||
if search:
|
||||
where_clauses.append("(data_object_name ILIKE :search OR description ILIKE :search OR policy_id ILIKE :search)")
|
||||
params["search"] = f"%{search}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_loeschfristen WHERE {where_sql}"),
|
||||
params,
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_loeschfristen
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'ACTIVE' THEN 0
|
||||
WHEN 'REVIEW_NEEDED' THEN 1
|
||||
WHEN 'DRAFT' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"policies": [_row_to_dict(r) for r in rows],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_loeschfristen_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return Loeschfristen statistics."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'ACTIVE') AS active,
|
||||
COUNT(*) FILTER (WHERE status = 'DRAFT') AS draft,
|
||||
COUNT(*) FILTER (WHERE status = 'REVIEW_NEEDED') AS review_needed,
|
||||
COUNT(*) FILTER (WHERE status = 'ARCHIVED') AS archived,
|
||||
COUNT(*) FILTER (WHERE has_active_legal_hold = TRUE) AS legal_holds_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE next_review_date IS NOT NULL
|
||||
AND next_review_date < NOW()
|
||||
AND status NOT IN ('ARCHIVED')
|
||||
) AS overdue_reviews
|
||||
FROM compliance_loeschfristen
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
if row:
|
||||
d = dict(row._mapping)
|
||||
return {k: int(v or 0) for k, v in d.items()}
|
||||
return {"total": 0, "active": 0, "draft": 0, "review_needed": 0,
|
||||
"archived": 0, "legal_holds_count": 0, "overdue_reviews": 0}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_loeschfrist(
|
||||
payload: LoeschfristCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new Loeschfrist policy."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
data = payload.model_dump()
|
||||
|
||||
# Build INSERT with JSONB casts
|
||||
columns = ["tenant_id"] + list(data.keys())
|
||||
value_parts = [":tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id}
|
||||
|
||||
for k, v in data.items():
|
||||
if k in JSONB_FIELDS:
|
||||
value_parts.append(f"CAST(:{k} AS jsonb)")
|
||||
params[k] = json.dumps(v if v is not None else [])
|
||||
else:
|
||||
value_parts.append(f":{k}")
|
||||
params[k] = v
|
||||
|
||||
cols_sql = ", ".join(columns)
|
||||
vals_sql = ", ".join(value_parts)
|
||||
|
||||
row = db.execute(
|
||||
text(f"INSERT INTO compliance_loeschfristen ({cols_sql}) VALUES ({vals_sql}) RETURNING *"),
|
||||
params,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/{policy_id}")
|
||||
async def get_loeschfrist(
|
||||
policy_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
row = db.execute(
|
||||
text("SELECT * FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": policy_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{policy_id}")
|
||||
async def update_loeschfrist(
|
||||
policy_id: str,
|
||||
payload: LoeschfristUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Full update of a Loeschfrist policy."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": policy_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
if field in JSONB_FIELDS:
|
||||
updates[field] = json.dumps(value if value is not None else [])
|
||||
set_clauses.append(f"{field} = CAST(:{field} AS jsonb)")
|
||||
else:
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_loeschfristen
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{policy_id}/status")
|
||||
async def update_loeschfrist_status(
|
||||
policy_id: str,
|
||||
payload: StatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Quick status update."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
valid = {"DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"}
|
||||
if payload.status not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid)}")
|
||||
|
||||
row = db.execute(
|
||||
text("""
|
||||
UPDATE compliance_loeschfristen
|
||||
SET status = :status, updated_at = :now
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
{"status": payload.status, "now": datetime.utcnow(), "id": policy_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/{policy_id}", status_code=204)
|
||||
async def delete_loeschfrist(
|
||||
policy_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": policy_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Loeschfrist not found")
|
||||
@@ -691,9 +691,328 @@ async def get_stats(
|
||||
{"tenant_id": tenant_id},
|
||||
).scalar()
|
||||
|
||||
incidents_count = db.execute(
|
||||
text("SELECT COUNT(*) FROM compliance_notfallplan_incidents WHERE tenant_id = :tenant_id AND status != 'closed'"),
|
||||
{"tenant_id": tenant_id},
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
"contacts": contacts_count or 0,
|
||||
"active_scenarios": scenarios_count or 0,
|
||||
"exercises": exercises_count or 0,
|
||||
"checklist_items": checklists_count or 0,
|
||||
"open_incidents": incidents_count or 0,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Incidents
|
||||
# ============================================================================
|
||||
|
||||
class IncidentCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
detected_by: Optional[str] = None
|
||||
status: str = 'detected'
|
||||
severity: str = 'medium'
|
||||
affected_data_categories: List[Any] = []
|
||||
estimated_affected_persons: int = 0
|
||||
measures: List[Any] = []
|
||||
art34_required: bool = False
|
||||
art34_justification: Optional[str] = None
|
||||
|
||||
|
||||
class IncidentUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
detected_by: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
affected_data_categories: Optional[List[Any]] = None
|
||||
estimated_affected_persons: Optional[int] = None
|
||||
measures: Optional[List[Any]] = None
|
||||
art34_required: Optional[bool] = None
|
||||
art34_justification: Optional[str] = None
|
||||
reported_to_authority_at: Optional[str] = None
|
||||
notified_affected_at: Optional[str] = None
|
||||
closed_at: Optional[str] = None
|
||||
closed_by: Optional[str] = None
|
||||
lessons_learned: Optional[str] = None
|
||||
|
||||
|
||||
def _incident_row(r) -> dict:
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"tenant_id": r.tenant_id,
|
||||
"title": r.title,
|
||||
"description": r.description,
|
||||
"detected_at": r.detected_at.isoformat() if r.detected_at else None,
|
||||
"detected_by": r.detected_by,
|
||||
"status": r.status,
|
||||
"severity": r.severity,
|
||||
"affected_data_categories": r.affected_data_categories if r.affected_data_categories else [],
|
||||
"estimated_affected_persons": r.estimated_affected_persons,
|
||||
"measures": r.measures if r.measures else [],
|
||||
"art34_required": r.art34_required,
|
||||
"art34_justification": r.art34_justification,
|
||||
"reported_to_authority_at": r.reported_to_authority_at.isoformat() if r.reported_to_authority_at else None,
|
||||
"notified_affected_at": r.notified_affected_at.isoformat() if r.notified_affected_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"closed_by": r.closed_by,
|
||||
"lessons_learned": r.lessons_learned,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/incidents")
|
||||
async def list_incidents(
|
||||
status: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""List all incidents for a tenant."""
|
||||
where = "WHERE tenant_id = :tenant_id"
|
||||
params: dict = {"tenant_id": tenant_id}
|
||||
|
||||
if status:
|
||||
where += " AND status = :status"
|
||||
params["status"] = status
|
||||
if severity:
|
||||
where += " AND severity = :severity"
|
||||
params["severity"] = severity
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_notfallplan_incidents
|
||||
{where}
|
||||
ORDER BY created_at DESC
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
return [_incident_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/incidents", status_code=201)
|
||||
async def create_incident(
|
||||
request: IncidentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Create a new incident."""
|
||||
row = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_notfallplan_incidents
|
||||
(tenant_id, title, description, detected_by, status, severity,
|
||||
affected_data_categories, estimated_affected_persons, measures,
|
||||
art34_required, art34_justification)
|
||||
VALUES
|
||||
(:tenant_id, :title, :description, :detected_by, :status, :severity,
|
||||
CAST(:affected_data_categories AS jsonb), :estimated_affected_persons,
|
||||
CAST(:measures AS jsonb), :art34_required, :art34_justification)
|
||||
RETURNING *
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"title": request.title,
|
||||
"description": request.description,
|
||||
"detected_by": request.detected_by,
|
||||
"status": request.status,
|
||||
"severity": request.severity,
|
||||
"affected_data_categories": json.dumps(request.affected_data_categories),
|
||||
"estimated_affected_persons": request.estimated_affected_persons,
|
||||
"measures": json.dumps(request.measures),
|
||||
"art34_required": request.art34_required,
|
||||
"art34_justification": request.art34_justification,
|
||||
},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _incident_row(row)
|
||||
|
||||
|
||||
@router.put("/incidents/{incident_id}")
|
||||
async def update_incident(
|
||||
incident_id: str,
|
||||
request: IncidentUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Update an incident (including status transitions)."""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM compliance_notfallplan_incidents WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": incident_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Incident {incident_id} not found")
|
||||
|
||||
updates = request.dict(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
# Auto-set timestamps based on status transitions
|
||||
if updates.get("status") == "reported" and not updates.get("reported_to_authority_at"):
|
||||
updates["reported_to_authority_at"] = datetime.utcnow().isoformat()
|
||||
if updates.get("status") == "closed" and not updates.get("closed_at"):
|
||||
updates["closed_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
set_parts = []
|
||||
for k in updates:
|
||||
if k in ("affected_data_categories", "measures"):
|
||||
set_parts.append(f"{k} = CAST(:{k} AS jsonb)")
|
||||
updates[k] = json.dumps(updates[k]) if isinstance(updates[k], list) else updates[k]
|
||||
else:
|
||||
set_parts.append(f"{k} = :{k}")
|
||||
|
||||
updates["id"] = incident_id
|
||||
updates["tenant_id"] = tenant_id
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_notfallplan_incidents
|
||||
SET {', '.join(set_parts)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _incident_row(row)
|
||||
|
||||
|
||||
@router.delete("/incidents/{incident_id}", status_code=204)
|
||||
async def delete_incident(
|
||||
incident_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Delete an incident."""
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_notfallplan_incidents WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": incident_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail=f"Incident {incident_id} not found")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Templates
|
||||
# ============================================================================
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
type: str = 'art33'
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
def _template_row(r) -> dict:
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"tenant_id": r.tenant_id,
|
||||
"type": r.type,
|
||||
"title": r.title,
|
||||
"content": r.content,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""List Melde-Templates for a tenant."""
|
||||
where = "WHERE tenant_id = :tenant_id"
|
||||
params: dict = {"tenant_id": tenant_id}
|
||||
if type:
|
||||
where += " AND type = :type"
|
||||
params["type"] = type
|
||||
|
||||
rows = db.execute(
|
||||
text(f"SELECT * FROM compliance_notfallplan_templates {where} ORDER BY type, created_at"),
|
||||
params,
|
||||
).fetchall()
|
||||
return [_template_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/templates", status_code=201)
|
||||
async def create_template(
|
||||
request: TemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Create a new Melde-Template."""
|
||||
row = db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_notfallplan_templates (tenant_id, type, title, content)
|
||||
VALUES (:tenant_id, :type, :title, :content)
|
||||
RETURNING *
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "type": request.type, "title": request.title, "content": request.content},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _template_row(row)
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def update_template(
|
||||
template_id: str,
|
||||
request: TemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Update a Melde-Template."""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM compliance_notfallplan_templates WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": template_id, "tenant_id": tenant_id},
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||
|
||||
updates = request.dict(exclude_none=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
||||
updates["id"] = template_id
|
||||
updates["tenant_id"] = tenant_id
|
||||
|
||||
row = db.execute(
|
||||
text(f"""
|
||||
UPDATE compliance_notfallplan_templates
|
||||
SET {set_clauses}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""),
|
||||
updates,
|
||||
).fetchone()
|
||||
db.commit()
|
||||
return _template_row(row)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=204)
|
||||
async def delete_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant),
|
||||
):
|
||||
"""Delete a Melde-Template."""
|
||||
result = db.execute(
|
||||
text("DELETE FROM compliance_notfallplan_templates WHERE id = :id AND tenant_id = :tenant_id"),
|
||||
{"id": template_id, "tenant_id": tenant_id},
|
||||
)
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
|
||||
|
||||
378
backend-compliance/compliance/api/quality_routes.py
Normal file
378
backend-compliance/compliance/api/quality_routes.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
FastAPI routes for AI Quality Metrics and Tests.
|
||||
|
||||
Endpoints:
|
||||
GET/POST /quality/metrics — list/create metrics
|
||||
PUT/DELETE /quality/metrics/{id} — update/delete metric
|
||||
GET/POST /quality/tests — list/create tests
|
||||
PUT/DELETE /quality/tests/{id} — update/delete test
|
||||
GET /quality/stats — avgScore, metricsAboveThreshold, passed, failed
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/quality", tags=["quality"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class MetricCreate(BaseModel):
|
||||
name: str
|
||||
category: str = "accuracy"
|
||||
score: float = 0.0
|
||||
threshold: float = 80.0
|
||||
trend: str = "stable"
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class MetricUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
threshold: Optional[float] = None
|
||||
trend: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestCreate(BaseModel):
|
||||
name: str
|
||||
status: str = "pending"
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stats
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_quality_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return quality dashboard stats."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
metrics_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) AS total_metrics,
|
||||
COALESCE(AVG(score), 0) AS avg_score,
|
||||
COUNT(*) FILTER (WHERE score >= threshold) AS metrics_above_threshold
|
||||
FROM compliance_quality_metrics
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
tests_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'passed') AS passed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'warning') AS warning,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_quality_tests
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
return {
|
||||
"total_metrics": int(metrics_row.total_metrics or 0),
|
||||
"avg_score": round(float(metrics_row.avg_score or 0), 1),
|
||||
"metrics_above_threshold": int(metrics_row.metrics_above_threshold or 0),
|
||||
"passed": int(tests_row.passed or 0),
|
||||
"failed": int(tests_row.failed or 0),
|
||||
"warning": int(tests_row.warning or 0),
|
||||
"total_tests": int(tests_row.total or 0),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metrics
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/metrics")
|
||||
async def list_metrics(
|
||||
category: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality metrics."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if category:
|
||||
where_clauses.append("category = :category")
|
||||
params["category"] = category
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_metrics WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_metrics
|
||||
WHERE {where_sql}
|
||||
ORDER BY category, name
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"metrics": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/metrics", status_code=201)
|
||||
async def create_metric(
|
||||
payload: MetricCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_metrics
|
||||
(tenant_id, name, category, score, threshold, trend, ai_system, last_measured)
|
||||
VALUES
|
||||
(:tenant_id, :name, :category, :score, :threshold, :trend, :ai_system, :last_measured)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"category": payload.category,
|
||||
"score": payload.score,
|
||||
"threshold": payload.threshold,
|
||||
"trend": payload.trend,
|
||||
"ai_system": payload.ai_system,
|
||||
"last_measured": payload.last_measured or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/metrics/{metric_id}")
|
||||
async def update_metric(
|
||||
metric_id: str,
|
||||
payload: MetricUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": metric_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_metrics
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/metrics/{metric_id}", status_code=204)
|
||||
async def delete_metric(
|
||||
metric_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_metrics
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": metric_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/tests")
|
||||
async def list_tests(
|
||||
status: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality tests."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_tests WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_tests
|
||||
WHERE {where_sql}
|
||||
ORDER BY last_run DESC NULLS LAST, created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"tests": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/tests", status_code=201)
|
||||
async def create_test(
|
||||
payload: TestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality test entry."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_tests
|
||||
(tenant_id, name, status, duration, ai_system, details, last_run)
|
||||
VALUES
|
||||
(:tenant_id, :name, :status, :duration, :ai_system, :details, :last_run)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"status": payload.status,
|
||||
"duration": payload.duration,
|
||||
"ai_system": payload.ai_system,
|
||||
"details": payload.details,
|
||||
"last_run": payload.last_run or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/tests/{test_id}")
|
||||
async def update_test(
|
||||
test_id: str,
|
||||
payload: TestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality test."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": test_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_tests
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/tests/{test_id}", status_code=204)
|
||||
async def delete_test(
|
||||
test_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_tests
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": test_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
270
backend-compliance/compliance/api/security_backlog_routes.py
Normal file
270
backend-compliance/compliance/api/security_backlog_routes.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
FastAPI routes for Security Backlog Tracking.
|
||||
|
||||
Endpoints:
|
||||
GET /security-backlog — list with filters (status, severity, type, search; limit/offset)
|
||||
GET /security-backlog/stats — open, critical, high, overdue counts
|
||||
POST /security-backlog — create finding
|
||||
PUT /security-backlog/{id} — update finding
|
||||
DELETE /security-backlog/{id} — delete finding (204)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/security-backlog", tags=["security-backlog"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class SecurityItemCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
type: str = "vulnerability"
|
||||
severity: str = "medium"
|
||||
status: str = "open"
|
||||
source: Optional[str] = None
|
||||
cve: Optional[str] = None
|
||||
cvss: Optional[float] = None
|
||||
affected_asset: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
remediation: Optional[str] = None
|
||||
|
||||
|
||||
class SecurityItemUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
cve: Optional[str] = None
|
||||
cvss: Optional[float] = None
|
||||
affected_asset: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
remediation: Optional[str] = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_security_items(
|
||||
status: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List security backlog items with optional filters."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if severity:
|
||||
where_clauses.append("severity = :severity")
|
||||
params["severity"] = severity
|
||||
if type:
|
||||
where_clauses.append("type = :type")
|
||||
params["type"] = type
|
||||
if search:
|
||||
where_clauses.append("(title ILIKE :search OR description ILIKE :search)")
|
||||
params["search"] = f"%{search}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_security_backlog WHERE {where_sql}"),
|
||||
params,
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_security_backlog
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE severity
|
||||
WHEN 'critical' THEN 0
|
||||
WHEN 'high' THEN 1
|
||||
WHEN 'medium' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
CASE status
|
||||
WHEN 'open' THEN 0
|
||||
WHEN 'in-progress' THEN 1
|
||||
WHEN 'accepted-risk' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [_row_to_dict(r) for r in rows],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_security_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return security backlog counts."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open,
|
||||
COUNT(*) FILTER (WHERE status = 'in-progress') AS in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'resolved') AS resolved,
|
||||
COUNT(*) FILTER (WHERE status = 'accepted-risk') AS accepted_risk,
|
||||
COUNT(*) FILTER (WHERE severity = 'critical' AND status != 'resolved') AS critical,
|
||||
COUNT(*) FILTER (WHERE severity = 'high' AND status != 'resolved') AS high,
|
||||
COUNT(*) FILTER (
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < NOW()
|
||||
AND status NOT IN ('resolved', 'accepted-risk')
|
||||
) AS overdue,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_security_backlog
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
if rows:
|
||||
d = dict(rows._mapping)
|
||||
return {k: (v or 0) for k, v in d.items()}
|
||||
return {"open": 0, "in_progress": 0, "resolved": 0, "accepted_risk": 0,
|
||||
"critical": 0, "high": 0, "overdue": 0, "total": 0}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_security_item(
|
||||
payload: SecurityItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new security backlog item."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_security_backlog
|
||||
(tenant_id, title, description, type, severity, status,
|
||||
source, cve, cvss, affected_asset, assigned_to, due_date, remediation)
|
||||
VALUES
|
||||
(:tenant_id, :title, :description, :type, :severity, :status,
|
||||
:source, :cve, :cvss, :affected_asset, :assigned_to, :due_date, :remediation)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"title": payload.title,
|
||||
"description": payload.description,
|
||||
"type": payload.type,
|
||||
"severity": payload.severity,
|
||||
"status": payload.status,
|
||||
"source": payload.source,
|
||||
"cve": payload.cve,
|
||||
"cvss": payload.cvss,
|
||||
"affected_asset": payload.affected_asset,
|
||||
"assigned_to": payload.assigned_to,
|
||||
"due_date": payload.due_date,
|
||||
"remediation": payload.remediation,
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/{item_id}")
|
||||
async def update_security_item(
|
||||
item_id: str,
|
||||
payload: SecurityItemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a security backlog item."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": item_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_security_backlog
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Security item not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
async def delete_security_item(
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_security_backlog
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": item_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Security item not found")
|
||||
Reference in New Issue
Block a user