""" 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, timezone from typing import Optional, List, Any, Dict 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="/legal-templates", tags=["legal-templates"]) VALID_DOCUMENT_TYPES = { # Original types "privacy_policy", "terms_of_service", "impressum", "cookie_policy", # Renamed from data_processing_agreement / withdrawal_policy (Migration 020) "dpa", "widerruf", # New types (Migration 020) "nda", "sla", "acceptable_use", "community_guidelines", "copyright_policy", "cloud_service_agreement", "data_usage_clause", "cookie_banner", "agb", "clause", } 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" # Attribution / source traceability source_url: Optional[str] = None source_repo: Optional[str] = None source_file_path: Optional[str] = None attribution_text: Optional[str] = None inspiration_sources: Optional[List[Any]] = None 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 # Attribution / source traceability source_url: Optional[str] = None source_repo: Optional[str] = None source_file_path: Optional[str] = None attribution_text: Optional[str] = None inspiration_sources: Optional[List[Any]] = None # ============================================================================= # 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), tenant_id: str = Depends(_get_tenant_id), ): """List legal templates with optional filters.""" 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), tenant_id: str = Depends(_get_tenant_id), ): """Return template counts by document_type.""" total_row = db.execute( text("SELECT COUNT(*) FROM compliance_legal_templates WHERE tenant_id = :tenant_id"), {"tenant_id": tenant_id}, ).fetchone() total = int(total_row[0] or 0) if total_row else 0 status_rows = db.execute(text(""" SELECT status, COUNT(*) AS cnt FROM compliance_legal_templates WHERE tenant_id = :tenant_id GROUP BY status """), {"tenant_id": tenant_id}).fetchall() by_status: Dict[str, int] = {r[0]: int(r[1] or 0) for r in status_rows} type_rows = db.execute(text(""" SELECT document_type, COUNT(*) AS cnt FROM compliance_legal_templates WHERE tenant_id = :tenant_id GROUP BY document_type ORDER BY document_type """), {"tenant_id": tenant_id}).fetchall() by_type: Dict[str, int] = {r[0]: int(r[1] or 0) for r in type_rows} return { "total": total, "by_status": { "published": by_status.get("published", 0), "draft": by_status.get("draft", 0), "archived": by_status.get("archived", 0), }, "by_type": by_type, } @router.get("/sources") async def get_template_sources( db: Session = Depends(get_db), tenant_id: str = Depends(_get_tenant_id), ): """Return distinct source_name values.""" 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), tenant_id: str = Depends(_get_tenant_id), ): """Fetch a single template by 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), tenant_id: str = Depends(_get_tenant_id), ): """Create a new legal template.""" 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 []) inspiration_json = json.dumps(payload.inspiration_sources 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, source_url, source_repo, source_file_path, source_retrieved_at, attribution_text, inspiration_sources ) 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, :source_url, :source_repo, :source_file_path, :source_retrieved_at, :attribution_text, CAST(:inspiration_sources AS jsonb) ) 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, "source_url": payload.source_url, "source_repo": payload.source_repo, "source_file_path": payload.source_file_path, "source_retrieved_at": None, "attribution_text": payload.attribution_text, "inspiration_sources": inspiration_json, }).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), tenant_id: str = Depends(_get_tenant_id), ): """Update an existing legal template.""" 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="Invalid document_type") if "status" in updates and updates["status"] not in VALID_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") set_clauses = ["updated_at = :updated_at"] params: Dict[str, Any] = { "id": template_id, "tenant_id": tenant_id, "updated_at": datetime.now(timezone.utc), } jsonb_fields = {"placeholders", "inspiration_sources"} for field, value in updates.items(): if field in jsonb_fields: 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), tenant_id: str = Depends(_get_tenant_id), ): """Delete a legal template.""" 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")