+ {apiError && (
+
+ {apiError}
+
+ )}
+
{/* Tab Navigation */}
{tabs.map(t => (
@@ -193,14 +342,28 @@ export default function VVTPage() {
sortBy={sortBy}
setSortBy={setSortBy}
onEdit={(id) => { setEditingId(id); setTab('editor') }}
- onNew={() => {
+ onNew={async () => {
const vvtId = generateVVTId(activities.map(a => a.vvtId))
const newAct = createEmptyActivity(vvtId)
- updateActivities([...activities, newAct])
- setEditingId(newAct.id)
- setTab('editor')
+ try {
+ const created = await apiCreateActivity(newAct)
+ setActivities(prev => [...prev, created])
+ setEditingId(created.id)
+ setTab('editor')
+ } catch (err) {
+ setApiError('Fehler beim Anlegen der Verarbeitung.')
+ console.error(err)
+ }
+ }}
+ onDelete={async (id) => {
+ try {
+ await apiDeleteActivity(id)
+ setActivities(prev => prev.filter(a => a.id !== id))
+ } catch (err) {
+ setApiError('Fehler beim Löschen der Verarbeitung.')
+ console.error(err)
+ }
}}
- onDelete={(id) => updateActivities(activities.filter(a => a.id !== id))}
/>
)}
@@ -208,12 +371,13 @@ export default function VVTPage() {
{
- const idx = activities.findIndex(a => a.id === updated.id)
- if (idx >= 0) {
- const copy = [...activities]
- copy[idx] = { ...updated, updatedAt: new Date().toISOString() }
- updateActivities(copy)
+ onSave={async (updated) => {
+ try {
+ const saved = await apiUpdateActivity(updated.id, updated)
+ setActivities(prev => prev.map(a => a.id === saved.id ? saved : a))
+ } catch (err) {
+ setApiError('Fehler beim Speichern der Verarbeitung.')
+ console.error(err)
}
}}
onBack={() => setTab('verzeichnis')}
@@ -229,8 +393,17 @@ export default function VVTPage() {
setAnswers={updateProfilingAnswers}
preview={generatorPreview}
setPreview={setGeneratorPreview}
- onAdoptAll={(newActivities) => {
- updateActivities([...activities, ...newActivities])
+ onAdoptAll={async (newActivities) => {
+ const created: VVTActivity[] = []
+ for (const act of newActivities) {
+ try {
+ const saved = await apiCreateActivity(act)
+ created.push(saved)
+ } catch (err) {
+ console.error('Failed to create activity from generator:', err)
+ }
+ }
+ if (created.length > 0) setActivities(prev => [...prev, ...created])
setGeneratorPreview(null)
setGeneratorStep(1)
setTab('verzeichnis')
@@ -242,7 +415,15 @@ export default function VVTPage() {
{
+ try {
+ const saved = await apiUpsertOrganization(org)
+ setOrgHeader(saved)
+ } catch (err) {
+ setApiError('Fehler beim Speichern der Organisationsdaten.')
+ console.error(err)
+ }
+ }}
/>
)}
diff --git a/admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx b/admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
index c0adb63..b994713 100644
--- a/admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
+++ b/admin-compliance/components/sdk/source-policy/PIIRulesTab.tsx
@@ -56,6 +56,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState
(null)
+ // Category filter
+ const [categoryFilter, setCategoryFilter] = useState('')
+
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState(null)
@@ -77,12 +80,14 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
useEffect(() => {
fetchRules()
- }, [])
+ }, [categoryFilter])
const fetchRules = async () => {
try {
setLoading(true)
- const res = await fetch(`${apiBase}/pii-rules`)
+ const params = new URLSearchParams()
+ if (categoryFilter) params.append('category', categoryFilter)
+ const res = await fetch(`${apiBase}/pii-rules?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
@@ -321,17 +326,29 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
{/* Rules List Header */}
-
+
PII-Erkennungsregeln
-
setIsNewRule(true)}
- className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
- >
-
-
-
- Neue Regel
-
+
+
setCategoryFilter(e.target.value)}
+ className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+ >
+ Alle Kategorien
+ {CATEGORIES.map((c) => (
+ {c.label}
+ ))}
+
+
setIsNewRule(true)}
+ className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
+ >
+
+
+
+ Neue Regel
+
+
{/* Rules Table */}
diff --git a/admin-compliance/components/sdk/source-policy/SourcesTab.tsx b/admin-compliance/components/sdk/source-policy/SourcesTab.tsx
index 5058fb8..b1205a5 100644
--- a/admin-compliance/components/sdk/source-policy/SourcesTab.tsx
+++ b/admin-compliance/components/sdk/source-policy/SourcesTab.tsx
@@ -51,6 +51,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
+ const [sourceTypeFilter, setSourceTypeFilter] = useState('')
// Edit modal
const [editingSource, setEditingSource] = useState
(null)
@@ -69,7 +70,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
useEffect(() => {
fetchSources()
- }, [licenseFilter, statusFilter])
+ }, [licenseFilter, statusFilter, sourceTypeFilter])
const fetchSources = async () => {
try {
@@ -77,6 +78,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
+ if (sourceTypeFilter) params.append('source_type', sourceTypeFilter)
const res = await fetch(`${apiBase}/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
@@ -230,6 +232,18 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
Aktiv
Inaktiv
+ setSourceTypeFilter(e.target.value)}
+ className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+ >
+ Alle Typen
+ Rechtlich
+ Leitlinien
+ Vorlagen
+ Technisch
+ Sonstige
+
setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py
index 8ac51a1..3ab0e5c 100644
--- a/backend-compliance/compliance/api/__init__.py
+++ b/backend-compliance/compliance/api/__init__.py
@@ -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",
]
diff --git a/backend-compliance/compliance/api/schemas.py b/backend-compliance/compliance/api/schemas.py
index 69cd173..73ee7d3 100644
--- a/backend-compliance/compliance/api/schemas.py
+++ b/backend-compliance/compliance/api/schemas.py
@@ -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
diff --git a/backend-compliance/compliance/api/source_policy_router.py b/backend-compliance/compliance/api/source_policy_router.py
index af671e0..f991fe3 100644
--- a/backend-compliance/compliance/api/source_policy_router.py
+++ b/backend-compliance/compliance/api/source_policy_router.py
@@ -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": [
{
diff --git a/backend-compliance/compliance/api/vvt_routes.py b/backend-compliance/compliance/api/vvt_routes.py
new file mode 100644
index 0000000..ecdfc03
--- /dev/null
+++ b/backend-compliance/compliance/api/vvt_routes.py
@@ -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),
+ )
diff --git a/backend-compliance/compliance/db/vvt_models.py b/backend-compliance/compliance/db/vvt_models.py
new file mode 100644
index 0000000..0e73e1d
--- /dev/null
+++ b/backend-compliance/compliance/db/vvt_models.py
@@ -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""
+
+
+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""
+
+
+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""
diff --git a/backend-compliance/migrations/006_vvt.sql b/backend-compliance/migrations/006_vvt.sql
new file mode 100644
index 0000000..c2a134e
--- /dev/null
+++ b/backend-compliance/migrations/006_vvt.sql
@@ -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);
diff --git a/backend-compliance/requirements.txt b/backend-compliance/requirements.txt
index 5ef55ed..99aa4ff 100644
--- a/backend-compliance/requirements.txt
+++ b/backend-compliance/requirements.txt
@@ -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)
diff --git a/backend-compliance/tests/test_vvt_routes.py b/backend-compliance/tests/test_vvt_routes.py
new file mode 100644
index 0000000..cfdae04
--- /dev/null
+++ b/backend-compliance/tests/test_vvt_routes.py
@@ -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"
diff --git a/scripts/apply_training_migrations.sh b/scripts/apply_training_migrations.sh
new file mode 100644
index 0000000..956b5e8
--- /dev/null
+++ b/scripts/apply_training_migrations.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+# Apply Training Engine migrations on Mac Mini and verify
+# Usage: bash scripts/apply_training_migrations.sh
+
+set -e
+
+DOCKER="/usr/local/bin/docker"
+CONTAINER="bp-compliance-ai-sdk"
+PROJECT_DIR="/Users/benjaminadmin/Projekte/breakpilot-compliance"
+
+echo "==> Applying Training Engine migrations on Mac Mini..."
+
+ssh macmini "cd ${PROJECT_DIR} && \
+ ${DOCKER} exec ${CONTAINER} \
+ psql \"\${DATABASE_URL}\" -f /migrations/014_training_engine.sql \
+ && echo 'Migration 014 applied' \
+ || echo 'Migration 014 may already be applied (table exists)'"
+
+ssh macmini "cd ${PROJECT_DIR} && \
+ ${DOCKER} exec ${CONTAINER} \
+ psql \"\${DATABASE_URL}\" -f /migrations/016_training_media.sql \
+ && echo 'Migration 016 applied' \
+ || echo 'Migration 016 may already be applied'"
+
+echo ""
+echo "==> Verifying training service..."
+curl -sf "https://macmini:8093/health" && echo "Health check: OK" || echo "Health check: FAILED"
+
+echo ""
+echo "==> Checking training modules endpoint..."
+curl -sf \
+ "https://macmini:8093/sdk/v1/training/modules" \
+ -H "X-Tenant-ID: 9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" \
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Modules found: {len(d.get(\"modules\",[]))}')" \
+ || echo "Training modules endpoint check failed"
+
+echo ""
+echo "Done."
diff --git a/scripts/apply_vvt_migration.sh b/scripts/apply_vvt_migration.sh
new file mode 100644
index 0000000..0a58b59
--- /dev/null
+++ b/scripts/apply_vvt_migration.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+# Apply VVT migration and rebuild backend-compliance on Mac Mini
+# Usage: bash scripts/apply_vvt_migration.sh
+
+set -e
+
+DOCKER="/usr/local/bin/docker"
+BACKEND_CONTAINER="bp-compliance-backend"
+PROJECT_DIR="/Users/benjaminadmin/Projekte/breakpilot-compliance"
+
+echo "==> Pushing code to Mac Mini..."
+git push origin main && git push gitea main
+
+echo "==> Pulling code on Mac Mini..."
+ssh macmini "cd ${PROJECT_DIR} && git pull --no-rebase origin main"
+
+echo "==> Applying VVT migration (006_vvt.sql)..."
+ssh macmini "cd ${PROJECT_DIR} && \
+ ${DOCKER} exec ${BACKEND_CONTAINER} \
+ python3 -c \"
+import psycopg2
+import os
+
+conn = psycopg2.connect(os.environ['DATABASE_URL'])
+conn.autocommit = True
+cur = conn.cursor()
+with open('/app/migrations/006_vvt.sql', 'r') as f:
+ sql = f.read()
+cur.execute(sql)
+cur.close()
+conn.close()
+print('VVT migration applied successfully')
+\"" || echo "Note: Migration may use different DB connection method. Trying psql..."
+
+ssh macmini "cd ${PROJECT_DIR} && \
+ ${DOCKER} exec ${BACKEND_CONTAINER} \
+ psql \"\${DATABASE_URL}\" -f /app/migrations/006_vvt.sql \
+ && echo 'VVT migration (psql) applied' \
+ || echo 'Could not apply via psql, check manually'"
+
+echo ""
+echo "==> Rebuilding backend-compliance..."
+ssh macmini "cd ${PROJECT_DIR} && \
+ ${DOCKER} compose build --no-cache backend-compliance && \
+ ${DOCKER} compose up -d backend-compliance"
+
+echo ""
+echo "==> Verifying VVT endpoint..."
+sleep 5
+curl -sf "https://macmini:8002/api/compliance/vvt/stats" \
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'VVT stats: total={d.get(\"total\",0)}')" \
+ || echo "VVT endpoint check: needs backend restart"
+
+echo ""
+echo "Done. Check logs: ssh macmini '${DOCKER} logs -f ${BACKEND_CONTAINER}'"