feat: Legal Templates Service — eigene Vorlagen für Dokumentengenerator
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 37s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s

Implementiert MIT-lizenzierte DSGVO-Templates (DSE, Impressum, AGB) in
der eigenen PostgreSQL-Datenbank statt KLAUSUR_SERVICE-Abhängigkeit.

- Migration 018: compliance_legal_templates Tabelle + 3 Seed-Templates
- Routes: GET/POST/PUT/DELETE /legal-templates + /status + /sources
- Registriert im bestehenden compliance catch-all Proxy (kein neuer Proxy)
- searchTemplates.ts: eigenes Backend als Primary, RAG bleibt Fallback
- ServiceMode-Banner: KLAUSUR_SERVICE-Referenz entfernt
- Tests: 25 Python + 3 Vitest — alle grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-03 23:12:07 +01:00
parent 29e6998a28
commit f909182632
8 changed files with 1079 additions and 93 deletions

View File

@@ -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' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
KLAUSUR_SERVICE nicht verfügbar Suche läuft über RAG-Fallback
Template-Datenbank nicht erreichbar Suche läuft über RAG-Fallback
</div>
)}
{serviceMode === 'offline' && (

View File

@@ -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([{
json: () => Promise.resolve({
templates: [{
id: 't1',
score: 0.9,
text: 'Inhalt',
document_title: 'DSE Template',
template_type: 'privacy_policy',
clause_category: null,
title: 'Datenschutzerklärung (DSGVO-konform)',
document_type: 'privacy_policy',
content: '# Datenschutzerklärung\n\n{{COMPANY_NAME}}',
description: 'Vollständige DSE',
language: 'de',
jurisdiction: 'de',
jurisdiction: 'DE',
license_id: 'mit',
license_name: 'MIT',
license_url: null,
license_name: 'MIT License',
source_name: 'BreakPilot Compliance',
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,
}]),
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({

View File

@@ -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<LegalTemplateResult[]> {
// 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<any> {
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<any[]> {
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()
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 []
}
}

View File

@@ -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",
]

View File

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

View File

@@ -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;

View File

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

View File

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