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