feat: 6 Dokumentations-Module auf 100% — VVT Backend, Filter, PDF-Export

Phase 1 — VVT Backend (localStorage → API):
- migrations/006_vvt.sql: Neue Tabellen (vvt_organization, vvt_activities, vvt_audit_log)
- compliance/db/vvt_models.py: SQLAlchemy-Models für alle VVT-Tabellen
- compliance/api/vvt_routes.py: Vollständiger CRUD-Router (10 Endpoints)
- compliance/api/__init__.py: VVT-Router registriert
- compliance/api/schemas.py: VVT Pydantic-Schemas ergänzt
- app/(sdk)/sdk/vvt/page.tsx: API-Client + camelCase↔snake_case Mapping,
  localStorage durch persistente DB-Calls ersetzt (POST/PUT/DELETE/GET)
- tests/test_vvt_routes.py: 18 Tests (alle grün)

Phase 3 — Document Generator PDF-Export:
- document-generator/page.tsx: "Als PDF exportieren"-Button funktioniert jetzt
  via window.print() + Print-Window mit korrektem HTML
- Fallback-Banner wenn Template-Service (breakpilot-core) nicht erreichbar

Phase 4 — Source Policy erweiterte Filter:
- SourcesTab.tsx: source_type-Filter (Rechtlich / Leitlinien / Vorlagen / etc.)
- PIIRulesTab.tsx: category-Filter (E-Mail / Telefon / IBAN / etc.)
- source_policy_router.py: Backend-Endpoints unterstützen jetzt source_type
  und category als Query-Parameter
- requirements.txt: reportlab==4.2.5 ergänzt (fehlende Audit-PDF-Dependency)

