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

@@ -454,6 +454,13 @@ export default function DocumentGeneratorPage() {
</button>
</StepHeader>
{/* Service unreachable warning */}
{!isLoading && !status && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
<strong>Template-Service nicht erreichbar.</strong> Stellen Sie sicher, dass breakpilot-core läuft (<code>curl -sf http://macmini:8099/health</code>). Das Suchen und Zusammenstellen von Vorlagen ist erst nach Verbindung möglich.
</div>
)}
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -737,6 +744,24 @@ export default function DocumentGeneratorPage() {
Kopieren
</button>
<button
onClick={() => {
const printWindow = window.open('', '_blank')
if (!printWindow) return
let content = combinedContent
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) {
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
}
}
const attributions = selectedTemplateObjects
.filter(t => t.attributionRequired && t.attributionText)
.map(t => `<li>${t.attributionText}</li>`)
.join('')
printWindow.document.write(`<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Datenschutzdokument</title><style>body{font-family:Arial,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}h2{border-bottom:1px solid #ccc;padding-bottom:8px;margin-top:32px}hr{border:none;border-top:1px solid #eee;margin:24px 0}.attribution{font-size:12px;color:#666;margin-top:48px;border-top:1px solid #ddd;padding-top:16px}@media print{body{margin:0}}</style></head><body><div style="white-space:pre-wrap">${content.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>${attributions ? `<div class="attribution"><strong>Quellenangaben:</strong><ul>${attributions}</ul></div>` : ''}</body></html>`)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 500)
}}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Als PDF exportieren

View File

