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
@@ -454,6 +454,13 @@ export default function DocumentGeneratorPage() {
</button> </button>
</StepHeader> </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 */} {/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -737,6 +744,24 @@ export default function DocumentGeneratorPage() {
Kopieren Kopieren
</button> </button>
<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" className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
> >
Als PDF exportieren Als PDF exportieren
+229 -48
View File
@@ -47,25 +47,149 @@ import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export' type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
const STORAGE_KEY = 'bp_vvt' const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
interface VVTData { // =============================================================================
activities: VVTActivity[] // API CLIENT
orgHeader: VVTOrganizationHeader // =============================================================================
profilingAnswers: ProfilingAnswers
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 { function activityToApi(act: VVTActivity): Record<string, unknown> {
if (typeof window === 'undefined') return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} } return {
try { vvt_id: act.vvtId,
const stored = localStorage.getItem(STORAGE_KEY) name: act.name,
if (stored) return JSON.parse(stored) description: act.description,
} catch { /* ignore */ } purposes: act.purposes,
return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} } 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) { function orgHeaderFromApi(raw: any): VVTOrganizationHeader {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) 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 [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
const [generatorStep, setGeneratorStep] = useState(1) const [generatorStep, setGeneratorStep] = useState(1)
const [generatorPreview, setGeneratorPreview] = useState<VVTActivity[] | null>(null) 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(() => { useEffect(() => {
const data = loadData() try {
setActivities(data.activities) const stored = localStorage.getItem(PROFILING_STORAGE_KEY)
setOrgHeader(data.orgHeader) if (stored) setProfilingAnswers(JSON.parse(stored))
setProfilingAnswers(data.profilingAnswers) } catch { /* ignore */ }
}, []) }, [])
// Save to localStorage on change // Load activities + org header from API
const persist = useCallback((acts: VVTActivity[], org: VVTOrganizationHeader, prof: ProfilingAnswers) => { useEffect(() => {
saveData({ activities: acts, orgHeader: org, profilingAnswers: prof }) async function loadFromApi() {
setIsLoading(true)
setApiError(null)
try {
const [acts, org] = await Promise.all([
apiListActivities(),
apiGetOrganization(),
])
setActivities(acts)
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 updateActivities = useCallback((acts: VVTActivity[]) => {
setActivities(acts)
persist(acts, orgHeader, profilingAnswers)
}, [orgHeader, profilingAnswers, persist])
const updateOrgHeader = useCallback((org: VVTOrganizationHeader) => {
setOrgHeader(org)
persist(activities, org, profilingAnswers)
}, [activities, profilingAnswers, persist])
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => { const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
setProfilingAnswers(prof) setProfilingAnswers(prof)
persist(activities, orgHeader, prof) try {
}, [activities, orgHeader, persist]) localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
} catch { /* ignore */ }
}, [])
// Computed stats // Computed stats
const activeCount = activities.filter(a => a.status === 'APPROVED').length const activeCount = activities.filter(a => a.status === 'APPROVED').length
@@ -147,6 +282,14 @@ export default function VVTPage() {
{ id: 'export', label: 'Export & Compliance' }, { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@@ -157,6 +300,12 @@ export default function VVTPage() {
tips={stepInfo.tips} 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 */} {/* Tab Navigation */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg"> <div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
{tabs.map(t => ( {tabs.map(t => (
@@ -193,14 +342,28 @@ export default function VVTPage() {
sortBy={sortBy} sortBy={sortBy}
setSortBy={setSortBy} setSortBy={setSortBy}
onEdit={(id) => { setEditingId(id); setTab('editor') }} onEdit={(id) => { setEditingId(id); setTab('editor') }}
onNew={() => { onNew={async () => {
const vvtId = generateVVTId(activities.map(a => a.vvtId)) const vvtId = generateVVTId(activities.map(a => a.vvtId))
const newAct = createEmptyActivity(vvtId) const newAct = createEmptyActivity(vvtId)
updateActivities([...activities, newAct]) try {
setEditingId(newAct.id) const created = await apiCreateActivity(newAct)
setTab('editor') 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 <TabEditor
activity={editingActivity} activity={editingActivity}
activities={activities} activities={activities}
onSave={(updated) => { onSave={async (updated) => {
const idx = activities.findIndex(a => a.id === updated.id) try {
if (idx >= 0) { const saved = await apiUpdateActivity(updated.id, updated)
const copy = [...activities] setActivities(prev => prev.map(a => a.id === saved.id ? saved : a))
copy[idx] = { ...updated, updatedAt: new Date().toISOString() } } catch (err) {
updateActivities(copy) setApiError('Fehler beim Speichern der Verarbeitung.')
console.error(err)
} }
}} }}
onBack={() => setTab('verzeichnis')} onBack={() => setTab('verzeichnis')}
@@ -229,8 +393,17 @@ export default function VVTPage() {
setAnswers={updateProfilingAnswers} setAnswers={updateProfilingAnswers}
preview={generatorPreview} preview={generatorPreview}
setPreview={setGeneratorPreview} setPreview={setGeneratorPreview}
onAdoptAll={(newActivities) => { onAdoptAll={async (newActivities) => {
updateActivities([...activities, ...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) setGeneratorPreview(null)
setGeneratorStep(1) setGeneratorStep(1)
setTab('verzeichnis') setTab('verzeichnis')
@@ -242,7 +415,15 @@ export default function VVTPage() {
<TabExport <TabExport
activities={activities} activities={activities}
orgHeader={orgHeader} 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> </div>
@@ -56,6 +56,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Category filter
const [categoryFilter, setCategoryFilter] = useState('')
// Test panel // Test panel
const [testText, setTestText] = useState('') const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null) const [testResult, setTestResult] = useState<PIITestResult | null>(null)
@@ -77,12 +80,14 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
useEffect(() => { useEffect(() => {
fetchRules() fetchRules()
}, []) }, [categoryFilter])
const fetchRules = async () => { const fetchRules = async () => {
try { try {
setLoading(true) 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') if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json() const data = await res.json()
@@ -321,17 +326,29 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div> </div>
{/* Rules List Header */} {/* 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> <h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button <div className="flex gap-3 items-center">
onClick={() => setIsNewRule(true)} <select
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2" value={categoryFilter}
> onChange={(e) => setCategoryFilter(e.target.value)}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> >
</svg> <option value="">Alle Kategorien</option>
Neue Regel {CATEGORIES.map((c) => (
</button> <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"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
</div> </div>
{/* Rules Table */} {/* Rules Table */}
@@ -51,6 +51,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('') const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
// Edit modal // Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null) const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
@@ -69,7 +70,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
useEffect(() => { useEffect(() => {
fetchSources() fetchSources()
}, [licenseFilter, statusFilter]) }, [licenseFilter, statusFilter, sourceTypeFilter])
const fetchSources = async () => { const fetchSources = async () => {
try { try {
@@ -77,6 +78,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter) if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false') 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}`) const res = await fetch(`${apiBase}/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden') 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="active">Aktiv</option>
<option value="inactive">Inaktiv</option> <option value="inactive">Inaktiv</option>
</select> </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 <button
onClick={() => setIsNewSource(true)} onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2" className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
@@ -9,6 +9,7 @@ from .dashboard_routes import router as dashboard_router
from .scraper_routes import router as scraper_router from .scraper_routes import router as scraper_router
from .module_routes import router as module_router from .module_routes import router as module_router
from .isms_routes import router as isms_router from .isms_routes import router as isms_router
from .vvt_routes import router as vvt_router
# Include sub-routers # Include sub-routers
router.include_router(audit_router) router.include_router(audit_router)
@@ -19,6 +20,7 @@ router.include_router(dashboard_router)
router.include_router(scraper_router) router.include_router(scraper_router)
router.include_router(module_router) router.include_router(module_router)
router.include_router(isms_router) router.include_router(isms_router)
router.include_router(vvt_router)
__all__ = [ __all__ = [
"router", "router",
@@ -30,4 +32,5 @@ __all__ = [
"scraper_router", "scraper_router",
"module_router", "module_router",
"isms_router", "isms_router",
"vvt_router",
] ]
@@ -1849,3 +1849,143 @@ class ISO27001OverviewResponse(BaseModel):
policies_approved: int policies_approved: int
objectives_count: int objectives_count: int
objectives_achieved: 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
@@ -148,12 +148,18 @@ def _source_to_dict(source: AllowedSourceDB) -> dict:
@router.get("/sources") @router.get("/sources")
async def list_sources( async def list_sources(
active_only: bool = Query(False), active_only: bool = Query(False),
source_type: Optional[str] = Query(None),
license: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List all allowed sources.""" """List all allowed sources with optional filters."""
query = db.query(AllowedSourceDB) query = db.query(AllowedSourceDB)
if active_only: if active_only:
query = query.filter(AllowedSourceDB.active == True) 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() sources = query.order_by(AllowedSourceDB.name).all()
return { return {
"sources": [ "sources": [
@@ -328,9 +334,15 @@ async def update_operation(
# ============================================================================= # =============================================================================
@router.get("/pii-rules") @router.get("/pii-rules")
async def list_pii_rules(db: Session = Depends(get_db)): async def list_pii_rules(
"""List all PII rules.""" category: Optional[str] = Query(None),
rules = db.query(PIIRuleDB).order_by(PIIRuleDB.category, PIIRuleDB.name).all() 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 { return {
"rules": [ "rules": [
{ {
@@ -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),
)
@@ -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}>"
+66
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);
+1
View File
@@ -24,6 +24,7 @@ anthropic==0.75.0
# PDF Generation (GDPR export, audit reports) # PDF Generation (GDPR export, audit reports)
weasyprint==66.0 weasyprint==66.0
reportlab==4.2.5
Jinja2==3.1.6 Jinja2==3.1.6
# Document Processing (Word import for consent admin) # Document Processing (Word import for consent admin)
+222
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"
+38
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."
+55
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}'"