shinkan-jinkendo/backend/routers/legal_documents.py

476 lines
16 KiB
Python
Raw Normal View History

"""
Rechtstexte (Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie).
Öffentlich (kein Auth):
GET /api/legal-documents/{document_type}/published
Superadmin only:
GET /api/admin/legal-documents – Liste aller Versionen
POST /api/admin/legal-documents – Neuen Entwurf anlegen
GET /api/admin/legal-documents/{id} – Einzelnes Dokument
PUT /api/admin/legal-documents/{id} – Entwurf bearbeiten
POST /api/admin/legal-documents/{id}/publish – Veröffentlichen
POST /api/admin/legal-documents/{id}/archive – Archivieren
GET /api/admin/legal-documents/{id}/audit – Änderungslog
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
# ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug.
# Öffentlicher GET-Endpoint ohne jegliche Auth; Admin-Endpoints nutzen require_auth + is_superadmin().
# Eingetragen in backend/scripts/check_access_layer_hints.py EXEMPT_ROUTERS.
router = APIRouter(tags=["legal_documents"])
VALID_TYPES = {"impressum", "privacy_policy", "terms_of_use", "media_policy"}
def _require_superadmin(session: dict):
role = (session.get("role") or "").lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
class LegalDocumentCreate(BaseModel):
document_type: str
title: str
content_sections: List[Dict[str, Any]] = []
change_note: Optional[str] = None
class LegalDocumentUpdate(BaseModel):
title: Optional[str] = None
content_sections: Optional[List[Dict[str, Any]]] = None
change_note: Optional[str] = None
class PublishRequest(BaseModel):
change_note: Optional[str] = None
# ─── Public endpoint ────────────────────────────────────────────────────────
@router.get("/api/legal-documents/{document_type}/published")
def get_published_legal_document(document_type: str):
"""Liefert das aktuell veröffentlichte Dokument oder null. Kein Auth erforderlich."""
if document_type not in VALID_TYPES:
raise HTTPException(status_code=404, detail="Unbekannter Dokumententyp")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
FROM legal_documents
WHERE document_type = %s AND status = 'published'
LIMIT 1
""",
(document_type,),
)
row = cur.fetchone()
if not row:
return None
doc = r2d(row)
return doc
# ─── Superadmin endpoints ────────────────────────────────────────────────────
@router.get("/api/admin/legal-documents")
def list_legal_documents(
document_type: Optional[str] = None,
session: dict = Depends(require_auth),
):
"""Alle Versionen aller Rechtstexte (oder gefiltert nach document_type)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
if document_type:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
WHERE d.document_type = %s
ORDER BY d.document_type, d.version DESC
""",
(document_type,),
)
else:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
ORDER BY d.document_type, d.version DESC
"""
)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.post("/api/admin/legal-documents", status_code=201)
def create_legal_document(
body: LegalDocumentCreate,
session: dict = Depends(require_auth),
):
"""Neuen Entwurf anlegen. Versionsnummer = max(vorherige) + 1."""
_require_superadmin(session)
if body.document_type not in VALID_TYPES:
raise HTTPException(status_code=422, detail="Ungültiger document_type")
profile_id = session["profile_id"]
import json as _json
sections_json = _json.dumps(body.content_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(version), 0) FROM legal_documents WHERE document_type = %s",
(body.document_type,),
)
row = cur.fetchone()
next_version = list(row.values())[0] + 1
cur.execute(
"""
INSERT INTO legal_documents
(document_type, version, title, content_sections, status, change_note, created_by_profile_id)
VALUES (%s, %s, %s, %s::jsonb, 'draft', %s, %s)
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(body.document_type, next_version, body.title, sections_json, body.change_note, profile_id),
)
new_row = cur.fetchone()
new_id = list(new_row.values())[0]
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'created', %s, %s)
""",
(new_id, profile_id, body.change_note),
)
conn.commit()
return r2d(new_row)
@router.get("/api/admin/legal-documents/{doc_id}")
def get_legal_document(doc_id: int, session: dict = Depends(require_auth)):
"""Einzelnes Dokument (alle Felder inkl. content_sections)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.content_sections,
d.status, d.change_note, d.created_at, d.updated_at,
d.published_at, d.created_by_profile_id,
p.name AS created_by_name,
pb.name AS published_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
LEFT JOIN profiles pb ON pb.id = d.published_by_profile_id
WHERE d.id = %s
""",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
return r2d(row)
@router.put("/api/admin/legal-documents/{doc_id}")
def update_legal_document(
doc_id: int,
body: LegalDocumentUpdate,
session: dict = Depends(require_auth),
):
"""Entwurf bearbeiten. Nur status='draft' kann bearbeitet werden."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status, title, content_sections, change_note FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] != "draft":
raise HTTPException(status_code=409, detail="Nur Entwürfe können bearbeitet werden")
import json as _json
new_title = body.title if body.title is not None else doc["title"]
new_sections = body.content_sections if body.content_sections is not None else doc["content_sections"]
new_note = body.change_note if body.change_note is not None else doc["change_note"]
sections_json = _json.dumps(new_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET title = %s, content_sections = %s::jsonb, change_note = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(new_title, sections_json, new_note, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'updated', %s, %s)
""",
(doc_id, profile_id, new_note),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/publish")
def publish_legal_document(
doc_id: int,
body: PublishRequest = PublishRequest(),
session: dict = Depends(require_auth),
):
"""
Veröffentlicht dieses Dokument. Ein bisher veröffentlichtes Dokument desselben
Typs wird automatisch auf 'archived' gesetzt (partial unique index erzwingt dies).
"""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, document_type, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "published":
raise HTTPException(status_code=409, detail="Dokument ist bereits veröffentlicht")
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Archivierte Dokumente können nicht veröffentlicht werden")
with get_db() as conn:
cur = get_cursor(conn)
# Vorheriges published-Dokument desselben Typs archivieren
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE document_type = %s AND status = 'published' AND id != %s
""",
(doc["document_type"], doc_id),
)
# Dieses Dokument veröffentlichen
cur.execute(
"""
UPDATE legal_documents
SET status = 'published', published_at = NOW(),
published_by_profile_id = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
""",
(profile_id, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, change_note, previous_status)
VALUES (%s, 'published', %s, %s, %s)
""",
(doc_id, profile_id, body.change_note, doc["status"]),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/archive")
def archive_legal_document(
doc_id: int,
session: dict = Depends(require_auth),
):
"""Archiviert ein Dokument. Veröffentlichte Dokumente dürfen nur archiviert werden wenn kein anderes published ist."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Dokument ist bereits archiviert")
prev_status = doc["status"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, status, updated_at
""",
(doc_id,),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, previous_status)
VALUES (%s, 'archived', %s, %s)
""",
(doc_id, profile_id, prev_status),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/copy-as-draft", status_code=201)
def copy_legal_document_as_draft(
doc_id: int,
session: dict = Depends(require_auth),
):
"""
Kopiert ein beliebiges Dokument (egal welcher Status) als neuen Entwurf mit
nächster Versionsnummer. Inhalt und Titel werden übernommen.
"""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, document_type, title, content_sections FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
src = r2d(row)
import json as _json
sections_json = _json.dumps(src["content_sections"], ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(version), 0) FROM legal_documents WHERE document_type = %s",
(src["document_type"],),
)
row2 = cur.fetchone()
next_version = list(row2.values())[0] + 1
cur.execute(
"""
INSERT INTO legal_documents
(document_type, version, title, content_sections, status, change_note, created_by_profile_id)
VALUES (%s, %s, %s, %s::jsonb, 'draft', NULL, %s)
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(src["document_type"], next_version, src["title"], sections_json, profile_id),
)
new_row = cur.fetchone()
new_id = list(new_row.values())[0]
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'created', %s, %s)
""",
(new_id, profile_id, f"Kopie von Version {src.get('version', '?')} (ID {doc_id})"),
)
conn.commit()
return r2d(new_row)
@router.get("/api/admin/legal-documents/{doc_id}/audit")
def get_legal_document_audit(doc_id: int, session: dict = Depends(require_auth)):
"""Änderungslog für ein Dokument."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM legal_documents WHERE id = %s", (doc_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
cur.execute(
"""
SELECT a.id, a.action, a.change_note, a.previous_status, a.created_at,
p.name AS changed_by_name
FROM legal_document_audit a
LEFT JOIN profiles p ON p.id = a.changed_by_profile_id
WHERE a.legal_document_id = %s
ORDER BY a.created_at DESC
""",
(doc_id,),
)
rows = cur.fetchall()
return [r2d(r) for r in rows]