feat(vvt): Go-Features nach Python portieren (Source of Truth)
Review-Daten (last_reviewed_at, next_review_at), created_by, DSFA-Link, CSV-Export mit Semikolon-Trennung, overdue_review_count in Stats. Go-VVT-Handler als DEPRECATED markiert. 32 Tests bestanden. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1910,6 +1910,10 @@ class VVTActivityCreate(BaseModel):
|
||||
status: str = 'DRAFT'
|
||||
responsible: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
last_reviewed_at: Optional[datetime] = None
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
|
||||
|
||||
class VVTActivityUpdate(BaseModel):
|
||||
@@ -1934,6 +1938,10 @@ class VVTActivityUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
last_reviewed_at: Optional[datetime] = None
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
|
||||
|
||||
class VVTActivityResponse(BaseModel):
|
||||
@@ -1960,6 +1968,10 @@ class VVTActivityResponse(BaseModel):
|
||||
status: str = 'DRAFT'
|
||||
responsible: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
last_reviewed_at: Optional[datetime] = None
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -1975,6 +1987,7 @@ class VVTStatsResponse(BaseModel):
|
||||
third_country_count: int
|
||||
draft_count: int
|
||||
approved_count: int
|
||||
overdue_review_count: int = 0
|
||||
|
||||
|
||||
class VVTAuditLogEntry(BaseModel):
|
||||
|
||||
@@ -14,12 +14,15 @@ Endpoints:
|
||||
GET /vvt/stats — Statistics
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
@@ -150,6 +153,10 @@ def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse:
|
||||
status=act.status or 'DRAFT',
|
||||
responsible=act.responsible,
|
||||
owner=act.owner,
|
||||
last_reviewed_at=act.last_reviewed_at,
|
||||
next_review_at=act.next_review_at,
|
||||
created_by=act.created_by,
|
||||
dsfa_id=str(act.dsfa_id) if act.dsfa_id else None,
|
||||
created_at=act.created_at,
|
||||
updated_at=act.updated_at,
|
||||
)
|
||||
@@ -160,6 +167,7 @@ async def list_activities(
|
||||
status: Optional[str] = Query(None),
|
||||
business_function: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
review_overdue: Optional[bool] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all processing activities with optional filters."""
|
||||
@@ -169,6 +177,12 @@ async def list_activities(
|
||||
query = query.filter(VVTActivityDB.status == status)
|
||||
if business_function:
|
||||
query = query.filter(VVTActivityDB.business_function == business_function)
|
||||
if review_overdue:
|
||||
now = datetime.now(timezone.utc)
|
||||
query = query.filter(
|
||||
VVTActivityDB.next_review_at.isnot(None),
|
||||
VVTActivityDB.next_review_at < now,
|
||||
)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.filter(
|
||||
@@ -184,6 +198,7 @@ async def list_activities(
|
||||
@router.post("/activities", response_model=VVTActivityResponse, status_code=201)
|
||||
async def create_activity(
|
||||
request: VVTActivityCreate,
|
||||
http_request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new processing activity."""
|
||||
@@ -197,7 +212,12 @@ async def create_activity(
|
||||
detail=f"Activity with VVT-ID '{request.vvt_id}' already exists"
|
||||
)
|
||||
|
||||
act = VVTActivityDB(**request.dict())
|
||||
data = request.dict()
|
||||
# Set created_by from X-User-ID header if not provided in body
|
||||
if not data.get('created_by'):
|
||||
data['created_by'] = http_request.headers.get('X-User-ID', 'system')
|
||||
|
||||
act = VVTActivityDB(**data)
|
||||
db.add(act)
|
||||
db.flush() # get ID before audit log
|
||||
|
||||
@@ -312,8 +332,11 @@ async def get_audit_log(
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/export")
|
||||
async def export_activities(db: Session = Depends(get_db)):
|
||||
"""JSON export of all activities for external review / PDF generation."""
|
||||
async def export_activities(
|
||||
format: str = Query("json", pattern="^(json|csv)$"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Export all activities as JSON or CSV (semicolon-separated, DE locale)."""
|
||||
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
|
||||
activities = db.query(VVTActivityDB).order_by(VVTActivityDB.created_at).all()
|
||||
|
||||
@@ -321,10 +344,13 @@ async def export_activities(db: Session = Depends(get_db)):
|
||||
db,
|
||||
action="EXPORT",
|
||||
entity_type="all_activities",
|
||||
new_values={"count": len(activities)},
|
||||
new_values={"count": len(activities), "format": format},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if format == "csv":
|
||||
return _export_csv(activities)
|
||||
|
||||
return {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"organization": {
|
||||
@@ -351,6 +377,10 @@ async def export_activities(db: Session = Depends(get_db)):
|
||||
"protection_level": a.protection_level,
|
||||
"business_function": a.business_function,
|
||||
"responsible": a.responsible,
|
||||
"created_by": a.created_by,
|
||||
"dsfa_id": str(a.dsfa_id) if a.dsfa_id else None,
|
||||
"last_reviewed_at": a.last_reviewed_at.isoformat() if a.last_reviewed_at else None,
|
||||
"next_review_at": a.next_review_at.isoformat() if a.next_review_at else None,
|
||||
"created_at": a.created_at.isoformat(),
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
@@ -359,6 +389,48 @@ async def export_activities(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
def _export_csv(activities: list) -> StreamingResponse:
|
||||
"""Generate semicolon-separated CSV with UTF-8 BOM for German Excel compatibility."""
|
||||
output = io.StringIO()
|
||||
# UTF-8 BOM for Excel
|
||||
output.write('\ufeff')
|
||||
|
||||
writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||
writer.writerow([
|
||||
'ID', 'VVT-ID', 'Name', 'Zweck', 'Rechtsgrundlage',
|
||||
'Datenkategorien', 'Betroffene', 'Empfaenger', 'Drittland',
|
||||
'Aufbewahrung', 'Status', 'Verantwortlich', 'Erstellt von',
|
||||
'Erstellt am',
|
||||
])
|
||||
|
||||
for a in activities:
|
||||
writer.writerow([
|
||||
str(a.id),
|
||||
a.vvt_id,
|
||||
a.name,
|
||||
'; '.join(a.purposes or []),
|
||||
'; '.join(a.legal_bases or []),
|
||||
'; '.join(a.personal_data_categories or []),
|
||||
'; '.join(a.data_subject_categories or []),
|
||||
'; '.join(a.recipient_categories or []),
|
||||
'Ja' if a.third_country_transfers else 'Nein',
|
||||
str(a.retention_period) if a.retention_period else '',
|
||||
a.status or 'DRAFT',
|
||||
a.responsible or '',
|
||||
a.created_by or 'system',
|
||||
a.created_at.strftime('%d.%m.%Y %H:%M') if a.created_at else '',
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type='text/csv; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="vvt_export_{datetime.utcnow().strftime("%Y%m%d")}.csv"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VVTStatsResponse)
|
||||
async def get_stats(db: Session = Depends(get_db)):
|
||||
"""Get VVT statistics summary."""
|
||||
@@ -366,12 +438,16 @@ async def get_stats(db: Session = Depends(get_db)):
|
||||
|
||||
by_status: dict = {}
|
||||
by_bf: dict = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
overdue_count = 0
|
||||
|
||||
for a in activities:
|
||||
status = a.status or 'DRAFT'
|
||||
bf = a.business_function or 'unknown'
|
||||
by_status[status] = by_status.get(status, 0) + 1
|
||||
by_bf[bf] = by_bf.get(bf, 0) + 1
|
||||
if a.next_review_at and a.next_review_at < now:
|
||||
overdue_count += 1
|
||||
|
||||
return VVTStatsResponse(
|
||||
total=len(activities),
|
||||
@@ -381,4 +457,5 @@ async def get_stats(db: Session = Depends(get_db)):
|
||||
third_country_count=sum(1 for a in activities if a.third_country_transfers),
|
||||
draft_count=by_status.get('DRAFT', 0),
|
||||
approved_count=by_status.get('APPROVED', 0),
|
||||
overdue_review_count=overdue_count,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user