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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user