4 Commits

Author SHA1 Message Date
Benjamin Admin
799668e472 fix: MkDocs Anker-Link in dokumentations-module.md korrigieren (#training → #training-engine)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 29s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:55:37 +01:00
Benjamin Admin
5c7c0055ff docs: MkDocs, SDK-Flow und Tests fuer 6 Dokumentations-Module aktualisieren
MkDocs:
- Neue Dokumentationsseite: docs-src/services/sdk-modules/dokumentations-module.md
  Beschreibt alle 6 Module (VVT, Source Policy, Document Generator,
  Audit Checklist, Audit Report, Training Engine) mit API-Endpoints,
  DB-Tabellen, Datenmodell und Besonderheiten
- mkdocs.yml: Neuen Eintrag "Dokumentations-Module (Paket 3+)" ergaenzt

SDK Flow (flow-data.ts):
- VVT: dbTables korrigiert ([] → compliance_vvt_organization/activities/audit_log),
  dbMode: none → read/write, descriptionLong auf Backend-Persistenz aktualisiert
- Training: dbTables ergaenzt (training_modules/assignments/quiz_*/matrix_entries/
  audit_log), dbMode: none → read/write, Beschreibung auf 28 Module aktualisiert
- Source Policy: Tabellennamen korrigiert (compliance_pii_field_rules →
  compliance_pii_rules, compliance_source_policies entfernt,
  compliance_source_operations ergaenzt)
- Document Generator: Beschreibung um PDF-Export (window.print) und
  Fallback-Banner ergaenzt

Tests:
- Neue Datei: tests/test_source_policy_routes.py (35 Tests, alle gruen)
  - Schema-Tests: SourceCreate, SourceUpdate, PIIRuleCreate, PIIRuleUpdate
  - DB-Model-Tests: AllowedSourceDB, PIIRuleDB
  - Filter-Logik: source_type-Filter und category-Filter Unit-Tests
  - Audit-Log-Helper: _log_audit Verhalten verifiziert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:48:55 +01:00
Benjamin Admin
ec53ba0350 fix: Audit PDF-Download-Pfad korrigiert (/pdf → /report/pdf)
Alle 3 Frontend-Seiten (audit-report, audit-report/[sessionId],
audit-checklist) riefen /sessions/{id}/pdf auf, aber der Backend-
Endpoint ist /sessions/{id}/report/pdf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:49:21 +01:00
Benjamin Admin
34fc8dc654 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>
2026-03-02 17:14:58 +01:00
21 changed files with 2012 additions and 79 deletions

View File

@@ -220,7 +220,7 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
inputs: ['modules'],
outputs: ['sourcePolicy'],
prerequisiteSteps: ['modules'],
dbTables: ['compliance_source_policies', 'compliance_allowed_sources', 'compliance_pii_field_rules', 'compliance_source_policy_audit'],
dbTables: ['compliance_allowed_sources', 'compliance_pii_rules', 'compliance_source_operations', 'compliance_source_policy_audit'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
@@ -480,14 +480,14 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
checkpointId: 'CP-VVT',
checkpointType: 'REQUIRED',
checkpointReviewer: 'DSB',
description: 'Erstellung des Verzeichnisses aller Verarbeitungstaetigkeiten nach Art. 30 DSGVO.',
descriptionLong: 'Das VVT (Verzeichnis von Verarbeitungstaetigkeiten) ist eine gesetzliche Pflichtdokumentation nach Art. 30 DSGVO. Fuer jede Verarbeitungstaetigkeit wird dokumentiert: Zweck, Rechtsgrundlage, Kategorien betroffener Personen, Datenkategorien, Empfaenger, Drittlandtransfers, Loeschfristen und TOMs. Das VVT wird automatisch aus den vorherigen Schritten zusammengestellt (Module, TOMs, Data Mapping). Der DSB muss das VVT freigeben.',
description: 'Erstellung des Verzeichnisses aller Verarbeitungstaetigkeiten nach Art. 30 DSGVO — vollstaendig backend-persistent.',
descriptionLong: 'Das VVT (Verzeichnis von Verarbeitungstaetigkeiten) ist eine gesetzliche Pflichtdokumentation nach Art. 30 DSGVO. Fuer jede Verarbeitungstaetigkeit wird dokumentiert: Zweck, Rechtsgrundlage, Kategorien betroffener Personen, Datenkategorien, Empfaenger, Drittlandtransfers, Loeschfristen und TOMs. Jede Aktivitaet wird mit einem eindeutigen VVT-ID versehen und in der Datenbank gespeichert (compliance_vvt_activities). Organisationsweite Metadaten (DSB-Kontakt, Branche, Standorte) werden separat verwaltet (compliance_vvt_organization). Alle Aenderungen werden in einem Audit-Log protokolliert (compliance_vvt_audit_log). Das VVT wird via FastAPI-Backend (backend-compliance:8002) persistent gespeichert. Der DSB muss das VVT freigeben.',
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
inputs: ['modules', 'toms', 'dataMapping'],
outputs: ['vvt'],
prerequisiteSteps: ['loeschfristen'],
dbTables: [],
dbMode: 'none',
dbTables: ['compliance_vvt_organization', 'compliance_vvt_activities', 'compliance_vvt_audit_log'],
dbMode: 'read/write',
ragCollections: ['bp_compliance_gesetze'],
ragPurpose: 'Art. 30 DSGVO Vorlage',
isOptional: false,
@@ -573,8 +573,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
checkpointId: 'CP-DOCGEN',
checkpointType: 'RECOMMENDED',
checkpointReviewer: 'NONE',
description: 'Generierung weiterer rechtlicher Dokumente (Impressum, AVV, Auftragsverarbeitung).',
descriptionLong: 'Der Dokumentengenerator erstellt zusaetzliche rechtliche Dokumente, die ueber die Pflichtdokumente hinausgehen: Impressum (nach TMG/DDG), Auftragsverarbeitungsvertraege (AVV nach Art. 28 DSGVO), Vertraulichkeitsvereinbarungen, Betriebsvereinbarungen zum Datenschutz und Datenschutz-Folgenabschaetzungs-Berichte. Die Templates werden aus bp_legal_templates geladen und mit den unternehmensspezifischen Daten befuellt.',
description: 'Generierung weiterer rechtlicher Dokumente (Impressum, AVV, Auftragsverarbeitung) mit PDF-Export.',
descriptionLong: 'Der Dokumentengenerator erstellt zusaetzliche rechtliche Dokumente, die ueber die Pflichtdokumente hinausgehen: Impressum (nach TMG/DDG), Auftragsverarbeitungsvertraege (AVV nach Art. 28 DSGVO), Vertraulichkeitsvereinbarungen, Betriebsvereinbarungen zum Datenschutz und Datenschutz-Folgenabschaetzungs-Berichte. Die Templates werden aus bp_legal_templates geladen und mit den unternehmensspezifischen Daten befuellt. PDF-Export ist direkt im Browser via window.print() moeglich. Steht der Template-Service (breakpilot-core) nicht bereit, erscheint ein Fallback-Banner mit Hinweis.',
legalBasis: 'Art. 28 DSGVO (Auftragsverarbeitung), DDG § 5 (Impressum)',
inputs: ['companyProfile', 'toms', 'vvt'],
outputs: ['generatedDocuments'],
@@ -790,13 +790,13 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
checkpointId: 'CP-TRAIN',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Durchfuehrung und Tracking von Compliance-Schulungen mit Zertifikaten.',
descriptionLong: 'Die Training Engine setzt den Schulungsplan der Academy um. Sie bietet interaktive Schulungsmodule mit Quizzes, Fallbeispielen und Zertifikaten. Jede abgeschlossene Schulung wird dokumentiert (Teilnehmer, Datum, Ergebnis) und dient als Evidence fuer Audits. Die Engine ueberwacht Faelligkeiten, sendet Erinnerungen bei ausstehenden Pflichtschulungen und generiert Compliance-Reports ueber den Schulungsstand aller Mitarbeiter.',
description: 'Durchfuehrung und Tracking von Compliance-Schulungen mit Quizzes und Zertifikaten — vollstaendig backend-persistent.',
descriptionLong: 'Die Training Engine setzt den Schulungsplan der Academy um. Sie bietet interaktive Schulungsmodule (DSGVO, AI Act, ISO 27001 etc.) mit Quizzes, automatisch generierten Inhalten und Zertifikaten. 28 vordefinierte Schulungsmodule sind hinterlegt. Jede abgeschlossene Schulung wird dokumentiert (Teilnehmer, Datum, Ergebnis, Quiz-Versuch) und dient als Evidence fuer Audits. Die Engine ueberwacht Faelligkeiten, sendet Erinnerungen bei ausstehenden Pflichtschulungen und generiert Compliance-Reports ueber den Schulungsstand aller Mitarbeiter. Backend: ai-compliance-sdk (Go, Port 8093) via /sdk/v1/training/*. Schulungsmatrix ordnet Rollen Pflichtmodulen zu.',
inputs: ['trainingPlan', 'modules'],
outputs: ['trainingContent'],
prerequisiteSteps: ['academy'],
dbTables: [],
dbMode: 'none',
dbTables: ['training_modules', 'training_assignments', 'training_quiz_questions', 'training_quiz_attempts', 'training_matrix_entries', 'training_audit_log'],
dbMode: 'read/write',
ragCollections: [],
isOptional: false,
url: '/sdk/training',

View File

@@ -506,7 +506,7 @@ export default function AuditChecklistPage() {
setGeneratingPdf(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/pdf?language=${pdfLanguage}`)
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)

View File

@@ -104,7 +104,7 @@ export default function AuditReportDetailPage() {
setGeneratingPdf(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/report/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('PDF-Generierung fehlgeschlagen')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)

View File

@@ -133,7 +133,7 @@ export default function AuditReportPage() {
const downloadPdf = async (sessionId: string) => {
try {
setGeneratingPdf(sessionId)
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/report/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)

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 })
// 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)
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) => {
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)
setTab('editor')
try {
const created = await apiCreateActivity(newAct)
setActivities(prev => [...prev, created])
setEditingId(created.id)
setTab('editor')
} catch (err) {
setApiError('Fehler beim Anlegen der Verarbeitung.')
console.error(err)
}
}}
onDelete={async (id) => {
try {
await apiDeleteActivity(id)
setActivities(prev => prev.filter(a => a.id !== id))
} catch (err) {
setApiError('Fehler beim Löschen der Verarbeitung.')
console.error(err)
}
}}
onDelete={(id) => updateActivities(activities.filter(a => a.id !== id))}
/>
)}
@@ -208,12 +371,13 @@ export default function VVTPage() {
<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,17 +326,29 @@ 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>
<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 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"
>
<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>
{/* Rules Table */}

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,347 @@
"""Tests for Source Policy Router (source_policy_router.py).
Fokus: Neue Filter-Parameter source_type (list_sources) und category (list_pii_rules)
sowie Schema-Validierungen und Audit-Log-Helper.
"""
import pytest
from unittest.mock import MagicMock, patch, call
from datetime import datetime
import uuid
from compliance.api.source_policy_router import (
SourceCreate,
SourceUpdate,
PIIRuleCreate,
PIIRuleUpdate,
_log_audit,
)
from compliance.db.source_policy_models import (
AllowedSourceDB,
PIIRuleDB,
SourcePolicyAuditDB,
)
# =============================================================================
# Schema Tests: SourceCreate
# =============================================================================
class TestSourceCreate:
def test_default_values(self):
req = SourceCreate(domain="eur-lex.europa.eu", name="EUR-Lex")
assert req.domain == "eur-lex.europa.eu"
assert req.name == "EUR-Lex"
assert req.source_type == "legal"
assert req.active is True
assert req.trust_boost == 0.5
def test_legal_source_type(self):
req = SourceCreate(domain="gesetze.de", name="Gesetze.de", source_type="legal")
assert req.source_type == "legal"
def test_guidance_source_type(self):
req = SourceCreate(domain="dsb.gv.at", name="DSB Austria", source_type="guidance")
assert req.source_type == "guidance"
def test_technical_source_type(self):
req = SourceCreate(domain="bsi.bund.de", name="BSI", source_type="technical")
assert req.source_type == "technical"
def test_trust_boost_range_low(self):
req = SourceCreate(domain="example.com", name="Test", trust_boost=0.0)
assert req.trust_boost == 0.0
def test_trust_boost_range_high(self):
req = SourceCreate(domain="example.com", name="Test", trust_boost=1.0)
assert req.trust_boost == 1.0
def test_trust_boost_invalid_raises(self):
with pytest.raises(Exception):
SourceCreate(domain="example.com", name="Test", trust_boost=1.5)
def test_optional_fields_none(self):
req = SourceCreate(domain="example.com", name="Test")
assert req.description is None
assert req.license is None
assert req.legal_basis is None
assert req.metadata is None
def test_full_values(self):
req = SourceCreate(
domain="eur-lex.europa.eu",
name="EUR-Lex",
description="EU-Rechtsquellen",
license="CC-BY",
legal_basis="Art. 5 DSGVO",
trust_boost=0.9,
source_type="legal",
active=True,
metadata={"region": "EU"},
)
assert req.trust_boost == 0.9
assert req.metadata == {"region": "EU"}
# =============================================================================
# Schema Tests: SourceUpdate
# =============================================================================
class TestSourceUpdate:
def test_partial_update_source_type(self):
req = SourceUpdate(source_type="guidance")
data = req.model_dump(exclude_none=True)
assert data == {"source_type": "guidance"}
def test_partial_update_active(self):
req = SourceUpdate(active=False)
data = req.model_dump(exclude_none=True)
assert data == {"active": False}
def test_empty_update(self):
req = SourceUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_multi_field_update(self):
req = SourceUpdate(source_type="technical", trust_boost=0.8, active=True)
data = req.model_dump(exclude_none=True)
assert data["source_type"] == "technical"
assert data["trust_boost"] == 0.8
assert data["active"] is True
# =============================================================================
# Schema Tests: PIIRuleCreate
# =============================================================================
class TestPIIRuleCreate:
def test_default_values(self):
req = PIIRuleCreate(name="E-Mail-Erkennung", category="pii")
assert req.name == "E-Mail-Erkennung"
assert req.category == "pii"
assert req.action == "mask"
assert req.active is True
assert req.pattern is None
def test_financial_category(self):
req = PIIRuleCreate(name="IBAN", category="financial", pattern=r"DE\d{20}")
assert req.category == "financial"
assert req.pattern == r"DE\d{20}"
def test_health_category(self):
req = PIIRuleCreate(name="Diagnose", category="health")
assert req.category == "health"
def test_id_category(self):
req = PIIRuleCreate(name="Personalausweis", category="id")
assert req.category == "id"
def test_action_redact(self):
req = PIIRuleCreate(name="Test", category="pii", action="redact")
assert req.action == "redact"
def test_serialization(self):
req = PIIRuleCreate(name="Telefon", category="pii", pattern=r"\+49\d+")
data = req.model_dump()
assert data["name"] == "Telefon"
assert data["category"] == "pii"
assert data["pattern"] == r"\+49\d+"
# =============================================================================
# Schema Tests: PIIRuleUpdate
# =============================================================================
class TestPIIRuleUpdate:
def test_partial_update_category(self):
req = PIIRuleUpdate(category="financial")
data = req.model_dump(exclude_none=True)
assert data == {"category": "financial"}
def test_partial_update_active(self):
req = PIIRuleUpdate(active=False)
data = req.model_dump(exclude_none=True)
assert data == {"active": False}
def test_empty_update(self):
req = PIIRuleUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_multi_field_update(self):
req = PIIRuleUpdate(name="Updated", category="id", action="redact")
data = req.model_dump(exclude_none=True)
assert data["name"] == "Updated"
assert data["category"] == "id"
assert data["action"] == "redact"
# =============================================================================
# DB Model Tests: AllowedSourceDB
# =============================================================================
class TestAllowedSourceDB:
def test_default_source_type(self):
src = AllowedSourceDB(
id=uuid.uuid4(),
domain="example.com",
name="Test Source",
)
# Column default is 'legal'
assert src.__tablename__ == 'compliance_allowed_sources'
def test_repr(self):
src = AllowedSourceDB(domain="bsi.bund.de", name="BSI")
assert "bsi.bund.de" in repr(src)
assert "BSI" in repr(src)
def test_tablename(self):
assert AllowedSourceDB.__tablename__ == 'compliance_allowed_sources'
# =============================================================================
# DB Model Tests: PIIRuleDB
# =============================================================================
class TestPIIRuleDB:
def test_tablename(self):
assert PIIRuleDB.__tablename__ == 'compliance_pii_rules'
# =============================================================================
# Filter Logic Tests (Unit — Mock DB)
# =============================================================================
class TestSourceTypeFilter:
"""Tests that list_sources correctly applies the source_type filter."""
def test_source_type_filter_applied(self):
"""source_type param should be passed to DB query filter."""
db_mock = MagicMock()
query_mock = MagicMock()
db_mock.query.return_value = query_mock
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value = query_mock
query_mock.offset.return_value = query_mock
query_mock.limit.return_value = query_mock
query_mock.all.return_value = []
# Simulate filter call chain for source_type='legal'
filtered = query_mock.filter.return_value
filtered.filter.return_value = filtered
filtered.order_by.return_value = filtered
filtered.offset.return_value = filtered
filtered.limit.return_value = filtered
filtered.all.return_value = []
# Verify filter is called when source_type is provided
result = db_mock.query(AllowedSourceDB)
result = result.filter(AllowedSourceDB.source_type == "legal")
assert query_mock.filter.call_count == 1
def test_no_filter_without_source_type(self):
"""Without source_type param, no filter should be applied."""
db_mock = MagicMock()
query_mock = MagicMock()
db_mock.query.return_value = query_mock
query_mock.order_by.return_value = query_mock
query_mock.offset.return_value = query_mock
query_mock.limit.return_value = query_mock
query_mock.all.return_value = []
# Without filter
result = db_mock.query(AllowedSourceDB)
result = result.order_by(AllowedSourceDB.name)
# filter NOT called → count should be 0
assert query_mock.filter.call_count == 0
class TestCategoryFilter:
"""Tests that list_pii_rules correctly applies the category filter."""
def test_category_filter_applied(self):
"""category param should be passed to DB query filter."""
db_mock = MagicMock()
query_mock = MagicMock()
db_mock.query.return_value = query_mock
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value = query_mock
query_mock.all.return_value = []
# Simulate filter for category='financial'
result = db_mock.query(PIIRuleDB)
result = result.filter(PIIRuleDB.category == "financial")
assert query_mock.filter.call_count == 1
def test_category_values(self):
"""All valid category values should be accepted by PIIRuleCreate."""
categories = ["pii", "financial", "health", "id", "location", "other"]
for cat in categories:
req = PIIRuleCreate(name=f"Rule {cat}", category=cat)
assert req.category == cat
# =============================================================================
# Audit Log Helper Tests
# =============================================================================
class TestLogAudit:
def test_creates_audit_entry(self):
db_mock = MagicMock()
entity_id = uuid.uuid4()
_log_audit(
db_mock,
action="create",
entity_type="source",
entity_id=entity_id,
new_values={"name": "Test Source", "domain": "example.com"},
)
db_mock.add.assert_called_once()
audit_obj = db_mock.add.call_args[0][0]
assert isinstance(audit_obj, SourcePolicyAuditDB)
assert audit_obj.action == "create"
assert audit_obj.entity_type == "source"
def test_creates_audit_entry_with_old_values(self):
db_mock = MagicMock()
entity_id = uuid.uuid4()
_log_audit(
db_mock,
action="update",
entity_type="source",
entity_id=entity_id,
old_values={"name": "Old Name"},
new_values={"name": "New Name"},
)
audit_obj = db_mock.add.call_args[0][0]
assert audit_obj.action == "update"
assert audit_obj.old_values == {"name": "Old Name"}
assert audit_obj.new_values == {"name": "New Name"}
def test_creates_audit_entry_for_delete(self):
db_mock = MagicMock()
entity_id = uuid.uuid4()
_log_audit(
db_mock,
action="delete",
entity_type="pii_rule",
entity_id=entity_id,
old_values={"name": "Deleted Rule"},
)
audit_obj = db_mock.add.call_args[0][0]
assert audit_obj.action == "delete"
assert audit_obj.entity_type == "pii_rule"
def test_add_called_without_commit(self):
"""_log_audit calls db.add() but NOT db.commit() — commit happens at the endpoint level."""
db_mock = MagicMock()
_log_audit(db_mock, "create", "source", uuid.uuid4())
db_mock.add.assert_called_once()
db_mock.commit.assert_not_called()

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,318 @@
# Dokumentations-Module (Paket 3 + ergänzende Module)
Diese Seite beschreibt die sechs Module, die die Compliance-Dokumentation vervollständigen:
**VVT**, **Training**, **Source Policy**, **Document Generator**, **Audit Checklist** und **Audit Report**.
Alle Module sind vollständig backend-persistent und bieten CRUD-Operationen über die REST-API.
---
## Übersicht
| Modul | SDK-Route | Paket | Checkpoint | Status |
|-------|-----------|-------|-----------|--------|
| [VVT](#vvt) | `/sdk/vvt` | Dokumentation | CP-VVT (REQUIRED / DSB) | 100% |
| [Source Policy](#source-policy) | `/sdk/source-policy` | Vorbereitung | CP-SPOL (REQUIRED) | 100% |
| [Document Generator](#document-generator) | `/sdk/document-generator` | Rechtliche Texte | CP-DOCGEN (RECOMMENDED) | 100% |
| [Audit Checklist](#audit-checklist) | `/sdk/audit-checklist` | Analyse | CP-CHK (RECOMMENDED) | 100% |
| [Audit Report](#audit-report) | `/sdk/audit-report` | Analyse | CP-AREP (REQUIRED) | 100% |
| [Training Engine](#training-engine) | `/sdk/training` | Betrieb | CP-TRAIN (REQUIRED) | 100% |
---
## VVT
**Route:** `/sdk/vvt` | **Backend:** `backend-compliance:8002` | **Rechtsgrundlage:** Art. 30 DSGVO
### Funktionen
- Anlegen, Bearbeiten und Löschen von Verarbeitungstätigkeiten (CRUD)
- Eindeutiger VVT-ID pro Tätigkeit (z.B. `VVT-001`)
- Vollständige Dokumentation je Tätigkeit: Zweck, Rechtsgrundlage, Datenkategorien, Empfänger, Drittlandtransfers, Löschfristen, TOMs
- Organisationsweite Metadaten: DSB-Kontakt, Branche, Standorte, Mitarbeiterzahl
- Audit-Log für alle Änderungen (CREATE / UPDATE / DELETE)
- Statistik-Endpoint: Gesamt, nach Status, nach Geschäftsfunktion, DSFA-pflichtige Tätigkeiten
- JSON-Export aller Aktivitäten
- Status-Workflow: `DRAFT``REVIEW``APPROVED``ARCHIVED`
- Schutzstufenklassifizierung: `LOW` / `MEDIUM` / `HIGH` / `CRITICAL`
- Profiling-Assistent (UI-seitig) zur Vorbefüllung
### API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/api/compliance/vvt/organization` | Organisationsheader laden |
| `PUT` | `/api/compliance/vvt/organization` | Organisationsheader speichern |
| `GET` | `/api/compliance/vvt/activities` | Alle Tätigkeiten (Filter: status, business_function) |
| `POST` | `/api/compliance/vvt/activities` | Neue Tätigkeit anlegen |
| `GET` | `/api/compliance/vvt/activities/{id}` | Einzelne Tätigkeit |
| `PUT` | `/api/compliance/vvt/activities/{id}` | Tätigkeit aktualisieren |
| `DELETE` | `/api/compliance/vvt/activities/{id}` | Tätigkeit löschen |
| `GET` | `/api/compliance/vvt/audit-log` | Änderungshistorie |
| `GET` | `/api/compliance/vvt/export` | JSON-Export aller Tätigkeiten |
| `GET` | `/api/compliance/vvt/stats` | Statistiken |
### DB-Tabellen
| Tabelle | Modus | Beschreibung |
|---------|-------|--------------|
| `compliance_vvt_organization` | read/write | Organisationsweite Metadaten |
| `compliance_vvt_activities` | read/write | Verarbeitungstätigkeiten |
| `compliance_vvt_audit_log` | write | Änderungsprotokoll |
### Datenmodell (Aktivität)
```json
{
"vvt_id": "VVT-001",
"name": "Gehaltsabrechnung",
"purposes": ["Vertragserfüllung"],
"legal_bases": ["Art. 6 Abs. 1b DSGVO"],
"data_subject_categories": ["Mitarbeiter"],
"personal_data_categories": ["Bankdaten", "Steuer-ID"],
"recipient_categories": ["Steuerberater"],
"third_country_transfers": [],
"retention_period": {"years": 10, "basis": "§ 257 HGB"},
"protection_level": "HIGH",
"dpia_required": false,
"status": "APPROVED"
}
```
---
## Source Policy
**Route:** `/sdk/source-policy` | **Backend:** `backend-compliance:8002` | **Rechtsgrundlage:** Art. 5 DSGVO
### Funktionen
- Verwaltung erlaubter Compliance-Rechtsquellen (Gesetze, Leitlinien, Standards)
- Filter nach Quelltyp (`legal`, `guidance`, `template`, `technical`, `other`) und Lizenz
- PII-Regelwerk: Definition sensibler Datenkategorien (E-Mail, IBAN, Personalausweis, etc.)
- Filter nach PII-Kategorie (pii, financial, health, id, location, other)
- Quell-Operations-Matrix: Welche Operationen auf welchen Quellen sind erlaubt
- Policy-Audit-Log: Nachvollziehbare Protokollierung aller Policy-Änderungen
- Policy-Statistiken: Zusammenfassung des Compliance-Status
- Compliance-Report: Gesamtübersicht aller aktiven Quellen und Regeln
### API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/api/source-policy/sources` | Quellen (Filter: source_type, license, active_only) |
| `POST` | `/api/source-policy/sources` | Neue Quelle anlegen |
| `PUT` | `/api/source-policy/sources/{id}` | Quelle aktualisieren |
| `DELETE` | `/api/source-policy/sources/{id}` | Quelle löschen |
| `GET` | `/api/source-policy/pii-rules` | PII-Regeln (Filter: category) |
| `POST` | `/api/source-policy/pii-rules` | Neue PII-Regel |
| `PUT` | `/api/source-policy/pii-rules/{id}` | PII-Regel aktualisieren |
| `DELETE` | `/api/source-policy/pii-rules/{id}` | PII-Regel löschen |
| `GET` | `/api/source-policy/operations-matrix` | Operations-Matrix |
| `GET` | `/api/source-policy/policy-stats` | Statistiken |
| `GET` | `/api/source-policy/compliance-report` | Compliance-Report |
### DB-Tabellen
| Tabelle | Modus | Beschreibung |
|---------|-------|--------------|
| `compliance_allowed_sources` | read/write | Erlaubte Rechtsquellen |
| `compliance_pii_rules` | read/write | PII-Erkennungsregeln |
| `compliance_source_operations` | read/write | Operations-Matrix |
| `compliance_source_policy_audit` | write | Audit-Trail |
---
## Document Generator
**Route:** `/sdk/document-generator` | **Backend:** breakpilot-core (Template-Service) | **Rechtsgrundlage:** Art. 28 DSGVO, DDG § 5
### Funktionen
- Generierung rechtlicher Dokumente aus Templates: Impressum, AVV, Datenschutzrichtlinie, NDA
- Templates werden aus `bp_legal_templates` (RAG-Collection) geladen
- Unternehmensspezifische Befüllung aus Company Profile
- **PDF-Export** direkt im Browser via `window.print()` — kein Server-seitiger Service erforderlich
- Fallback-Banner: Wenn der Template-Service (breakpilot-core) nicht erreichbar ist, erscheint ein informativer Hinweis
- Attributionsnachweis: Verwendete Rechtsquellen werden im Dokument aufgeführt
### Generierte Dokumente
| Dokument | Rechtsgrundlage | Format |
|----------|-----------------|--------|
| Impressum | DDG § 5 (ehemals TMG) | HTML / PDF |
| Auftragsverarbeitungsvertrag (AVV) | Art. 28 DSGVO | HTML / PDF |
| Datenschutzerklärung | Art. 13, 14 DSGVO | HTML / PDF |
| NDA / Vertraulichkeitsvereinbarung | GeschGehG | HTML / PDF |
### PDF-Export
Der PDF-Export öffnet ein neues Browser-Fenster mit dem vollständig formatierten Dokument
und löst automatisch den Browser-Druckdialog aus (`window.print()`). Keine zusätzliche
Server-Dependency erforderlich.
```
[Als PDF exportieren] → window.open() → Dokument-HTML → window.print() → Browser-PDF-Dialog
```
---
## Audit Checklist
**Route:** `/sdk/audit-checklist` | **Backend:** `backend-compliance:8002` | **Rechtsgrundlage:** Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht)
### Funktionen
- Session-Management: Neue Audit-Sitzung erstellen (Name, Auditor, Scope)
- Status-Workflow: `draft``in_progress``completed``archived`
- Automatische Befüllung der Checkliste aus Requirements und Controls
- Interaktiver Sign-Off-Workflow: Konform / Teilweise / Nicht konform / Nicht geprüft
- Digitale Signatur-Hash (SHA-256) pro Prüfpunkt für Unveränderlichkeitsnachweis
- Notizen-Bearbeitung je Prüfpunkt
- **PDF-Download** in Deutsch oder Englisch (`GET /sessions/{id}/report/pdf`)
- JSON-Export der gesamten Checkliste
- Session-History: Übersicht vergangener Audit-Sitzungen
### API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/api/compliance/audit/sessions` | Alle Sitzungen |
| `POST` | `/api/compliance/audit/sessions` | Neue Sitzung |
| `PUT` | `/api/compliance/audit/sessions/{id}` | Sitzung aktualisieren (Status) |
| `GET` | `/api/compliance/audit/checklist/{sessionId}` | Checkliste laden |
| `PUT` | `/api/compliance/audit/checklist/{sessionId}/items/{reqId}/sign-off` | Prüfpunkt abzeichnen |
| `GET` | `/api/compliance/audit/sessions/{sessionId}/report/pdf` | PDF-Report (lang=de/en) |
!!! warning "Korrekter PDF-Endpunkt"
Der PDF-Download-Endpunkt lautet `/sessions/{id}/**report**/pdf`, nicht `/sessions/{id}/pdf`.
Dieser Fehler war in früheren Versionen vorhanden und wurde behoben.
### DB-Tabellen
| Tabelle | Modus | Beschreibung |
|---------|-------|--------------|
| `compliance_audit_sessions` | read/write | Audit-Sitzungen |
| `compliance_audit_signoffs` | write | Prüfpunkt-Abzeichnungen mit Signatur-Hash |
| `compliance_requirements` | read | Prüfpunkte aus Requirements |
### PDF-Generierung
Die PDF-Reports werden serverseitig mit **ReportLab 4.2.5** generiert und enthalten:
- Deckblatt mit Audit-Metadaten
- Zusammenfassung mit Ampelstatus
- Statistik-Kreisdiagramm (konform / nicht konform / ausstehend)
- Prüfpunkt-Details mit Notizen und digitalem Signatur-Hash
- Anhang: Nicht-konforme Punkte mit Handlungsempfehlungen
---
## Audit Report
**Route:** `/sdk/audit-report` und `/sdk/audit-report/{sessionId}` | **Backend:** `backend-compliance:8002` | **Rechtsgrundlage:** Art. 5 Abs. 2 DSGVO
### Funktionen
- Übersicht aller Audit-Sitzungen mit Status-Badges (Entwurf / In Bearbeitung / Abgeschlossen / Archiviert)
- Detail-Seite pro Sitzung:
- Sitzungs-Metadaten (Auditor, Organisation, Zeitraum)
- Fortschrittsbalken mit Farbkodierung (grün ≥ 80%, gelb ≥ 50%, rot < 50%)
- Statistik-Kacheln (konform / nicht konform / ausstehend)
- Interaktive Prüfpunkte mit nachträglichem Sign-Off
- Notizen-Bearbeitung per Prüfpunkt
- PDF-Download mit Sprachauswahl (DE / EN)
- Navigation: Klick auf eine Sitzung in der Übersicht öffnet die Detail-Seite
### API-Endpoints
Nutzt dieselben Backend-Endpoints wie Audit Checklist (s.o.).
### DB-Tabellen
| Tabelle | Modus | Beschreibung |
|---------|-------|--------------|
| `compliance_audit_sessions` | read/write | Sitzungsdaten inkl. Fortschritt |
| `compliance_audit_signoffs` | read/write | Nachträgliche Prüfpunkt-Aktualisierungen |
---
## Training Engine
**Route:** `/sdk/training` | **Backend:** `ai-compliance-sdk:8093` (Go) | **Rechtsgrundlage:** Art. 39 Abs. 1b DSGVO, EU AI Act Art. 4
### Funktionen
- **28 vordefinierte Schulungsmodule** für DSGVO, ISO 27001, AI Act, Hinweisgeberschutz u.a.
- Modul-Typen: Jährlich (`annual`), Ereignisbasiert (`event_trigger`), Mikro-Schulung (`micro`)
- Quiz-System: Automatisch generierte Fragen mit konfigurierbarer Bestehensgrenze
- Zertifikate bei erfolgreichem Abschluss
- Schulungsmatrix: Rollen-basierte Pflichtmodulzuordnung (CISO, DSB, Entwickler, etc.)
- Aufgabenzuweisung: Schulungen können Mitarbeitern zugewiesen werden
- Eskalation: Automatische Erinnerungen bei überfälligen Pflichtschulungen
- KI-generierter Content: Schulungsinhalte können via Ollama-LLM automatisch generiert werden
- Audit-Log: Vollständige Nachverfolgung aller Schulungsaktivitäten
### API-Endpoints
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/sdk/v1/training/modules` | Alle Module (Filter: regulation_area, frequency_type, search) |
| `POST` | `/sdk/v1/training/modules` | Neues Modul anlegen |
| `GET` | `/sdk/v1/training/modules/{id}` | Modul mit Content und Quiz-Fragen |
| `PUT` | `/sdk/v1/training/modules/{id}` | Modul aktualisieren |
| `GET` | `/sdk/v1/training/matrix` | Schulungsmatrix |
| `POST` | `/sdk/v1/training/matrix` | Matrix-Eintrag setzen |
| `GET` | `/sdk/v1/training/assignments` | Schulungszuweisungen |
| `POST` | `/sdk/v1/training/assignments/compute` | Zuweisungen für Nutzer berechnen |
| `POST` | `/sdk/v1/training/assignments/{id}/complete` | Schulung abschließen |
| `GET` | `/sdk/v1/training/quiz/{moduleId}` | Quiz-Fragen laden |
| `POST` | `/sdk/v1/training/quiz/{moduleId}/submit` | Quiz-Antworten einreichen |
| `GET` | `/sdk/v1/training/stats` | Schulungsstatistiken |
| `GET` | `/sdk/v1/training/deadlines` | Fällige Schulungen |
| `POST` | `/sdk/v1/training/escalation/check` | Eskalationen prüfen |
| `POST` | `/sdk/v1/training/content/generate` | KI-Content generieren (Ollama) |
### DB-Tabellen (ai-compliance-sdk PostgreSQL)
| Tabelle | Modus | Beschreibung |
|---------|-------|--------------|
| `training_modules` | read/write | Schulungsmodule mit Metadaten |
| `training_assignments` | read/write | Mitarbeiterzuweisungen |
| `training_quiz_questions` | read/write | Quiz-Fragen je Modul |
| `training_quiz_attempts` | read/write | Quiz-Versuche und Ergebnisse |
| `training_matrix_entries` | read/write | Rollen-Modul-Zuordnung |
| `training_audit_log` | write | Aktivitätsprotokoll |
### Vordefinierte Module (Auswahl)
| Code | Titel | Bereich | Typ |
|------|-------|---------|-----|
| `DSGVO-BASIC` | DSGVO Grundlagen | dsgvo | annual |
| `DSGVO-BREACH` | Datenpannen und Meldepflichten | dsgvo | event_trigger |
| `DSGVO-DSR` | Betroffenenrechte | dsgvo | annual |
| `AI-BAS` | KI-Kompetenz Grundlagen | ai_act | annual |
| `AI-RISK` | Hochrisiko-KI-Systeme | ai_act | event_trigger |
| `ISMS-AUD` | ISMS Audit-Vorbereitung | iso27001 | event_trigger |
| `HIN-BAS` | Hinweisgeberschutz | hinschg | annual |
| `PHISH` | Phishing-Erkennung | iso27001 | micro |
---
## Datenfluss
```mermaid
graph LR
A[Source Policy] --> B[Requirements]
B --> C[Audit Checklist]
C --> D[Audit Report]
D --> E[Obligations]
E --> F[TOMs]
F --> G[Löschfristen]
G --> H[VVT]
I[Document Generator] --> J[Workflow]
K[Academy] --> L[Training Engine]
```
Die **Source Policy** bildet die Grundlage für alle nachfolgenden Analyse-Schritte.
Das **VVT** ist der abschließende Schritt der Dokumentationsphase und baut auf TOMs und Löschfristen auf.
Die **Training Engine** operiert parallel im Betrieb-Paket und liefert Evidence für Audits.

View File

@@ -66,6 +66,7 @@ nav:
- Uebersicht: services/document-crawler/index.md
- SDK Module:
- Analyse-Module (Paket 2): services/sdk-modules/analyse-module.md
- Dokumentations-Module (Paket 3+): services/sdk-modules/dokumentations-module.md
- Academy: services/sdk-modules/academy.md
- Whistleblower: services/sdk-modules/whistleblower.md
- Incidents: services/sdk-modules/incidents.md

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