@@ -47,25 +47,149 @@ import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
const STORAGE_KEY = 'bp_vvt'
const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
interface VVTData {
activities: VVTActivity[]
orgHeader: VVTOrganizationHeader
profilingAnswers: ProfilingAnswers
// =============================================================================
// API CLIENT
// =============================================================================
const VVT_API_BASE = '/api/sdk/v1/compliance/vvt'
function activityFromApi(raw: any): VVTActivity {
return {
id: raw.id,
vvtId: raw.vvt_id,
name: raw.name || '',
description: raw.description || '',
purposes: raw.purposes || [],
legalBases: raw.legal_bases || [],
dataSubjectCategories: raw.data_subject_categories || [],
personalDataCategories: raw.personal_data_categories || [],
recipientCategories: raw.recipient_categories || [],
thirdCountryTransfers: raw.third_country_transfers || [],
retentionPeriod: raw.retention_period || { description: '' },
tomDescription: raw.tom_description || '',
businessFunction: raw.business_function || 'other',
systems: raw.systems || [],
deploymentModel: raw.deployment_model || 'cloud',
dataSources: raw.data_sources || [],
dataFlows: raw.data_flows || [],
protectionLevel: raw.protection_level || 'MEDIUM',
dpiaRequired: raw.dpia_required || false,
structuredToms: raw.structured_toms || { accessControl: [], confidentiality: [], integrity: [], availability: [], separation: [] },
status: raw.status || 'DRAFT',
responsible: raw.responsible || '',
owner: raw.owner || '',
createdAt: raw.created_at || new Date().toISOString(),
updatedAt: raw.updated_at || raw.created_at || new Date().toISOString(),
}
}
function loadData(): VVTData {
if (typeof window === 'undefined') return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch { /* ignore */ }
return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
function activityToApi(act: VVTActivity): Record<string, unknown> {
return {
vvt_id: act.vvtId,
name: act.name,
description: act.description,
purposes: act.purposes,
legal_bases: act.legalBases,
data_subject_categories: act.dataSubjectCategories,
personal_data_categories: act.personalDataCategories,
recipient_categories: act.recipientCategories,
third_country_transfers: act.thirdCountryTransfers,
retention_period: act.retentionPeriod,
tom_description: act.tomDescription,
business_function: act.businessFunction,
systems: act.systems,
deployment_model: act.deploymentModel,
data_sources: act.dataSources,
data_flows: act.dataFlows,
protection_level: act.protectionLevel,
dpia_required: act.dpiaRequired,
structured_toms: act.structuredToms,
status: act.status,
responsible: act.responsible,
owner: act.owner,
}
}
function saveData(data: VVTData) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
function orgHeaderFromApi(raw: any): VVTOrganizationHeader {
return {
organizationName: raw.organization_name || '',
industry: raw.industry || '',
locations: raw.locations || [],
employeeCount: raw.employee_count || 0,
dpoName: raw.dpo_name || '',
dpoContact: raw.dpo_contact || '',
vvtVersion: raw.vvt_version || '1.0',
lastReviewDate: raw.last_review_date || '',
nextReviewDate: raw.next_review_date || '',
reviewInterval: raw.review_interval || 'annual',
}
}
function orgHeaderToApi(org: VVTOrganizationHeader): Record<string, unknown> {
return {
organization_name: org.organizationName,
industry: org.industry,
locations: org.locations,
employee_count: org.employeeCount,
dpo_name: org.dpoName,
dpo_contact: org.dpoContact,
vvt_version: org.vvtVersion,
last_review_date: org.lastReviewDate || null,
next_review_date: org.nextReviewDate || null,
review_interval: org.reviewInterval,
}
}
async function apiListActivities(): Promise<VVTActivity[]> {
const res = await fetch(`${VVT_API_BASE}/activities`)
if (!res.ok) throw new Error(`GET activities failed: ${res.status}`)
const data = await res.json()
return data.map(activityFromApi)
}
async function apiGetOrganization(): Promise<VVTOrganizationHeader | null> {
const res = await fetch(`${VVT_API_BASE}/organization`)
if (!res.ok) return null
const data = await res.json()
if (!data) return null
return orgHeaderFromApi(data)
}
async function apiCreateActivity(act: VVTActivity): Promise<VVTActivity> {
const res = await fetch(`${VVT_API_BASE}/activities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activityToApi(act)),
})
if (!res.ok) throw new Error(`POST activity failed: ${res.status}`)
return activityFromApi(await res.json())
}
async function apiUpdateActivity(id: string, act: VVTActivity): Promise<VVTActivity> {
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activityToApi(act)),
})
if (!res.ok) throw new Error(`PUT activity failed: ${res.status}`)
return activityFromApi(await res.json())
}
async function apiDeleteActivity(id: string): Promise<void> {
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`DELETE activity failed: ${res.status}`)
}
async function apiUpsertOrganization(org: VVTOrganizationHeader): Promise<VVTOrganizationHeader> {
const res = await fetch(`${VVT_API_BASE}/organization`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orgHeaderToApi(org)),
})
if (!res.ok) throw new Error(`PUT organization failed: ${res.status}`)
return orgHeaderFromApi(await res.json())
}
// =============================================================================
@@ -84,34 +208,45 @@ export default function VVTPage() {
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
const [generatorStep, setGeneratorStep] = useState(1)
const [generatorPreview, setGeneratorPreview] = useState<VVTActivity[] | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [apiError, setApiError] = useState<string | null>(null)
// Load from localStorage
// Load profiling answers from localStorage (UI state only)
useEffect(() => {
const data = loadData()
setActivities(data.activities)
setOrgHeader(data.orgHeader)
setProfilingAnswers(data.profilingAnswers)
try {
const stored = localStorage.getItem(PROFILING_STORAGE_KEY)
if (stored) setProfilingAnswers(JSON.parse(stored))
} catch { /* ignore */ }
}, [])
// Save to localStorage on change
const persist = useCallback((acts: VVTActivity[], org: VVTOrganizationHeader, prof: ProfilingAnswers) => {
saveData({ activities: acts, orgHeader: org, profilingAnswers: prof })
}, [])
const updateActivities = useCallback((acts: VVTActivity[]) => {
// Load activities + org header from API
useEffect(() => {
async function loadFromApi() {
setIsLoading(true)
setApiError(null)
try {
const [acts, org] = await Promise.all([
apiListActivities(),
apiGetOrganization(),
])
setActivities(acts)
persist(acts, orgHeader, profilingAnswers)
}, [orgHeader, profilingAnswers, persist])
const updateOrgHeader = useCallback((org: VVTOrganizationHeader) => {
setOrgHeader(org)
persist(activities, org, profilingAnswers)
}, [activities, profilingAnswers, persist])
if (org) setOrgHeader(org)
} catch (err) {
setApiError('Fehler beim Laden der VVT-Daten. Bitte Verbindung prüfen.')
console.error('VVT API load error:', err)
} finally {
setIsLoading(false)
}
}
loadFromApi()
}, [])
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
setProfilingAnswers(prof)
persist(activities, orgHeader, prof)
}, [activities, orgHeader, persist])
try {
localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
} catch { /* ignore */ }
}, [])
// Computed stats
const activeCount = activities.filter(a => a.status === 'APPROVED').length
@@ -147,6 +282,14 @@ export default function VVTPage() {
{ id: 'export', label: 'Export & Compliance' },
]
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
)
}
return (
<div className="space-y-6">
<StepHeader
@@ -157,6 +300,12 @@ export default function VVTPage() {
tips={stepInfo.tips}
/>
{apiError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{apiError}
</div>
)}
{/* Tab Navigation */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
{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)
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() {
<TabEditor
activity={editingActivity}
activities={activities}
onSave={(updated) => {
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() {
<TabExport
activities={activities}
orgHeader={orgHeader}
onUpdateOrgHeader={updateOrgHeader}
onUpdateOrgHeader={async (org) => {
try {
const saved = await apiUpsertOrganization(org)
setOrgHeader(saved)
} catch (err) {
setApiError('Fehler beim Speichern der Organisationsdaten.')
console.error(err)
}
}}
/>
)}
</div>

View File

@@ -56,6 +56,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Category filter
const [categoryFilter, setCategoryFilter] = useState('')
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(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,8 +326,19 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<div className="flex gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => 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"
>
<option value="">Alle Kategorien</option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
@@ -333,6 +349,7 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
Neue Regel
</button>
</div>
</div>
{/* Rules Table */}
{loading ? (

View File

@@ -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<AllowedSource | null>(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) {
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<select
value={sourceTypeFilter}
onChange={(e) => 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"
>
<option value="">Alle Typen</option>
<option value="legal">Rechtlich</option>
<option value="guidance">Leitlinien</option>
<option value="template">Vorlagen</option>
<option value="technical">Technisch</option>
<option value="other">Sonstige</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"

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"

View File

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

View File

@@ -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}'"