feat: Whistleblower backend + Scanner banner-check (last 2 gaps)
Whistleblower (HinSchG): - Migration 118: 3 tables (reports, messages, measures) with HinSchG deadlines (7d acknowledgment, 3mo feedback) - whistleblower_routes.py: 14 endpoints (CRUD, acknowledge, close, messages, measures, public submit, anonymous status check) - Frontend api-operations.ts rewired from Go SDK to compliance proxy - Access key format XXXX-XXXX-XXXX for anonymous reporters Scanner banner-check (TTDSG § 25): - CMP Dashboard: green "Kein Cookie-Banner erforderlich" when no trackers detected + no banner configured - Red warning "Cookie-Banner fehlt!" when trackers found but no banner - Mandatory note: Impressum (DDG § 5) + DSE (DSGVO Art. 13) still required [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,7 @@ _ROUTER_MODULES = [
|
||||
"banner_analytics_routes",
|
||||
"banner_ab_routes",
|
||||
"compliance_report_routes",
|
||||
"whistleblower_routes",
|
||||
]
|
||||
|
||||
_loaded_count = 0
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
FastAPI routes for Whistleblower (HinSchG) — Hinweisgeberschutz.
|
||||
|
||||
Admin endpoints for managing reports + public endpoint for anonymous submissions.
|
||||
Deadlines: 7 days acknowledgment (§ 17 Abs. 1), 3 months feedback (§ 17 Abs. 2).
|
||||
|
||||
Endpoints:
|
||||
GET /whistleblower/reports — list with filters
|
||||
GET /whistleblower/reports/stats — counts by status/category
|
||||
POST /whistleblower/reports — create report (admin)
|
||||
GET /whistleblower/reports/{id} — single report with messages
|
||||
PUT /whistleblower/reports/{id} — update status/priority/assignment
|
||||
POST /whistleblower/reports/{id}/acknowledge — send acknowledgment
|
||||
POST /whistleblower/reports/{id}/close — close report
|
||||
POST /whistleblower/reports/{id}/messages — add message
|
||||
GET /whistleblower/reports/{id}/measures — list measures
|
||||
POST /whistleblower/reports/{id}/measures — add measure
|
||||
POST /whistleblower/submit — public anonymous submission
|
||||
GET /whistleblower/check/{access_key} — reporter checks status
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from .tenant_utils import get_tenant_id as _get_tenant_id
|
||||
from .db_utils import row_to_dict as _row_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/whistleblower", tags=["whistleblower"])
|
||||
|
||||
VALID_CATEGORIES = {"corruption", "fraud", "data_protection", "discrimination",
|
||||
"environment", "competition", "product_safety", "tax_evasion", "other"}
|
||||
VALID_STATUSES = {"new", "acknowledged", "under_review", "investigation",
|
||||
"measures_taken", "closed", "rejected"}
|
||||
|
||||
|
||||
def _gen_ref(tenant_id: str, db: Session) -> str:
|
||||
year = datetime.now().year
|
||||
q = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid")
|
||||
count = db.execute(q, {"tid": tenant_id}).scalar() or 0
|
||||
return f"WB-{year}-{count + 1:06d}"
|
||||
|
||||
|
||||
def _gen_access_key() -> str:
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
parts = [''.join(secrets.choice(chars) for _ in range(4)) for _ in range(3)]
|
||||
return '-'.join(parts)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schemas
|
||||
# =============================================================================
|
||||
|
||||
class ReportCreate(BaseModel):
|
||||
category: str = "other"
|
||||
title: str
|
||||
description: str
|
||||
is_anonymous: bool = True
|
||||
reporter_name: Optional[str] = None
|
||||
reporter_email: Optional[str] = None
|
||||
reporter_phone: Optional[str] = None
|
||||
priority: str = "normal"
|
||||
|
||||
class ReportUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
message: str
|
||||
sender_type: str = "admin"
|
||||
is_internal: bool = False
|
||||
|
||||
class MeasureCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
due_date: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
where = ["tenant_id = :tid"]
|
||||
params = {"tid": tenant_id, "lim": limit}
|
||||
if status:
|
||||
where.append("status = :st")
|
||||
params["st"] = status
|
||||
if category:
|
||||
where.append("category = :cat")
|
||||
params["cat"] = category
|
||||
q = text(f"SELECT * FROM compliance_whistleblower_reports WHERE {' AND '.join(where)} ORDER BY received_at DESC LIMIT :lim")
|
||||
return [_row_to_dict(r) for r in db.execute(q, params).fetchall()]
|
||||
|
||||
|
||||
@router.get("/reports/stats")
|
||||
def report_stats(
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
q = text("SELECT status, COUNT(*) as cnt FROM compliance_whistleblower_reports WHERE tenant_id = :tid GROUP BY status")
|
||||
by_status = {r.status: r.cnt for r in db.execute(q, {"tid": tenant_id}).fetchall()}
|
||||
q2 = text("SELECT category, COUNT(*) as cnt FROM compliance_whistleblower_reports WHERE tenant_id = :tid GROUP BY category")
|
||||
by_category = {r.category: r.cnt for r in db.execute(q2, {"tid": tenant_id}).fetchall()}
|
||||
q3 = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid AND deadline_acknowledgment < :now AND acknowledged_at IS NULL AND status = 'new'")
|
||||
overdue_ack = db.execute(q3, {"tid": tenant_id, "now": now}).scalar() or 0
|
||||
q4 = text("SELECT COUNT(*) FROM compliance_whistleblower_reports WHERE tenant_id = :tid AND deadline_feedback < :now AND status NOT IN ('closed', 'rejected')")
|
||||
overdue_fb = db.execute(q4, {"tid": tenant_id, "now": now}).scalar() or 0
|
||||
total = sum(by_status.values())
|
||||
return {"total": total, "by_status": by_status, "by_category": by_category, "overdue_acknowledgment": overdue_ack, "overdue_feedback": overdue_fb}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def create_report(
|
||||
body: ReportCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
now = datetime.now(timezone.utc)
|
||||
ref = _gen_ref(tenant_id, db)
|
||||
ak = _gen_access_key()
|
||||
q = text("""
|
||||
INSERT INTO compliance_whistleblower_reports
|
||||
(tenant_id, reference_number, access_key, category, title, description,
|
||||
is_anonymous, reporter_name, reporter_email, reporter_phone, priority,
|
||||
received_at, deadline_acknowledgment, deadline_feedback)
|
||||
VALUES (:tid, :ref, :ak, :cat, :title, :desc,
|
||||
:anon, :rn, :re, :rp, :pri,
|
||||
:now, :dl_ack, :dl_fb)
|
||||
RETURNING *
|
||||
""")
|
||||
row = db.execute(q, {
|
||||
"tid": tenant_id, "ref": ref, "ak": ak,
|
||||
"cat": body.category, "title": body.title, "desc": body.description,
|
||||
"anon": body.is_anonymous, "rn": body.reporter_name,
|
||||
"re": body.reporter_email, "rp": body.reporter_phone,
|
||||
"pri": body.priority, "now": now,
|
||||
"dl_ack": now + timedelta(days=7),
|
||||
"dl_fb": now + timedelta(days=90),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
def get_report(
|
||||
report_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
row = db.execute(text("SELECT * FROM compliance_whistleblower_reports WHERE id = :rid AND tenant_id = :tid"),
|
||||
{"rid": report_id, "tid": tenant_id}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Report not found")
|
||||
result = _row_to_dict(row)
|
||||
msgs = db.execute(text("SELECT * FROM compliance_whistleblower_messages WHERE report_id = :rid ORDER BY created_at"),
|
||||
{"rid": report_id}).fetchall()
|
||||
result["messages"] = [_row_to_dict(m) for m in msgs]
|
||||
measures = db.execute(text("SELECT * FROM compliance_whistleblower_measures WHERE report_id = :rid ORDER BY created_at"),
|
||||
{"rid": report_id}).fetchall()
|
||||
result["measures"] = [_row_to_dict(m) for m in measures]
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/reports/{report_id}")
|
||||
def update_report(
|
||||
report_id: str,
|
||||
body: ReportUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
sets, params = [], {"rid": report_id, "tid": tenant_id}
|
||||
for field in ["status", "priority", "assigned_to", "category"]:
|
||||
val = getattr(body, field, None)
|
||||
if val is not None:
|
||||
sets.append(f"{field} = :{field}")
|
||||
params[field] = val
|
||||
if not sets:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
sets.append("updated_at = NOW()")
|
||||
q = text(f"UPDATE compliance_whistleblower_reports SET {', '.join(sets)} WHERE id = :rid AND tenant_id = :tid RETURNING *")
|
||||
row = db.execute(q, params).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Report not found")
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/acknowledge")
|
||||
def acknowledge_report(
|
||||
report_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
q = text("""
|
||||
UPDATE compliance_whistleblower_reports
|
||||
SET status = 'acknowledged', acknowledged_at = NOW(), updated_at = NOW()
|
||||
WHERE id = :rid AND tenant_id = :tid RETURNING *
|
||||
""")
|
||||
row = db.execute(q, {"rid": report_id, "tid": tenant_id}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Report not found")
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/close")
|
||||
def close_report(
|
||||
report_id: str,
|
||||
reason: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
q = text("""
|
||||
UPDATE compliance_whistleblower_reports
|
||||
SET status = 'closed', closed_at = NOW(), closure_reason = :reason, updated_at = NOW()
|
||||
WHERE id = :rid AND tenant_id = :tid RETURNING *
|
||||
""")
|
||||
row = db.execute(q, {"rid": report_id, "tid": tenant_id, "reason": reason}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Report not found")
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/messages")
|
||||
def add_message(
|
||||
report_id: str,
|
||||
body: MessageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
q = text("""
|
||||
INSERT INTO compliance_whistleblower_messages (report_id, sender_type, message, is_internal)
|
||||
VALUES (:rid, :st, :msg, :internal) RETURNING *
|
||||
""")
|
||||
row = db.execute(q, {"rid": report_id, "st": body.sender_type, "msg": body.message, "internal": body.is_internal}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/measures")
|
||||
def list_measures(report_id: str, db: Session = Depends(get_db)):
|
||||
return [_row_to_dict(r) for r in db.execute(text(
|
||||
"SELECT * FROM compliance_whistleblower_measures WHERE report_id = :rid ORDER BY created_at"
|
||||
), {"rid": report_id}).fetchall()]
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/measures")
|
||||
def add_measure(
|
||||
report_id: str, body: MeasureCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = text("""
|
||||
INSERT INTO compliance_whistleblower_measures (report_id, title, description, responsible, due_date)
|
||||
VALUES (:rid, :title, :desc, :resp, :due) RETURNING *
|
||||
""")
|
||||
row = db.execute(q, {"rid": report_id, "title": body.title, "desc": body.description,
|
||||
"resp": body.responsible, "due": body.due_date}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Routes (Anonymous)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/submit")
|
||||
def submit_report(body: ReportCreate, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id)):
|
||||
"""Public anonymous submission — same as create but returns only access_key."""
|
||||
body.is_anonymous = True
|
||||
result = create_report(body, db, tenant_id)
|
||||
return {"access_key": result["access_key"], "reference_number": result["reference_number"],
|
||||
"message": "Ihre Meldung wurde erfolgreich eingereicht. Nutzen Sie den Zugangscode um den Status zu pruefen."}
|
||||
|
||||
|
||||
@router.get("/check/{access_key}")
|
||||
def check_status(access_key: str, db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id)):
|
||||
"""Reporter checks status anonymously via access key."""
|
||||
row = db.execute(text(
|
||||
"SELECT id, reference_number, status, category, received_at, acknowledged_at FROM compliance_whistleblower_reports WHERE access_key = :ak AND tenant_id = :tid"
|
||||
), {"ak": access_key, "tid": tenant_id}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Meldung nicht gefunden")
|
||||
result = _row_to_dict(row)
|
||||
msgs = db.execute(text(
|
||||
"SELECT message, sender_type, created_at FROM compliance_whistleblower_messages WHERE report_id = :rid AND is_internal = FALSE ORDER BY created_at"
|
||||
), {"rid": result["id"]}).fetchall()
|
||||
result["messages"] = [_row_to_dict(m) for m in msgs]
|
||||
return result
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Migration 118: Whistleblower (HinSchG) — Report + Message tables
|
||||
-- Meldestelle fuer Hinweisgeber gemaess HinSchG §§ 12-18
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_whistleblower_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
reference_number VARCHAR(50) NOT NULL,
|
||||
access_key VARCHAR(20) NOT NULL,
|
||||
category VARCHAR(30) NOT NULL DEFAULT 'other',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'new',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
reporter_name VARCHAR(300),
|
||||
reporter_email VARCHAR(300),
|
||||
reporter_phone VARCHAR(100),
|
||||
assigned_to VARCHAR(300),
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
deadline_acknowledgment TIMESTAMPTZ,
|
||||
deadline_feedback TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ,
|
||||
closure_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, reference_number),
|
||||
UNIQUE(tenant_id, access_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wb_reports_tenant ON compliance_whistleblower_reports(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wb_reports_status ON compliance_whistleblower_reports(tenant_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_wb_reports_access ON compliance_whistleblower_reports(access_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_whistleblower_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
report_id UUID NOT NULL REFERENCES compliance_whistleblower_reports(id) ON DELETE CASCADE,
|
||||
sender_type VARCHAR(20) NOT NULL DEFAULT 'reporter',
|
||||
message TEXT NOT NULL,
|
||||
is_internal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wb_messages_report ON compliance_whistleblower_messages(report_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_whistleblower_measures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
report_id UUID NOT NULL REFERENCES compliance_whistleblower_reports(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planned',
|
||||
responsible VARCHAR(300),
|
||||
due_date TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wb_measures_report ON compliance_whistleblower_measures(report_id);
|
||||
Reference in New Issue
Block a user