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

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:
Benjamin Admin
2026-03-03 18:04:53 +01:00
parent 9143b84daa
commit 25d5da78ef
19 changed files with 5718 additions and 524 deletions

View File

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

View 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")

View File

@@ -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")

View 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")

View 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")