diff --git a/admin-compliance/app/(sdk)/sdk/document-generator/page.tsx b/admin-compliance/app/(sdk)/sdk/document-generator/page.tsx index dd34321..83ed323 100644 --- a/admin-compliance/app/(sdk)/sdk/document-generator/page.tsx +++ b/admin-compliance/app/(sdk)/sdk/document-generator/page.tsx @@ -257,9 +257,9 @@ export default function DocumentGeneratorPage() { ]) setStatus(statusData) setSources(sourcesData) - const hasKlausur = statusData !== null + const hasTemplateDb = statusData !== null const hasRag = await fetch(`${RAG_PROXY}/regulations`).then(r => r.ok).catch(() => false) - setServiceMode(hasKlausur ? 'full' : hasRag ? 'rag-only' : 'offline') + setServiceMode(hasTemplateDb ? 'full' : hasRag ? 'rag-only' : 'offline') } catch { setServiceMode('offline') } finally { @@ -396,7 +396,7 @@ export default function DocumentGeneratorPage() { {/* Service mode banners */} {serviceMode === 'rag-only' && (
- ⚠️ KLAUSUR_SERVICE nicht verfügbar — Suche läuft über RAG-Fallback + ⚠️ Template-Datenbank nicht erreichbar — Suche läuft über RAG-Fallback
)} {serviceMode === 'offline' && ( diff --git a/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.test.ts b/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.test.ts index 10a77e6..7275ba0 100644 --- a/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.test.ts +++ b/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.test.ts @@ -1,49 +1,51 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { searchTemplates } from './searchTemplates' +// jsdom doesn't define window.location.origin — stub it +Object.defineProperty(window, 'location', { + value: { origin: 'https://localhost' }, + writable: true, +}) + describe('searchTemplates', () => { beforeEach(() => { vi.restoreAllMocks() }) - it('gibt Ergebnisse zurück wenn KLAUSUR_SERVICE verfügbar', async () => { + it('gibt Ergebnisse zurück wenn eigenes Backend verfügbar', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve([{ - id: 't1', - score: 0.9, - text: 'Inhalt', - document_title: 'DSE Template', - template_type: 'privacy_policy', - clause_category: null, - language: 'de', - jurisdiction: 'de', - license_id: 'mit', - license_name: 'MIT', - license_url: null, - attribution_required: false, - attribution_text: null, - source_name: 'Test', - source_url: null, - source_repo: null, - placeholders: [], - is_complete_document: true, - is_modular: false, - requires_customization: false, - output_allowed: true, - modification_allowed: true, - distortion_prohibited: false, - }]), + json: () => Promise.resolve({ + templates: [{ + id: 't1', + title: 'Datenschutzerklärung (DSGVO-konform)', + document_type: 'privacy_policy', + content: '# Datenschutzerklärung\n\n{{COMPANY_NAME}}', + description: 'Vollständige DSE', + language: 'de', + jurisdiction: 'DE', + license_id: 'mit', + license_name: 'MIT License', + source_name: 'BreakPilot Compliance', + attribution_required: false, + is_complete_document: true, + placeholders: ['{{COMPANY_NAME}}', '{{CONTACT_EMAIL}}'], + }], + total: 1, + }), })) const results = await searchTemplates({ query: 'Datenschutzerklärung' }) expect(results).toHaveLength(1) - expect(results[0].documentTitle).toBe('DSE Template') + expect(results[0].documentTitle).toBe('Datenschutzerklärung (DSGVO-konform)') + expect(results[0].templateType).toBe('privacy_policy') + expect(results[0].placeholders).toContain('{{COMPANY_NAME}}') + expect((results[0] as any).source).toBe('db') }) - it('fällt auf RAG zurück wenn KLAUSUR_SERVICE fehlschlägt', async () => { + it('fällt auf RAG zurück wenn Backend fehlschlägt', async () => { vi.stubGlobal('fetch', vi.fn() - .mockRejectedValueOnce(new Error('KLAUSUR down')) + .mockRejectedValueOnce(new Error('Backend down')) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ diff --git a/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.ts b/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.ts index bfd6ef4..3043d1e 100644 --- a/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.ts +++ b/admin-compliance/app/(sdk)/sdk/document-generator/searchTemplates.ts @@ -1,16 +1,14 @@ /** * Template search helpers for document-generator. * - * Provides a two-tier search: - * 1. Primary: KLAUSUR_SERVICE (curated legal templates with full metadata) - * 2. Fallback: RAG proxy (ai-compliance-sdk regulation search) + * Two-tier search: + * 1. Primary: own backend (compliance_legal_templates DB) — MIT-licensed, curated + * 2. Fallback: RAG proxy (ai-compliance-sdk regulation search — law texts) */ import type { LegalTemplateResult } from '@/lib/sdk/types' -export const KLAUSUR_SERVICE_URL = - process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086' - +export const TEMPLATES_API = '/api/sdk/v1/compliance/legal-templates' export const RAG_PROXY = '/api/sdk/v1/rag' export interface TemplateSearchParams { @@ -23,63 +21,34 @@ export interface TemplateSearchParams { } /** - * Search for legal templates with automatic RAG fallback. + * Search for legal templates. * - * Tries KLAUSUR_SERVICE first (5 s timeout). If unavailable or returning an - * error, falls back to the RAG proxy served by ai-compliance-sdk. + * Tries own backend DB first (5 s timeout). Falls back to RAG proxy + * (regulation texts — useful as reference, but not ready-made templates). * Returns an empty array if both services are down. */ export async function searchTemplates( params: TemplateSearchParams ): Promise { - // 1. Primary: KLAUSUR_SERVICE + // 1. Primary: own backend — compliance_legal_templates table try { - const res = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: params.query, - template_type: params.templateType, - license_types: params.licenseTypes, - language: params.language, - jurisdiction: params.jurisdiction, - limit: params.limit || 10, - }), - signal: AbortSignal.timeout(5000), - }) + const url = new URL(TEMPLATES_API, window.location.origin) + if (params.query) url.searchParams.set('query', params.query) + if (params.templateType) url.searchParams.set('document_type', params.templateType) + if (params.language) url.searchParams.set('language', params.language) + url.searchParams.set('limit', String(params.limit || 20)) + url.searchParams.set('status', 'published') + + const res = await fetch(url.toString(), { signal: AbortSignal.timeout(5000) }) if (res.ok) { const data = await res.json() - return data.map((r: any) => ({ - id: r.id, - score: r.score, - text: r.text, - documentTitle: r.document_title, - templateType: r.template_type, - clauseCategory: r.clause_category, - language: r.language, - jurisdiction: r.jurisdiction, - licenseId: r.license_id, - licenseName: r.license_name, - licenseUrl: r.license_url, - attributionRequired: r.attribution_required, - attributionText: r.attribution_text, - sourceName: r.source_name, - sourceUrl: r.source_url, - sourceRepo: r.source_repo, - placeholders: r.placeholders || [], - isCompleteDocument: r.is_complete_document, - isModular: r.is_modular, - requiresCustomization: r.requires_customization, - outputAllowed: r.output_allowed ?? true, - modificationAllowed: r.modification_allowed ?? true, - distortionProhibited: r.distortion_prohibited ?? false, - })) + return (data.templates || []).map(mapTemplateToResult) } } catch { - // KLAUSUR_SERVICE not reachable — fall through to RAG + // Backend not reachable — fall through to RAG } - // 2. Fallback: RAG proxy + // 2. Fallback: RAG proxy (Gesetzestexte — Referenz, kein fertiges Template) try { const res = await fetch(`${RAG_PROXY}/search`, { method: 'POST', @@ -122,19 +91,52 @@ export async function searchTemplates( return [] } +function mapTemplateToResult(r: any): LegalTemplateResult { + return { + id: r.id, + score: 1.0, + text: r.content || '', + documentTitle: r.title, + templateType: r.document_type, + clauseCategory: null, + language: r.language, + jurisdiction: r.jurisdiction, + licenseId: r.license_id as any, + licenseName: r.license_name, + licenseUrl: null, + attributionRequired: r.attribution_required ?? false, + attributionText: null, + sourceName: r.source_name, + sourceUrl: null, + sourceRepo: null, + placeholders: r.placeholders || [], + isCompleteDocument: r.is_complete_document ?? true, + isModular: false, + requiresCustomization: (r.placeholders || []).length > 0, + outputAllowed: true, + modificationAllowed: true, + distortionProhibited: false, + source: 'db' as const, + } +} + export async function getTemplatesStatus(): Promise { - const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/status`, { - signal: AbortSignal.timeout(5000), - }) - if (!response.ok) return null - return response.json() + try { + const res = await fetch(`${TEMPLATES_API}/status`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) return null + return res.json() + } catch { + return null + } } export async function getSources(): Promise { - const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/sources`, { - signal: AbortSignal.timeout(5000), - }) - if (!response.ok) return [] - const data = await response.json() - return data.sources || [] + try { + const res = await fetch(`${TEMPLATES_API}/sources`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) return [] + const data = await res.json() + return data.sources || [] + } catch { + return [] + } } diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index 1ef5b88..451bdd2 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -19,6 +19,7 @@ from .obligation_routes import router as obligation_router from .security_backlog_routes import router as security_backlog_router from .quality_routes import router as quality_router from .loeschfristen_routes import router as loeschfristen_router +from .legal_template_routes import router as legal_template_router # Include sub-routers router.include_router(audit_router) @@ -39,6 +40,7 @@ router.include_router(obligation_router) router.include_router(security_backlog_router) router.include_router(quality_router) router.include_router(loeschfristen_router) +router.include_router(legal_template_router) __all__ = [ "router", @@ -60,4 +62,5 @@ __all__ = [ "security_backlog_router", "quality_router", "loeschfristen_router", + "legal_template_router", ] diff --git a/backend-compliance/compliance/api/legal_template_routes.py b/backend-compliance/compliance/api/legal_template_routes.py new file mode 100644 index 0000000..a3b0666 --- /dev/null +++ b/backend-compliance/compliance/api/legal_template_routes.py @@ -0,0 +1,352 @@ +""" +FastAPI routes for Legal Templates (Document Generator). + +Self-authored templates (MIT License) stored in compliance_legal_templates. + +Endpoints: + GET /legal-templates — list (query, document_type, language, status, limit, offset) + GET /legal-templates/status — counts by type + GET /legal-templates/sources — distinct source_name list + GET /legal-templates/{id} — single template by id + POST /legal-templates — create (admin) + PUT /legal-templates/{id} — update + DELETE /legal-templates/{id} — delete (204) +""" + +import json +import logging +from datetime import datetime +from typing import Optional, List, Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query, Header +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session +from uuid import UUID + +from classroom_engine.database import get_db + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/legal-templates", tags=["legal-templates"]) + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" + +VALID_DOCUMENT_TYPES = {"privacy_policy", "terms_of_service", "impressum"} +VALID_STATUSES = {"published", "draft", "archived"} + + +# ============================================================================= +# Pydantic Schemas +# ============================================================================= + +class LegalTemplateCreate(BaseModel): + document_type: str + title: str + description: Optional[str] = None + content: str + placeholders: Optional[List[str]] = None + language: str = "de" + jurisdiction: str = "DE" + license_id: str = "mit" + license_name: str = "MIT License" + source_name: str = "BreakPilot Compliance" + attribution_required: bool = False + is_complete_document: bool = True + version: str = "1.0.0" + status: str = "published" + + +class LegalTemplateUpdate(BaseModel): + document_type: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + content: Optional[str] = None + placeholders: Optional[List[str]] = None + language: Optional[str] = None + jurisdiction: Optional[str] = None + license_id: Optional[str] = None + license_name: Optional[str] = None + source_name: Optional[str] = None + attribution_required: Optional[bool] = None + is_complete_document: Optional[bool] = None + version: Optional[str] = None + status: Optional[str] = None + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _row_to_dict(row) -> Dict[str, Any]: + result = dict(row._mapping) + for key, val in result.items(): + if isinstance(val, datetime): + result[key] = val.isoformat() + elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))): + result[key] = str(val) + return result + + +def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: + if x_tenant_id: + try: + UUID(x_tenant_id) + return x_tenant_id + except ValueError: + pass + return DEFAULT_TENANT_ID + + +# ============================================================================= +# Routes +# ============================================================================= + +@router.get("") +async def list_legal_templates( + query: Optional[str] = Query(None, description="Full-text ILIKE search on title/description/content"), + document_type: Optional[str] = Query(None), + language: Optional[str] = Query(None), + status: Optional[str] = Query("published"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """List legal templates with optional filters.""" + tenant_id = _get_tenant_id(x_tenant_id) + + where_clauses = ["tenant_id = :tenant_id"] + params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} + + if status: + where_clauses.append("status = :status") + params["status"] = status + if document_type: + where_clauses.append("document_type = :document_type") + params["document_type"] = document_type + if language: + where_clauses.append("language = :language") + params["language"] = language + if query: + where_clauses.append( + "(title ILIKE :query OR description ILIKE :query OR content ILIKE :query)" + ) + params["query"] = f"%{query}%" + + where_sql = " AND ".join(where_clauses) + + total_row = db.execute( + text(f"SELECT COUNT(*) FROM compliance_legal_templates WHERE {where_sql}"), + params, + ).fetchone() + total = total_row[0] if total_row else 0 + + rows = db.execute( + text(f""" + SELECT * FROM compliance_legal_templates + WHERE {where_sql} + ORDER BY document_type, title + LIMIT :limit OFFSET :offset + """), + params, + ).fetchall() + + return { + "templates": [_row_to_dict(r) for r in rows], + "total": total, + } + + +@router.get("/status") +async def get_templates_status( + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Return template counts by document_type.""" + tenant_id = _get_tenant_id(x_tenant_id) + + row = db.execute(text(""" + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'published') AS published, + COUNT(*) FILTER (WHERE status = 'draft') AS draft, + COUNT(*) FILTER (WHERE status = 'archived') AS archived, + COUNT(*) FILTER (WHERE document_type = 'privacy_policy') AS privacy_policy, + COUNT(*) FILTER (WHERE document_type = 'terms_of_service') AS terms_of_service, + COUNT(*) FILTER (WHERE document_type = 'impressum') AS impressum + FROM compliance_legal_templates + WHERE tenant_id = :tenant_id + """), {"tenant_id": tenant_id}).fetchone() + + if row: + d = dict(row._mapping) + counts = {k: int(v or 0) for k, v in d.items()} + return { + "total": counts["total"], + "by_status": { + "published": counts["published"], + "draft": counts["draft"], + "archived": counts["archived"], + }, + "by_type": { + "privacy_policy": counts["privacy_policy"], + "terms_of_service": counts["terms_of_service"], + "impressum": counts["impressum"], + }, + } + return {"total": 0, "by_status": {}, "by_type": {}} + + +@router.get("/sources") +async def get_template_sources( + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Return distinct source_name values.""" + tenant_id = _get_tenant_id(x_tenant_id) + + rows = db.execute( + text("SELECT DISTINCT source_name FROM compliance_legal_templates WHERE tenant_id = :tenant_id ORDER BY source_name"), + {"tenant_id": tenant_id}, + ).fetchall() + + return {"sources": [r[0] for r in rows]} + + +@router.get("/{template_id}") +async def get_legal_template( + template_id: str, + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Fetch a single template by ID.""" + tenant_id = _get_tenant_id(x_tenant_id) + row = db.execute( + text("SELECT * FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"), + {"id": template_id, "tenant_id": tenant_id}, + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Template not found") + return _row_to_dict(row) + + +@router.post("", status_code=201) +async def create_legal_template( + payload: LegalTemplateCreate, + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Create a new legal template.""" + tenant_id = _get_tenant_id(x_tenant_id) + + if payload.document_type not in VALID_DOCUMENT_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid document_type. Must be one of: {', '.join(VALID_DOCUMENT_TYPES)}" + ) + if payload.status not in VALID_STATUSES: + raise HTTPException( + status_code=400, + detail=f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}" + ) + + placeholders_json = json.dumps(payload.placeholders or []) + + row = db.execute(text(""" + INSERT INTO compliance_legal_templates ( + tenant_id, document_type, title, description, content, + placeholders, language, jurisdiction, + license_id, license_name, source_name, + attribution_required, is_complete_document, version, status + ) VALUES ( + :tenant_id, :document_type, :title, :description, :content, + CAST(:placeholders AS jsonb), :language, :jurisdiction, + :license_id, :license_name, :source_name, + :attribution_required, :is_complete_document, :version, :status + ) RETURNING * + """), { + "tenant_id": tenant_id, + "document_type": payload.document_type, + "title": payload.title, + "description": payload.description, + "content": payload.content, + "placeholders": placeholders_json, + "language": payload.language, + "jurisdiction": payload.jurisdiction, + "license_id": payload.license_id, + "license_name": payload.license_name, + "source_name": payload.source_name, + "attribution_required": payload.attribution_required, + "is_complete_document": payload.is_complete_document, + "version": payload.version, + "status": payload.status, + }).fetchone() + db.commit() + return _row_to_dict(row) + + +@router.put("/{template_id}") +async def update_legal_template( + template_id: str, + payload: LegalTemplateUpdate, + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Update an existing legal template.""" + tenant_id = _get_tenant_id(x_tenant_id) + + updates = payload.model_dump(exclude_unset=True) + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + if "document_type" in updates and updates["document_type"] not in VALID_DOCUMENT_TYPES: + raise HTTPException(status_code=400, detail=f"Invalid document_type") + if "status" in updates and updates["status"] not in VALID_STATUSES: + raise HTTPException(status_code=400, detail=f"Invalid status") + + set_clauses = ["updated_at = :updated_at"] + params: Dict[str, Any] = { + "id": template_id, + "tenant_id": tenant_id, + "updated_at": datetime.utcnow(), + } + + for field, value in updates.items(): + if field == "placeholders": + params[field] = json.dumps(value if value is not None else []) + set_clauses.append(f"{field} = CAST(:{field} AS jsonb)") + else: + params[field] = value + set_clauses.append(f"{field} = :{field}") + + row = db.execute( + text(f""" + UPDATE compliance_legal_templates + SET {', '.join(set_clauses)} + WHERE id = :id AND tenant_id = :tenant_id + RETURNING * + """), + params, + ).fetchone() + db.commit() + + if not row: + raise HTTPException(status_code=404, detail="Template not found") + return _row_to_dict(row) + + +@router.delete("/{template_id}", status_code=204) +async def delete_legal_template( + template_id: str, + db: Session = Depends(get_db), + x_tenant_id: Optional[str] = Header(None), +): + """Delete a legal template.""" + tenant_id = _get_tenant_id(x_tenant_id) + result = db.execute( + text("DELETE FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"), + {"id": template_id, "tenant_id": tenant_id}, + ) + db.commit() + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Template not found") diff --git a/backend-compliance/migrations/018_legal_templates.sql b/backend-compliance/migrations/018_legal_templates.sql new file mode 100644 index 0000000..54ed69b --- /dev/null +++ b/backend-compliance/migrations/018_legal_templates.sql @@ -0,0 +1,257 @@ +-- Migration 018: Legal Templates +-- Self-authored templates (MIT License) for the Document Generator. +-- Stores DSE, Impressum, and AGB as Markdown with {{PLACEHOLDER}} syntax. + +CREATE TABLE IF NOT EXISTS compliance_legal_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL DEFAULT '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', + document_type TEXT NOT NULL, -- privacy_policy | terms_of_service | impressum + title TEXT NOT NULL, + description TEXT, + content TEXT NOT NULL, -- Markdown with {{PLACEHOLDER}} syntax + placeholders JSONB NOT NULL DEFAULT '[]'::jsonb, + language TEXT NOT NULL DEFAULT 'de', + jurisdiction TEXT NOT NULL DEFAULT 'DE', + license_id TEXT NOT NULL DEFAULT 'mit', + license_name TEXT NOT NULL DEFAULT 'MIT License', + source_name TEXT NOT NULL DEFAULT 'BreakPilot Compliance', + attribution_required BOOLEAN NOT NULL DEFAULT FALSE, + is_complete_document BOOLEAN NOT NULL DEFAULT TRUE, + version TEXT NOT NULL DEFAULT '1.0.0', + status TEXT NOT NULL DEFAULT 'published', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_legal_templates_tenant ON compliance_legal_templates(tenant_id); +CREATE INDEX IF NOT EXISTS idx_legal_templates_type ON compliance_legal_templates(document_type); +CREATE INDEX IF NOT EXISTS idx_legal_templates_status ON compliance_legal_templates(status); +CREATE INDEX IF NOT EXISTS idx_legal_templates_language ON compliance_legal_templates(language); + +-- ============================================================================= +-- Seed: Template 1 — Datenschutzerklärung (DSE) +-- Basiert auf DSGVO Art. 13/14 (Informationspflichten) +-- ============================================================================= +INSERT INTO compliance_legal_templates ( + document_type, title, description, language, jurisdiction, + placeholders, content +) VALUES ( + 'privacy_policy', + 'Datenschutzerklärung (DSGVO-konform)', + 'Vollständige Datenschutzerklärung gemäß DSGVO Art. 13/14 mit Pflichtangaben für Websites und Online-Dienste.', + 'de', 'DE', + '["{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{COMPANY_CITY}}", "{{CONTACT_EMAIL}}", "{{DPO_NAME}}", "{{DPO_EMAIL}}", "{{SUPERVISORY_AUTHORITY}}", "{{VERSION_DATE}}"]'::jsonb, + $template$# Datenschutzerklärung + +*Stand: {{VERSION_DATE}}* + +## 1. Verantwortlicher + +Verantwortlicher im Sinne der Datenschutz-Grundverordnung (DSGVO) ist: + +**{{COMPANY_NAME}}** +{{COMPANY_ADDRESS}} +{{COMPANY_CITY}} + +E-Mail: {{CONTACT_EMAIL}} + +## 2. Datenschutzbeauftragter + +Unser Datenschutzbeauftragter ist erreichbar unter: + +Name: {{DPO_NAME}} +E-Mail: {{DPO_EMAIL}} + +## 3. Erhebung und Verarbeitung personenbezogener Daten + +### 3.1 Server-Logfiles + +Beim Aufruf unserer Website werden durch den Webserver automatisch folgende Daten protokolliert: + +- IP-Adresse (anonymisiert) +- Datum und Uhrzeit des Abrufs +- Aufgerufene URL +- Referrer-URL +- Verwendeter Browser und Betriebssystem + +**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der sicheren und stabilen Bereitstellung des Dienstes) + +**Speicherdauer:** 7 Tage, danach automatische Löschung. + +### 3.2 Kontaktaufnahme + +Wenn Sie uns per E-Mail oder Kontaktformular kontaktieren, verarbeiten wir Ihre E-Mail-Adresse sowie alle weiteren von Ihnen mitgeteilten Daten, um Ihre Anfrage zu beantworten. + +**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung oder vorvertragliche Maßnahmen) bzw. Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung von Anfragen) + +**Speicherdauer:** Bis zur abschließenden Bearbeitung Ihrer Anfrage; bei gesetzlichen Aufbewahrungspflichten entsprechend länger. + +## 4. Empfänger von Daten / Auftragsverarbeiter + +Wir setzen Dienstleister ein, die in unserem Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter gemäß Art. 28 DSGVO). Mit diesen Dienstleistern haben wir Auftragsverarbeitungsverträge (AVV) abgeschlossen. Eine Weitergabe an Dritte außerhalb dieser Verträge erfolgt nicht, es sei denn, wir sind dazu gesetzlich verpflichtet. + +## 5. Drittlandsübermittlungen + +Eine Übermittlung Ihrer Daten in Länder außerhalb der EU/des EWR findet nur statt, soweit dies zur Vertragserfüllung erforderlich ist oder Sie ausdrücklich eingewilligt haben. In diesen Fällen stellen wir durch geeignete Garantien (z. B. EU-Standardvertragsklauseln) ein angemessenes Datenschutzniveau sicher. + +## 6. Ihre Rechte als betroffene Person + +Sie haben gegenüber uns folgende Rechte hinsichtlich Ihrer personenbezogenen Daten: + +- **Auskunftsrecht** (Art. 15 DSGVO): Sie können Auskunft über die von uns verarbeiteten Daten verlangen. +- **Berichtigungsrecht** (Art. 16 DSGVO): Sie können die Berichtigung unrichtiger Daten verlangen. +- **Löschungsrecht** (Art. 17 DSGVO): Sie können unter bestimmten Voraussetzungen die Löschung Ihrer Daten verlangen. +- **Einschränkungsrecht** (Art. 18 DSGVO): Sie können die Einschränkung der Verarbeitung verlangen. +- **Datenportabilität** (Art. 20 DSGVO): Sie können Ihre Daten in einem maschinenlesbaren Format erhalten. +- **Widerspruchsrecht** (Art. 21 DSGVO): Sie können der Verarbeitung auf Basis berechtigter Interessen widersprechen. +- **Widerruf der Einwilligung** (Art. 7 Abs. 3 DSGVO): Soweit die Verarbeitung auf einer Einwilligung beruht, können Sie diese jederzeit widerrufen. + +Zur Ausübung Ihrer Rechte wenden Sie sich bitte an: {{CONTACT_EMAIL}} + +## 7. Beschwerderecht bei der Aufsichtsbehörde + +Sie haben das Recht, sich bei der für uns zuständigen Datenschutz-Aufsichtsbehörde zu beschweren: + +{{SUPERVISORY_AUTHORITY}} + +## 8. Aktualität und Änderungen dieser Datenschutzerklärung + +Diese Datenschutzerklärung ist aktuell gültig und hat den Stand {{VERSION_DATE}}. Durch die Weiterentwicklung unserer Website oder aufgrund geänderter gesetzlicher Vorgaben kann es notwendig werden, diese Datenschutzerklärung zu ändern. Die jeweils aktuelle Datenschutzerklärung kann jederzeit auf unserer Website abgerufen werden. +$template$ +) ON CONFLICT DO NOTHING; + +-- ============================================================================= +-- Seed: Template 2 — Impressum +-- Basiert auf DDG § 5 (früher TMG § 5) +-- ============================================================================= +INSERT INTO compliance_legal_templates ( + document_type, title, description, language, jurisdiction, + placeholders, content +) VALUES ( + 'impressum', + 'Impressum (DDG § 5)', + 'Vollständiges Impressum gemäß § 5 DDG (Digital-Dienste-Gesetz, früher TMG) für gewerbliche Online-Angebote in Deutschland.', + 'de', 'DE', + '["{{COMPANY_NAME}}", "{{COMPANY_LEGAL_FORM}}", "{{COMPANY_ADDRESS}}", "{{COMPANY_CITY}}", "{{CEO_NAME}}", "{{COMPANY_PHONE}}", "{{CONTACT_EMAIL}}", "{{WEBSITE_URL}}", "{{REGISTER_SECTION}}", "{{VAT_SECTION}}"]'::jsonb, + $template$# Impressum + +## Angaben gemäß § 5 DDG + +**{{COMPANY_NAME}}** ({{COMPANY_LEGAL_FORM}}) + +{{COMPANY_ADDRESS}} +{{COMPANY_CITY}} + +Vertreten durch: {{CEO_NAME}} + +**Kontakt:** +Telefon: {{COMPANY_PHONE}} +E-Mail: {{CONTACT_EMAIL}} +Website: {{WEBSITE_URL}} + +{{REGISTER_SECTION}} + +{{VAT_SECTION}} + +## Haftungsausschluss + +### Haftung für Inhalte + +Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. + +### Haftung für Links + +Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen. + +## Urheberrecht + +Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Beiträge Dritter sind als solche gekennzeichnet. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet. +$template$ +) ON CONFLICT DO NOTHING; + +-- ============================================================================= +-- Seed: Template 3 — Allgemeine Geschäftsbedingungen (AGB) +-- ============================================================================= +INSERT INTO compliance_legal_templates ( + document_type, title, description, language, jurisdiction, + placeholders, content +) VALUES ( + 'terms_of_service', + 'Allgemeine Geschäftsbedingungen (AGB)', + 'AGB für Online-Dienste und SaaS-Angebote mit 8 Paragraphen: Geltungsbereich, Leistung, Vertragsschluss, Preise, Widerruf, Haftung, Datenschutz, Schlussbestimmungen.', + 'de', 'DE', + '["{{COMPANY_NAME}}", "{{SERVICE_DESCRIPTION}}", "{{PRICING_SECTION}}", "{{PAYMENT_TERMS}}", "{{COMPANY_CITY}}", "{{PRIVACY_POLICY_URL}}", "{{VERSION_DATE}}"]'::jsonb, + $template$# Allgemeine Geschäftsbedingungen + +**{{COMPANY_NAME}}** +*Stand: {{VERSION_DATE}}* + +--- + +## § 1 Geltungsbereich + +(1) Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Verträge zwischen **{{COMPANY_NAME}}** (nachfolgend „Anbieter") und dem Kunden über die Nutzung der angebotenen Leistungen. + +(2) Abweichende Bedingungen des Kunden werden nicht anerkannt, es sei denn, der Anbieter stimmt ihrer Geltung ausdrücklich schriftlich zu. + +(3) Diese AGB gelten gegenüber Unternehmern (§ 14 BGB) und Verbrauchern (§ 13 BGB) gleichermaßen, sofern nicht ausdrücklich unterschieden wird. + +## § 2 Leistungsangebot + +(1) Der Anbieter erbringt folgende Leistungen: {{SERVICE_DESCRIPTION}} + +(2) Der Anbieter ist berechtigt, die Leistungen durch Dritte (Subunternehmer) erbringen zu lassen. + +(3) Art und Umfang der Leistungen ergeben sich aus der jeweiligen Produktbeschreibung oder dem individuellen Angebot. + +## § 3 Vertragsschluss + +(1) Die Darstellung der Leistungen auf der Website des Anbieters stellt kein bindendes Angebot dar, sondern eine Aufforderung zur Angebotsabgabe (invitatio ad offerendum). + +(2) Der Kunde gibt ein verbindliches Angebot ab, indem er den Bestellvorgang abschließt oder ein schriftliches Angebot annimmt. + +(3) Der Anbieter kann das Angebot innerhalb von 5 Werktagen annehmen. Die Annahme erfolgt durch eine ausdrückliche Auftragsbestätigung oder Bereitstellung der Leistung. + +## § 4 Preise und Zahlung + +{{PRICING_SECTION}} + +**Zahlungsbedingungen:** {{PAYMENT_TERMS}} + +Alle Preise verstehen sich zuzüglich der gesetzlichen Mehrwertsteuer, sofern nicht ausdrücklich als Bruttopreise ausgewiesen. + +Bei Zahlungsverzug ist der Anbieter berechtigt, Verzugszinsen in Höhe von 9 Prozentpunkten über dem jeweiligen Basiszinssatz (§ 288 Abs. 2 BGB) zu berechnen. Die Geltendmachung weiterer Schäden bleibt vorbehalten. + +## § 5 Widerrufsrecht für Verbraucher + +Verbraucher haben das Recht, diesen Vertrag binnen 14 Tagen ohne Angabe von Gründen zu widerrufen. Die Widerrufsfrist beträgt 14 Tage ab dem Tag des Vertragsabschlusses. + +Um das Widerrufsrecht auszuüben, müssen Sie uns ({{COMPANY_NAME}}) mittels einer eindeutigen Erklärung über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. + +**Ausnahme:** Das Widerrufsrecht erlischt bei digitalen Inhalten, wenn der Anbieter mit der Ausführung des Vertrags begonnen hat und der Verbraucher ausdrücklich zugestimmt hat, dass der Anbieter mit der Ausführung des Vertrags vor Ablauf der Widerrufsfrist beginnt und seine Kenntnis davon bestätigt hat, dass er durch seine Zustimmung mit Beginn der Ausführung des Vertrags sein Widerrufsrecht verliert. + +## § 6 Gewährleistung und Haftung + +(1) Der Anbieter haftet unbeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit sowie für Schäden, die durch Vorsatz oder grobe Fahrlässigkeit des Anbieters oder seiner Erfüllungsgehilfen entstehen. + +(2) Im Übrigen haftet der Anbieter nur für die Verletzung wesentlicher Vertragspflichten (Kardinalpflichten). Bei leicht fahrlässiger Verletzung von Kardinalpflichten ist die Haftung auf den vertragstypisch vorhersehbaren Schaden begrenzt. + +(3) Die vorstehenden Haftungsbeschränkungen gelten nicht, soweit der Anbieter eine Garantie übernommen hat oder Ansprüche nach dem Produkthaftungsgesetz bestehen. + +(4) Für den Verlust von Daten haftet der Anbieter nur, soweit der Schaden auch bei ordnungsgemäßer und regelmäßiger Datensicherung durch den Kunden eingetreten wäre. + +## § 7 Datenschutz + +Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung, die unter {{PRIVACY_POLICY_URL}} abrufbar ist. Die Datenschutzerklärung ist Bestandteil dieser AGB. + +## § 8 Schlussbestimmungen + +(1) Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts (CISG). Gegenüber Verbrauchern gilt diese Rechtswahl nur, soweit der durch zwingende Bestimmungen des Rechts des Staates, in dem der Verbraucher seinen gewöhnlichen Aufenthalt hat, gewährte Schutz nicht entzogen wird. + +(2) Gerichtsstand für alle Streitigkeiten aus diesem Vertrag ist, sofern der Kunde Kaufmann, eine juristische Person des öffentlichen Rechts oder ein öffentlich-rechtliches Sondervermögen ist, {{COMPANY_CITY}}. + +(3) Sollten einzelne Bestimmungen dieser AGB unwirksam sein oder werden, so bleibt die Wirksamkeit der übrigen Bestimmungen davon unberührt. Die unwirksame Bestimmung wird durch eine wirksame ersetzt, die dem wirtschaftlichen Zweck der unwirksamen Bestimmung am nächsten kommt. + +(4) Änderungen und Ergänzungen dieser AGB bedürfen der Schriftform. Dies gilt auch für die Aufhebung des Schriftformerfordernisses. +$template$ +) ON CONFLICT DO NOTHING; diff --git a/backend-compliance/tests/test_legal_template_routes.py b/backend-compliance/tests/test_legal_template_routes.py new file mode 100644 index 0000000..0b6373e --- /dev/null +++ b/backend-compliance/tests/test_legal_template_routes.py @@ -0,0 +1,355 @@ +"""Tests for Legal Template routes and schemas (legal_template_routes.py).""" + +import json +import pytest +from datetime import datetime +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient +from fastapi import FastAPI + +from compliance.api.legal_template_routes import ( + LegalTemplateCreate, + LegalTemplateUpdate, + _row_to_dict, + _get_tenant_id, + DEFAULT_TENANT_ID, + VALID_DOCUMENT_TYPES, + VALID_STATUSES, + router, +) +from classroom_engine.database import get_db + +app = FastAPI() +app.include_router(router) + +DEFAULT_TENANT = DEFAULT_TENANT_ID +TEMPLATE_ID = "ffffffff-0001-0001-0001-000000000001" +UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999" + +DSE_PLACEHOLDERS = [ + "{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{CONTACT_EMAIL}}", "{{VERSION_DATE}}" +] + + +# ============================================================================= +# Helpers +# ============================================================================= + +def make_template_row(overrides=None): + data = { + "id": TEMPLATE_ID, + "tenant_id": DEFAULT_TENANT, + "document_type": "privacy_policy", + "title": "Datenschutzerklärung (DSGVO-konform)", + "description": "Vollständige DSE gemäß DSGVO Art. 13/14", + "content": "# Datenschutzerklärung\n\n## 1. Verantwortlicher\n\n{{COMPANY_NAME}}", + "placeholders": DSE_PLACEHOLDERS, + "language": "de", + "jurisdiction": "DE", + "license_id": "mit", + "license_name": "MIT License", + "source_name": "BreakPilot Compliance", + "attribution_required": False, + "is_complete_document": True, + "version": "1.0.0", + "status": "published", + "created_at": datetime(2024, 1, 1), + "updated_at": datetime(2024, 1, 1), + } + if overrides: + data.update(overrides) + row = MagicMock() + row._mapping = data + return row + + +def make_db_mock(): + db = MagicMock() + return db + + +# ============================================================================= +# TestLegalTemplateSchemas +# ============================================================================= + +class TestLegalTemplateSchemas: + def test_create_schema_defaults(self): + """LegalTemplateCreate sets sensible defaults.""" + payload = LegalTemplateCreate( + document_type="privacy_policy", + title="Test DSE", + content="# Test" + ) + assert payload.language == "de" + assert payload.jurisdiction == "DE" + assert payload.license_id == "mit" + assert payload.license_name == "MIT License" + assert payload.source_name == "BreakPilot Compliance" + assert payload.attribution_required is False + assert payload.is_complete_document is True + assert payload.version == "1.0.0" + assert payload.status == "published" + + def test_create_schema_with_placeholders(self): + """LegalTemplateCreate accepts placeholder list.""" + payload = LegalTemplateCreate( + document_type="impressum", + title="Impressum", + content="# Impressum\n{{COMPANY_NAME}}", + placeholders=["{{COMPANY_NAME}}", "{{CEO_NAME}}"] + ) + assert len(payload.placeholders) == 2 + assert "{{COMPANY_NAME}}" in payload.placeholders + + def test_update_schema_all_optional(self): + """LegalTemplateUpdate: all fields optional.""" + payload = LegalTemplateUpdate() + d = payload.model_dump(exclude_unset=True) + assert d == {} + + def test_update_schema_partial(self): + """LegalTemplateUpdate partial: only set fields serialized.""" + payload = LegalTemplateUpdate(status="archived", title="Neue DSE") + d = payload.model_dump(exclude_unset=True) + assert d == {"status": "archived", "title": "Neue DSE"} + + def test_valid_document_types_constant(self): + """VALID_DOCUMENT_TYPES contains the 3 expected types.""" + assert "privacy_policy" in VALID_DOCUMENT_TYPES + assert "terms_of_service" in VALID_DOCUMENT_TYPES + assert "impressum" in VALID_DOCUMENT_TYPES + assert len(VALID_DOCUMENT_TYPES) == 3 + + def test_valid_statuses_constant(self): + """VALID_STATUSES contains expected values.""" + assert "published" in VALID_STATUSES + assert "draft" in VALID_STATUSES + assert "archived" in VALID_STATUSES + + +# ============================================================================= +# TestLegalTemplateDB +# ============================================================================= + +class TestLegalTemplateDB: + def test_row_to_dict_converts_datetime(self): + """_row_to_dict converts datetime to ISO string.""" + row = make_template_row() + result = _row_to_dict(row) + assert result["created_at"] == "2024-01-01T00:00:00" + assert result["updated_at"] == "2024-01-01T00:00:00" + + def test_row_to_dict_preserves_strings(self): + """_row_to_dict preserves string fields unchanged.""" + row = make_template_row() + result = _row_to_dict(row) + assert result["title"] == "Datenschutzerklärung (DSGVO-konform)" + assert result["license_id"] == "mit" + assert result["document_type"] == "privacy_policy" + + def test_row_to_dict_preserves_list(self): + """_row_to_dict preserves list fields (placeholders).""" + row = make_template_row() + result = _row_to_dict(row) + assert isinstance(result["placeholders"], list) + assert "{{COMPANY_NAME}}" in result["placeholders"] + + def test_get_tenant_id_default(self): + """_get_tenant_id returns default when no header provided.""" + result = _get_tenant_id(None) + assert result == DEFAULT_TENANT_ID + + def test_get_tenant_id_valid_uuid(self): + """_get_tenant_id returns provided UUID when valid.""" + custom_uuid = "12345678-1234-1234-1234-123456789abc" + result = _get_tenant_id(custom_uuid) + assert result == custom_uuid + + def test_get_tenant_id_invalid_uuid(self): + """_get_tenant_id falls back to default for invalid UUID.""" + result = _get_tenant_id("not-a-uuid") + assert result == DEFAULT_TENANT_ID + + +# ============================================================================= +# TestLegalTemplateSearch +# ============================================================================= + +class TestLegalTemplateSearch: + def setup_method(self): + self.db = make_db_mock() + app.dependency_overrides[get_db] = lambda: self.db + self.client = TestClient(app) + + def teardown_method(self): + app.dependency_overrides.clear() + + def _setup_list(self, rows, total=None): + count_row = MagicMock() + count_row.__getitem__ = lambda self, i: total if total is not None else len(rows) + self.db.execute.return_value.fetchone.side_effect = [count_row] + self.db.execute.return_value.fetchall.return_value = rows + + def test_list_returns_200(self): + """GET /legal-templates returns 200 with templates list.""" + rows = [make_template_row()] + count_mock = MagicMock() + count_mock.__getitem__ = lambda self, i: 1 + + execute_results = [] + first_call = MagicMock() + first_call.fetchone.return_value = count_mock + second_call = MagicMock() + second_call.fetchall.return_value = rows + self.db.execute.side_effect = [first_call, second_call] + + res = self.client.get("/legal-templates") + assert res.status_code == 200 + data = res.json() + assert "templates" in data + assert "total" in data + + def test_list_filter_by_document_type(self): + """GET /legal-templates?document_type=impressum passes filter.""" + count_mock = MagicMock() + count_mock.__getitem__ = lambda self, i: 0 + first_call = MagicMock() + first_call.fetchone.return_value = count_mock + second_call = MagicMock() + second_call.fetchall.return_value = [] + self.db.execute.side_effect = [first_call, second_call] + + res = self.client.get("/legal-templates?document_type=impressum") + assert res.status_code == 200 + # Verify the SQL call used document_type filter + call_args = self.db.execute.call_args_list[0] + sql_str = str(call_args[0][0]) + assert "document_type" in sql_str + + def test_create_invalid_document_type_returns_400(self): + """POST /legal-templates with invalid document_type returns 400.""" + res = self.client.post("/legal-templates", json={ + "document_type": "unknown_type", + "title": "Test", + "content": "# Test" + }) + assert res.status_code == 400 + assert "document_type" in res.json()["detail"] + + def test_create_invalid_status_returns_400(self): + """POST /legal-templates with invalid status returns 400.""" + res = self.client.post("/legal-templates", json={ + "document_type": "privacy_policy", + "title": "Test", + "content": "# Test", + "status": "invalid_status" + }) + assert res.status_code == 400 + + def test_get_nonexistent_returns_404(self): + """GET /legal-templates/{id} for unknown ID returns 404.""" + self.db.execute.return_value.fetchone.return_value = None + res = self.client.get(f"/legal-templates/{UNKNOWN_ID}") + assert res.status_code == 404 + + def test_update_no_fields_returns_400(self): + """PUT /legal-templates/{id} with empty body returns 400.""" + res = self.client.put(f"/legal-templates/{TEMPLATE_ID}", json={}) + assert res.status_code == 400 + assert "No fields" in res.json()["detail"] + + def test_delete_nonexistent_returns_404(self): + """DELETE /legal-templates/{id} for unknown ID returns 404.""" + result_mock = MagicMock() + result_mock.rowcount = 0 + self.db.execute.return_value = result_mock + res = self.client.delete(f"/legal-templates/{UNKNOWN_ID}") + assert res.status_code == 404 + + def test_status_endpoint_returns_200(self): + """GET /legal-templates/status returns count structure.""" + row = MagicMock() + row._mapping = { + "total": 3, "published": 3, "draft": 0, "archived": 0, + "privacy_policy": 1, "terms_of_service": 1, "impressum": 1 + } + self.db.execute.return_value.fetchone.return_value = row + res = self.client.get("/legal-templates/status") + assert res.status_code == 200 + data = res.json() + assert data["total"] == 3 + assert "by_type" in data + assert "by_status" in data + + def test_sources_endpoint_returns_list(self): + """GET /legal-templates/sources returns sources list.""" + self.db.execute.return_value.fetchall.return_value = [ + ("BreakPilot Compliance",), + ] + res = self.client.get("/legal-templates/sources") + assert res.status_code == 200 + data = res.json() + assert "sources" in data + assert "BreakPilot Compliance" in data["sources"] + + +# ============================================================================= +# TestLegalTemplateSeed +# ============================================================================= + +class TestLegalTemplateSeed: + """Validate that seed template structures are correct.""" + + def test_dse_placeholders_present(self): + """DSE template row has expected placeholder tokens.""" + row = make_template_row({ + "document_type": "privacy_policy", + "placeholders": [ + "{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{COMPANY_CITY}}", + "{{CONTACT_EMAIL}}", "{{DPO_NAME}}", "{{DPO_EMAIL}}", + "{{SUPERVISORY_AUTHORITY}}", "{{VERSION_DATE}}" + ] + }) + result = _row_to_dict(row) + assert "{{COMPANY_NAME}}" in result["placeholders"] + assert "{{CONTACT_EMAIL}}" in result["placeholders"] + assert "{{VERSION_DATE}}" in result["placeholders"] + assert len(result["placeholders"]) == 8 + + def test_impressum_has_ceo_placeholder(self): + """Impressum template row has CEO_NAME placeholder.""" + row = make_template_row({ + "document_type": "impressum", + "placeholders": [ + "{{COMPANY_NAME}}", "{{COMPANY_LEGAL_FORM}}", "{{COMPANY_ADDRESS}}", + "{{COMPANY_CITY}}", "{{CEO_NAME}}", "{{COMPANY_PHONE}}", + "{{CONTACT_EMAIL}}", "{{WEBSITE_URL}}", "{{REGISTER_SECTION}}", "{{VAT_SECTION}}" + ] + }) + result = _row_to_dict(row) + assert "{{CEO_NAME}}" in result["placeholders"] + assert "{{WEBSITE_URL}}" in result["placeholders"] + + def test_agb_has_pricing_placeholder(self): + """AGB template row has PRICING_SECTION placeholder.""" + row = make_template_row({ + "document_type": "terms_of_service", + "placeholders": [ + "{{COMPANY_NAME}}", "{{SERVICE_DESCRIPTION}}", "{{PRICING_SECTION}}", + "{{PAYMENT_TERMS}}", "{{COMPANY_CITY}}", "{{PRIVACY_POLICY_URL}}", "{{VERSION_DATE}}" + ] + }) + result = _row_to_dict(row) + assert "{{PRICING_SECTION}}" in result["placeholders"] + assert "{{PAYMENT_TERMS}}" in result["placeholders"] + assert "{{PRIVACY_POLICY_URL}}" in result["placeholders"] + + def test_all_seeds_use_mit_license(self): + """All seed templates declare MIT license and no attribution required.""" + for doc_type in ["privacy_policy", "terms_of_service", "impressum"]: + row = make_template_row({"document_type": doc_type}) + result = _row_to_dict(row) + assert result["license_id"] == "mit" + assert result["license_name"] == "MIT License" + assert result["attribution_required"] is False + assert result["is_complete_document"] is True diff --git a/scripts/apply_legal_templates_migration.sh b/scripts/apply_legal_templates_migration.sh new file mode 100755 index 0000000..28d487b --- /dev/null +++ b/scripts/apply_legal_templates_migration.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e +echo "Applying Migration 018: Legal Templates..." +/usr/local/bin/docker exec bp-compliance-backend python3 -c " +import psycopg2, os +conn = psycopg2.connect(os.getenv('DATABASE_URL')) +cur = conn.cursor() +with open('/app/migrations/018_legal_templates.sql') as f: + cur.execute(f.read()) +conn.commit() +cur.close() +conn.close() +print('Migration 018 applied successfully') +" +echo "Done."