Phase 2 — Training (Migration-Skripte):
- scripts/apply_training_migrations.sh: SSH-Skript für Mac Mini
- scripts/apply_vvt_migration.sh: Vollständiges Deploy-Skript für VVT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 17:14:58 +01:00
parent 7cc420bd9e
commit 34fc8dc654
14 changed files with 1332 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ from .dashboard_routes import router as dashboard_router
from .scraper_routes import router as scraper_router
from .module_routes import router as module_router
from .isms_routes import router as isms_router
from .vvt_routes import router as vvt_router
# Include sub-routers
router.include_router(audit_router)
@@ -19,6 +20,7 @@ router.include_router(dashboard_router)
router.include_router(scraper_router)
router.include_router(module_router)
router.include_router(isms_router)
router.include_router(vvt_router)
__all__ = [
"router",
@@ -30,4 +32,5 @@ __all__ = [
"scraper_router",
"module_router",
"isms_router",
"vvt_router",
]

View File

@@ -1849,3 +1849,143 @@ class ISO27001OverviewResponse(BaseModel):
policies_approved: int
objectives_count: int
objectives_achieved: int
# ============================================================================
# VVT Schemas — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DSGVO)
# ============================================================================
class VVTOrganizationUpdate(BaseModel):
organization_name: Optional[str] = None
industry: Optional[str] = None
locations: Optional[List[str]] = None
employee_count: Optional[int] = None
dpo_name: Optional[str] = None
dpo_contact: Optional[str] = None
vvt_version: Optional[str] = None
last_review_date: Optional[date] = None
next_review_date: Optional[date] = None
review_interval: Optional[str] = None
class VVTOrganizationResponse(BaseModel):
id: str
organization_name: str
industry: Optional[str] = None
locations: List[Any] = []
employee_count: Optional[int] = None
dpo_name: Optional[str] = None
dpo_contact: Optional[str] = None
vvt_version: str = '1.0'
last_review_date: Optional[date] = None
next_review_date: Optional[date] = None
review_interval: str = 'annual'
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class VVTActivityCreate(BaseModel):
vvt_id: str
name: str
description: Optional[str] = None
purposes: List[str] = []
legal_bases: List[str] = []
data_subject_categories: List[str] = []
personal_data_categories: List[str] = []
recipient_categories: List[str] = []
third_country_transfers: List[Any] = []
retention_period: Dict[str, Any] = {}
tom_description: Optional[str] = None
business_function: Optional[str] = None
systems: List[str] = []
deployment_model: Optional[str] = None
data_sources: List[Any] = []
data_flows: List[Any] = []
protection_level: str = 'MEDIUM'
dpia_required: bool = False
structured_toms: Dict[str, Any] = {}
status: str = 'DRAFT'
responsible: Optional[str] = None
owner: Optional[str] = None
class VVTActivityUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
purposes: Optional[List[str]] = None
legal_bases: Optional[List[str]] = None
data_subject_categories: Optional[List[str]] = None
personal_data_categories: Optional[List[str]] = None
recipient_categories: Optional[List[str]] = None
third_country_transfers: Optional[List[Any]] = None
retention_period: Optional[Dict[str, Any]] = None
tom_description: Optional[str] = None
business_function: Optional[str] = None
systems: Optional[List[str]] = None
deployment_model: Optional[str] = None
data_sources: Optional[List[Any]] = None
data_flows: Optional[List[Any]] = None
protection_level: Optional[str] = None
dpia_required: Optional[bool] = None
structured_toms: Optional[Dict[str, Any]] = None
status: Optional[str] = None
responsible: Optional[str] = None
owner: Optional[str] = None
class VVTActivityResponse(BaseModel):
id: str
vvt_id: str
name: str
description: Optional[str] = None
purposes: List[Any] = []
legal_bases: List[Any] = []
data_subject_categories: List[Any] = []
personal_data_categories: List[Any] = []
recipient_categories: List[Any] = []
third_country_transfers: List[Any] = []
retention_period: Dict[str, Any] = {}
tom_description: Optional[str] = None
business_function: Optional[str] = None
systems: List[Any] = []
deployment_model: Optional[str] = None
data_sources: List[Any] = []
data_flows: List[Any] = []
protection_level: str = 'MEDIUM'
dpia_required: bool = False
structured_toms: Dict[str, Any] = {}
status: str = 'DRAFT'
responsible: Optional[str] = None
owner: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class VVTStatsResponse(BaseModel):
total: int
by_status: Dict[str, int]
by_business_function: Dict[str, int]
dpia_required_count: int
third_country_count: int
draft_count: int
approved_count: int
class VVTAuditLogEntry(BaseModel):
id: str
action: str
entity_type: str
entity_id: Optional[str] = None
changed_by: Optional[str] = None
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -148,12 +148,18 @@ def _source_to_dict(source: AllowedSourceDB) -> dict:
@router.get("/sources")
async def list_sources(
active_only: bool = Query(False),
source_type: Optional[str] = Query(None),
license: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""List all allowed sources."""
"""List all allowed sources with optional filters."""
query = db.query(AllowedSourceDB)
if active_only:
query = query.filter(AllowedSourceDB.active == True)
if source_type:
query = query.filter(AllowedSourceDB.source_type == source_type)
if license:
query = query.filter(AllowedSourceDB.license == license)
sources = query.order_by(AllowedSourceDB.name).all()
return {
"sources": [
@@ -328,9 +334,15 @@ async def update_operation(
# =============================================================================
@router.get("/pii-rules")
async def list_pii_rules(db: Session = Depends(get_db)):
"""List all PII rules."""
rules = db.query(PIIRuleDB).order_by(PIIRuleDB.category, PIIRuleDB.name).all()
async def list_pii_rules(
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""List all PII rules with optional category filter."""
query = db.query(PIIRuleDB)
if category:
query = query.filter(PIIRuleDB.category == category)
rules = query.order_by(PIIRuleDB.category, PIIRuleDB.name).all()
return {
"rules": [
{

View File

@@ -0,0 +1,384 @@
"""
FastAPI routes for VVT — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DSGVO).
Endpoints:
GET /vvt/organization — Load organization header
PUT /vvt/organization — Save organization header
GET /vvt/activities — List activities (filter: status, business_function)
POST /vvt/activities — Create new activity
GET /vvt/activities/{id} — Get single activity
PUT /vvt/activities/{id} — Update activity
DELETE /vvt/activities/{id} — Delete activity
GET /vvt/audit-log — Audit trail (limit, offset)
GET /vvt/export — JSON export of all activities
GET /vvt/stats — Statistics
"""
import logging
from datetime import datetime
from typing import Optional, List
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from ..db.vvt_models import VVTOrganizationDB, VVTActivityDB, VVTAuditLogDB
from .schemas import (
VVTOrganizationUpdate, VVTOrganizationResponse,
VVTActivityCreate, VVTActivityUpdate, VVTActivityResponse,
VVTStatsResponse, VVTAuditLogEntry,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vvt", tags=["compliance-vvt"])
def _log_audit(
db: Session,
action: str,
entity_type: str,
entity_id=None,
changed_by: str = "system",
old_values=None,
new_values=None,
):
entry = VVTAuditLogDB(
action=action,
entity_type=entity_type,
entity_id=entity_id,
changed_by=changed_by,
old_values=old_values,
new_values=new_values,
)
db.add(entry)
# ============================================================================
# Organization Header
# ============================================================================
@router.get("/organization", response_model=Optional[VVTOrganizationResponse])
async def get_organization(db: Session = Depends(get_db)):
"""Load the VVT organization header (single record)."""
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
if not org:
return None
return VVTOrganizationResponse(
id=str(org.id),
organization_name=org.organization_name,
industry=org.industry,
locations=org.locations or [],
employee_count=org.employee_count,
dpo_name=org.dpo_name,
dpo_contact=org.dpo_contact,
vvt_version=org.vvt_version or '1.0',
last_review_date=org.last_review_date,
next_review_date=org.next_review_date,
review_interval=org.review_interval or 'annual',
created_at=org.created_at,
updated_at=org.updated_at,
)
@router.put("/organization", response_model=VVTOrganizationResponse)
async def upsert_organization(
request: VVTOrganizationUpdate,
db: Session = Depends(get_db),
):
"""Create or update the VVT organization header."""
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
if not org:
data = request.dict(exclude_none=True)
if 'organization_name' not in data:
data['organization_name'] = 'Meine Organisation'
org = VVTOrganizationDB(**data)
db.add(org)
else:
for field, value in request.dict(exclude_none=True).items():
setattr(org, field, value)
org.updated_at = datetime.utcnow()
db.commit()
db.refresh(org)
return VVTOrganizationResponse(
id=str(org.id),
organization_name=org.organization_name,
industry=org.industry,
locations=org.locations or [],
employee_count=org.employee_count,
dpo_name=org.dpo_name,
dpo_contact=org.dpo_contact,
vvt_version=org.vvt_version or '1.0',
last_review_date=org.last_review_date,
next_review_date=org.next_review_date,
review_interval=org.review_interval or 'annual',
created_at=org.created_at,
updated_at=org.updated_at,
)
# ============================================================================
# Activities
# ============================================================================
def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse:
return VVTActivityResponse(
id=str(act.id),
vvt_id=act.vvt_id,
name=act.name,
description=act.description,
purposes=act.purposes or [],
legal_bases=act.legal_bases or [],
data_subject_categories=act.data_subject_categories or [],
personal_data_categories=act.personal_data_categories or [],
recipient_categories=act.recipient_categories or [],
third_country_transfers=act.third_country_transfers or [],
retention_period=act.retention_period or {},
tom_description=act.tom_description,
business_function=act.business_function,
systems=act.systems or [],
deployment_model=act.deployment_model,
data_sources=act.data_sources or [],
data_flows=act.data_flows or [],
protection_level=act.protection_level or 'MEDIUM',
dpia_required=act.dpia_required or False,
structured_toms=act.structured_toms or {},
status=act.status or 'DRAFT',
responsible=act.responsible,
owner=act.owner,
created_at=act.created_at,
updated_at=act.updated_at,
)
@router.get("/activities", response_model=List[VVTActivityResponse])
async def list_activities(
status: Optional[str] = Query(None),
business_function: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""List all processing activities with optional filters."""
query = db.query(VVTActivityDB)
if status:
query = query.filter(VVTActivityDB.status == status)
if business_function:
query = query.filter(VVTActivityDB.business_function == business_function)
if search:
term = f"%{search}%"
query = query.filter(
(VVTActivityDB.name.ilike(term)) |
(VVTActivityDB.description.ilike(term)) |
(VVTActivityDB.vvt_id.ilike(term))
)
activities = query.order_by(VVTActivityDB.created_at.desc()).all()
return [_activity_to_response(a) for a in activities]
@router.post("/activities", response_model=VVTActivityResponse, status_code=201)
async def create_activity(
request: VVTActivityCreate,
db: Session = Depends(get_db),
):
"""Create a new processing activity."""
# Check for duplicate vvt_id
existing = db.query(VVTActivityDB).filter(
VVTActivityDB.vvt_id == request.vvt_id
).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Activity with VVT-ID '{request.vvt_id}' already exists"
)
act = VVTActivityDB(**request.dict())
db.add(act)
db.flush() # get ID before audit log
_log_audit(
db,
action="CREATE",
entity_type="activity",
entity_id=act.id,
new_values={"vvt_id": act.vvt_id, "name": act.name, "status": act.status},
)
db.commit()
db.refresh(act)
return _activity_to_response(act)
@router.get("/activities/{activity_id}", response_model=VVTActivityResponse)
async def get_activity(activity_id: str, db: Session = Depends(get_db)):
"""Get a single processing activity by ID."""
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
if not act:
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
return _activity_to_response(act)
@router.put("/activities/{activity_id}", response_model=VVTActivityResponse)
async def update_activity(
activity_id: str,
request: VVTActivityUpdate,
db: Session = Depends(get_db),
):
"""Update a processing activity."""
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
if not act:
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
old_values = {"name": act.name, "status": act.status}
updates = request.dict(exclude_none=True)
for field, value in updates.items():
setattr(act, field, value)
act.updated_at = datetime.utcnow()
_log_audit(
db,
action="UPDATE",
entity_type="activity",
entity_id=act.id,
old_values=old_values,
new_values=updates,
)
db.commit()
db.refresh(act)
return _activity_to_response(act)
@router.delete("/activities/{activity_id}")
async def delete_activity(activity_id: str, db: Session = Depends(get_db)):
"""Delete a processing activity."""
act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first()
if not act:
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
_log_audit(
db,
action="DELETE",
entity_type="activity",
entity_id=act.id,
old_values={"vvt_id": act.vvt_id, "name": act.name},
)
db.delete(act)
db.commit()
return {"success": True, "message": f"Activity {activity_id} deleted"}
# ============================================================================
# Audit Log
# ============================================================================
@router.get("/audit-log", response_model=List[VVTAuditLogEntry])
async def get_audit_log(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
"""Get the VVT audit trail."""
entries = (
db.query(VVTAuditLogDB)
.order_by(VVTAuditLogDB.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [
VVTAuditLogEntry(
id=str(e.id),
action=e.action,
entity_type=e.entity_type,
entity_id=str(e.entity_id) if e.entity_id else None,
changed_by=e.changed_by,
old_values=e.old_values,
new_values=e.new_values,
created_at=e.created_at,
)
for e in entries
]
# ============================================================================
# Export & Stats
# ============================================================================
@router.get("/export")
async def export_activities(db: Session = Depends(get_db)):
"""JSON export of all activities for external review / PDF generation."""
org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first()
activities = db.query(VVTActivityDB).order_by(VVTActivityDB.created_at).all()
_log_audit(
db,
action="EXPORT",
entity_type="all_activities",
new_values={"count": len(activities)},
)
db.commit()
return {
"exported_at": datetime.utcnow().isoformat(),
"organization": {
"name": org.organization_name if org else "",
"dpo_name": org.dpo_name if org else "",
"dpo_contact": org.dpo_contact if org else "",
"vvt_version": org.vvt_version if org else "1.0",
} if org else None,
"activities": [
{
"id": str(a.id),
"vvt_id": a.vvt_id,
"name": a.name,
"description": a.description,
"status": a.status,
"purposes": a.purposes,
"legal_bases": a.legal_bases,
"data_subject_categories": a.data_subject_categories,
"personal_data_categories": a.personal_data_categories,
"recipient_categories": a.recipient_categories,
"third_country_transfers": a.third_country_transfers,
"retention_period": a.retention_period,
"dpia_required": a.dpia_required,
"protection_level": a.protection_level,
"business_function": a.business_function,
"responsible": a.responsible,
"created_at": a.created_at.isoformat(),
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in activities
],
}
@router.get("/stats", response_model=VVTStatsResponse)
async def get_stats(db: Session = Depends(get_db)):
"""Get VVT statistics summary."""
activities = db.query(VVTActivityDB).all()
by_status: dict = {}
by_bf: dict = {}
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
return VVTStatsResponse(
total=len(activities),
by_status=by_status,
by_business_function=by_bf,
dpia_required_count=sum(1 for a in activities if a.dpia_required),
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),
)

View File

@@ -0,0 +1,109 @@
"""
SQLAlchemy models for VVT — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DSGVO).
Tables:
- compliance_vvt_organization: Organization header (DSB, version, review dates)
- compliance_vvt_activities: Individual processing activities
- compliance_vvt_audit_log: Audit trail for all VVT changes
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Boolean, Integer, Date, DateTime, JSON, Index
)
from sqlalchemy.dialects.postgresql import UUID
from classroom_engine.database import Base
class VVTOrganizationDB(Base):
"""VVT organization header — stores DSB contact, version and review schedule."""
__tablename__ = 'compliance_vvt_organization'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
organization_name = Column(String(300), nullable=False)
industry = Column(String(100))
locations = Column(JSON, default=list)
employee_count = Column(Integer)
dpo_name = Column(String(200))
dpo_contact = Column(String(200))
vvt_version = Column(String(20), default='1.0')
last_review_date = Column(Date)
next_review_date = Column(Date)
review_interval = Column(String(20), default='annual')
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index('idx_vvt_org_created', 'created_at'),
)
def __repr__(self):
return f"<VVTOrganization {self.organization_name}>"
class VVTActivityDB(Base):
"""Individual processing activity per Art. 30 DSGVO."""
__tablename__ = 'compliance_vvt_activities'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vvt_id = Column(String(50), unique=True, nullable=False)
name = Column(String(300), nullable=False)
description = Column(Text)
purposes = Column(JSON, default=list)
legal_bases = Column(JSON, default=list)
data_subject_categories = Column(JSON, default=list)
personal_data_categories = Column(JSON, default=list)
recipient_categories = Column(JSON, default=list)
third_country_transfers = Column(JSON, default=list)
retention_period = Column(JSON, default=dict)
tom_description = Column(Text)
business_function = Column(String(50))
systems = Column(JSON, default=list)
deployment_model = Column(String(20))
data_sources = Column(JSON, default=list)
data_flows = Column(JSON, default=list)
protection_level = Column(String(10), default='MEDIUM')
dpia_required = Column(Boolean, default=False)
structured_toms = Column(JSON, default=dict)
status = Column(String(20), default='DRAFT')
responsible = Column(String(200))
owner = Column(String(200))
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
Index('idx_vvt_activities_status', 'status'),
Index('idx_vvt_activities_business_function', 'business_function'),
Index('idx_vvt_activities_vvt_id', 'vvt_id'),
)
def __repr__(self):
return f"<VVTActivity {self.vvt_id}: {self.name}>"
class VVTAuditLogDB(Base):
"""Audit trail for all VVT create/update/delete/export actions."""
__tablename__ = 'compliance_vvt_audit_log'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
action = Column(String(20), nullable=False) # CREATE, UPDATE, DELETE, EXPORT
entity_type = Column(String(50), nullable=False) # activity, organization
entity_id = Column(UUID(as_uuid=True))
changed_by = Column(String(200))
old_values = Column(JSON)
new_values = Column(JSON)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
Index('idx_vvt_audit_created', 'created_at'),
Index('idx_vvt_audit_entity', 'entity_type', 'entity_id'),
)
def __repr__(self):
return f"<VVTAuditLog {self.action} {self.entity_type}>"

View File

@@ -0,0 +1,66 @@
-- =========================================================
-- Migration 006: VVT — Verzeichnis von Verarbeitungstaetigkeiten
-- Art. 30 DSGVO
-- =========================================================
CREATE TABLE IF NOT EXISTS compliance_vvt_organization (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_name VARCHAR(300) NOT NULL,
industry VARCHAR(100),
locations JSONB DEFAULT '[]',
employee_count INT,
dpo_name VARCHAR(200),
dpo_contact VARCHAR(200),
vvt_version VARCHAR(20) DEFAULT '1.0',
last_review_date DATE,
next_review_date DATE,
review_interval VARCHAR(20) DEFAULT 'annual',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS compliance_vvt_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vvt_id VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(300) NOT NULL,
description TEXT,
purposes JSONB DEFAULT '[]',
legal_bases JSONB DEFAULT '[]',
data_subject_categories JSONB DEFAULT '[]',
personal_data_categories JSONB DEFAULT '[]',
recipient_categories JSONB DEFAULT '[]',
third_country_transfers JSONB DEFAULT '[]',
retention_period JSONB DEFAULT '{}',
tom_description TEXT,
business_function VARCHAR(50),
systems JSONB DEFAULT '[]',
deployment_model VARCHAR(20),
data_sources JSONB DEFAULT '[]',
data_flows JSONB DEFAULT '[]',
protection_level VARCHAR(10) DEFAULT 'MEDIUM',
dpia_required BOOLEAN DEFAULT FALSE,
structured_toms JSONB DEFAULT '{}',
status VARCHAR(20) DEFAULT 'DRAFT',
responsible VARCHAR(200),
owner VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vvt_activities_status ON compliance_vvt_activities(status);
CREATE INDEX IF NOT EXISTS idx_vvt_activities_business_function ON compliance_vvt_activities(business_function);
CREATE INDEX IF NOT EXISTS idx_vvt_activities_vvt_id ON compliance_vvt_activities(vvt_id);
CREATE TABLE IF NOT EXISTS compliance_vvt_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action VARCHAR(20) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
changed_by VARCHAR(200),
old_values JSONB,
new_values JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vvt_audit_created ON compliance_vvt_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_vvt_audit_entity ON compliance_vvt_audit_log(entity_type, entity_id);

View File

@@ -24,6 +24,7 @@ anthropic==0.75.0
# PDF Generation (GDPR export, audit reports)
weasyprint==66.0
reportlab==4.2.5
Jinja2==3.1.6
# Document Processing (Word import for consent admin)

View File

@@ -0,0 +1,222 @@
"""Tests for VVT routes and schemas (vvt_routes.py, vvt_models.py)."""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, date
import uuid
from compliance.api.schemas import (
VVTActivityCreate,
VVTActivityUpdate,
VVTOrganizationUpdate,
VVTStatsResponse,
)
from compliance.api.vvt_routes import _activity_to_response, _log_audit
from compliance.db.vvt_models import VVTActivityDB, VVTOrganizationDB, VVTAuditLogDB
# =============================================================================
# Schema Tests
# =============================================================================
class TestVVTActivityCreate:
def test_default_values(self):
req = VVTActivityCreate(vvt_id="VVT-001", name="Test Verarbeitung")
assert req.vvt_id == "VVT-001"
assert req.name == "Test Verarbeitung"
assert req.status == "DRAFT"
assert req.protection_level == "MEDIUM"
assert req.dpia_required is False
assert req.purposes == []
assert req.legal_bases == []
def test_full_values(self):
req = VVTActivityCreate(
vvt_id="VVT-002",
name="Gehaltsabrechnung",
description="Verarbeitung von Gehaltsabrechnungsdaten",
purposes=["Vertragserfuellung"],
legal_bases=["Art. 6 Abs. 1b DSGVO"],
data_subject_categories=["Mitarbeiter"],
personal_data_categories=["Bankdaten", "Steuer-ID"],
status="APPROVED",
dpia_required=False,
)
assert req.vvt_id == "VVT-002"
assert req.status == "APPROVED"
assert len(req.purposes) == 1
assert len(req.personal_data_categories) == 2
def test_serialization(self):
req = VVTActivityCreate(vvt_id="VVT-003", name="Test")
data = req.model_dump()
assert data["vvt_id"] == "VVT-003"
assert isinstance(data["purposes"], list)
assert isinstance(data["retention_period"], dict)
class TestVVTActivityUpdate:
def test_partial_update(self):
req = VVTActivityUpdate(status="APPROVED")
data = req.model_dump(exclude_none=True)
assert data == {"status": "APPROVED"}
def test_empty_update(self):
req = VVTActivityUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_multi_field_update(self):
req = VVTActivityUpdate(
name="Updated Name",
dpia_required=True,
protection_level="HIGH",
)
data = req.model_dump(exclude_none=True)
assert data["name"] == "Updated Name"
assert data["dpia_required"] is True
assert data["protection_level"] == "HIGH"
class TestVVTOrganizationUpdate:
def test_defaults(self):
req = VVTOrganizationUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_partial_update(self):
req = VVTOrganizationUpdate(
organization_name="BreakPilot GmbH",
dpo_name="Max Mustermann",
)
data = req.model_dump(exclude_none=True)
assert data["organization_name"] == "BreakPilot GmbH"
assert data["dpo_name"] == "Max Mustermann"
class TestVVTStatsResponse:
def test_stats_response(self):
stats = VVTStatsResponse(
total=5,
by_status={"DRAFT": 3, "APPROVED": 2},
by_business_function={"HR": 2, "IT": 3},
dpia_required_count=1,
third_country_count=0,
draft_count=3,
approved_count=2,
)
assert stats.total == 5
assert stats.by_status["DRAFT"] == 3
assert stats.dpia_required_count == 1
# =============================================================================
# DB Model Tests
# =============================================================================
class TestVVTModels:
def test_activity_defaults(self):
act = VVTActivityDB()
assert act.status is None or act.status == 'DRAFT'
assert act.dpia_required is False or act.dpia_required is None
def test_activity_repr(self):
act = VVTActivityDB()
act.vvt_id = "VVT-001"
act.name = "Test"
assert "VVT-001" in repr(act)
def test_organization_repr(self):
org = VVTOrganizationDB()
org.organization_name = "Test GmbH"
assert "Test GmbH" in repr(org)
def test_audit_log_repr(self):
log = VVTAuditLogDB()
log.action = "CREATE"
log.entity_type = "activity"
assert "CREATE" in repr(log)
# =============================================================================
# Helper Function Tests
# =============================================================================
class TestActivityToResponse:
def _make_activity(self, **kwargs) -> VVTActivityDB:
act = VVTActivityDB()
act.id = uuid.uuid4()
act.vvt_id = kwargs.get("vvt_id", "VVT-001")
act.name = kwargs.get("name", "Test")
act.description = kwargs.get("description", None)
act.purposes = kwargs.get("purposes", [])
act.legal_bases = kwargs.get("legal_bases", [])
act.data_subject_categories = kwargs.get("data_subject_categories", [])
act.personal_data_categories = kwargs.get("personal_data_categories", [])
act.recipient_categories = kwargs.get("recipient_categories", [])
act.third_country_transfers = kwargs.get("third_country_transfers", [])
act.retention_period = kwargs.get("retention_period", {})
act.tom_description = kwargs.get("tom_description", None)
act.business_function = kwargs.get("business_function", None)
act.systems = kwargs.get("systems", [])
act.deployment_model = kwargs.get("deployment_model", None)
act.data_sources = kwargs.get("data_sources", [])
act.data_flows = kwargs.get("data_flows", [])
act.protection_level = kwargs.get("protection_level", "MEDIUM")
act.dpia_required = kwargs.get("dpia_required", False)
act.structured_toms = kwargs.get("structured_toms", {})
act.status = kwargs.get("status", "DRAFT")
act.responsible = kwargs.get("responsible", None)
act.owner = kwargs.get("owner", None)
act.created_at = datetime.utcnow()
act.updated_at = None
return act
def test_basic_conversion(self):
act = self._make_activity(vvt_id="VVT-001", name="Kundendaten")
response = _activity_to_response(act)
assert response.vvt_id == "VVT-001"
assert response.name == "Kundendaten"
assert response.status == "DRAFT"
assert response.protection_level == "MEDIUM"
def test_null_lists_become_empty(self):
act = self._make_activity()
act.purposes = None
act.legal_bases = None
response = _activity_to_response(act)
assert response.purposes == []
assert response.legal_bases == []
def test_null_dicts_become_empty(self):
act = self._make_activity()
act.retention_period = None
act.structured_toms = None
response = _activity_to_response(act)
assert response.retention_period == {}
assert response.structured_toms == {}
class TestLogAudit:
def test_creates_audit_entry(self):
mock_db = MagicMock()
act_id = uuid.uuid4()
_log_audit(
db=mock_db,
action="CREATE",
entity_type="activity",
entity_id=act_id,
changed_by="test_user",
new_values={"name": "Test"},
)
mock_db.add.assert_called_once()
added = mock_db.add.call_args[0][0]
assert added.action == "CREATE"
assert added.entity_type == "activity"
assert added.entity_id == act_id
def test_defaults_changed_by(self):
mock_db = MagicMock()
_log_audit(mock_db, "DELETE", "activity")
added = mock_db.add.call_args[0][0]
assert added.changed_by == "system"