Files
breakpilot-compliance/backend-compliance/compliance/api/consent_template_routes.py
Sharang Parnerkar cb90d0db0c chore(backend): deprecation sweep — Pydantic V1 -> V2, utcnow -> tz-aware
Two low-risk Pydantic V1 idioms that will be hard errors in V3:
  - Query(regex=...) -> Query(pattern=...) (audit_routes, control_generator_routes)
  - class Config: from_attributes=True -> model_config = ConfigDict(...)
    in source_policy_router.py (schemas.py is intentionally skipped — it is
    the Phase 1 schema-split target and the ConfigDict conversion is most
    efficient to do during that split).

Naive -> aware datetime sweep across 47 files:
  - datetime.utcnow() -> datetime.now(timezone.utc)
  - default=datetime.utcnow -> default=lambda: datetime.now(timezone.utc)
  - onupdate=datetime.utcnow -> onupdate=lambda: datetime.now(timezone.utc)

All SQLAlchemy DateTime columns in the project already declare
timezone=True, so the DB schema expects aware datetimes. Before this
commit, the in-Python side was generating naive values and the driver
was silently coercing them. This is a latent-bug fix, not a behavior
change at the DB boundary.

Verified:
  - 173/173 pytest compliance/tests/ pass (same as baseline)
  - tests/contracts/test_openapi_baseline.py passes (360 paths,
    484 operations unchanged)
  - DeprecationWarning count dropped from 158 -> 35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:09:59 +02:00

314 lines
10 KiB
Python

"""
FastAPI routes for Consent Email Templates + DSGVO Processes.
Endpoints:
GET /consent-templates — List email templates (filtered by tenant)
POST /consent-templates — Create a new email template
PUT /consent-templates/{id} — Update an email template
DELETE /consent-templates/{id} — Delete an email template
GET /gdpr-processes — List GDPR processes
PUT /gdpr-processes/{id} — Update a GDPR process
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text
from classroom_engine.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(tags=["consent-templates"])
# ============================================================================
# Pydantic Schemas
# ============================================================================
class ConsentTemplateCreate(BaseModel):
template_key: str
subject: str
body: str
language: str = 'de'
is_active: bool = True
class ConsentTemplateUpdate(BaseModel):
subject: Optional[str] = None
body: Optional[str] = None
is_active: Optional[bool] = None
class GDPRProcessUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
legal_basis: Optional[str] = None
retention_days: Optional[int] = None
is_active: Optional[bool] = None
# ============================================================================
# Helpers
# ============================================================================
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str:
return x_tenant_id or 'default'
# ============================================================================
# Email Templates
# ============================================================================
@router.get("/consent-templates")
async def list_consent_templates(
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""List all email templates for a tenant."""
rows = db.execute(
text("""
SELECT id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
FROM compliance_consent_email_templates
WHERE tenant_id = :tenant_id
ORDER BY template_key, language
"""),
{"tenant_id": tenant_id},
).fetchall()
return [
{
"id": str(r.id),
"tenant_id": r.tenant_id,
"template_key": r.template_key,
"subject": r.subject,
"body": r.body,
"language": r.language,
"is_active": r.is_active,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
@router.post("/consent-templates", status_code=201)
async def create_consent_template(
request: ConsentTemplateCreate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""Create a new email template."""
existing = db.execute(
text("""
SELECT id FROM compliance_consent_email_templates
WHERE tenant_id = :tenant_id AND template_key = :template_key AND language = :language
"""),
{"tenant_id": tenant_id, "template_key": request.template_key, "language": request.language},
).fetchone()
if existing:
raise HTTPException(
status_code=409,
detail=f"Template '{request.template_key}' for language '{request.language}' already exists for this tenant",
)
row = db.execute(
text("""
INSERT INTO compliance_consent_email_templates
(tenant_id, template_key, subject, body, language, is_active)
VALUES (:tenant_id, :template_key, :subject, :body, :language, :is_active)
RETURNING id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
"""),
{
"tenant_id": tenant_id,
"template_key": request.template_key,
"subject": request.subject,
"body": request.body,
"language": request.language,
"is_active": request.is_active,
},
).fetchone()
db.commit()
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"template_key": row.template_key,
"subject": row.subject,
"body": row.body,
"language": row.language,
"is_active": row.is_active,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@router.put("/consent-templates/{template_id}")
async def update_consent_template(
template_id: str,
request: ConsentTemplateUpdate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""Update an existing email template."""
existing = db.execute(
text("""
SELECT id FROM compliance_consent_email_templates
WHERE id = :id AND tenant_id = :tenant_id
"""),
{"id": template_id, "tenant_id": tenant_id},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
updates = request.dict(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
updates["id"] = template_id
updates["tenant_id"] = tenant_id
updates["now"] = datetime.now(timezone.utc)
row = db.execute(
text(f"""
UPDATE compliance_consent_email_templates
SET {set_clauses}, updated_at = :now
WHERE id = :id AND tenant_id = :tenant_id
RETURNING id, tenant_id, template_key, subject, body, language, is_active, created_at, updated_at
"""),
updates,
).fetchone()
db.commit()
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"template_key": row.template_key,
"subject": row.subject,
"body": row.body,
"language": row.language,
"is_active": row.is_active,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@router.delete("/consent-templates/{template_id}")
async def delete_consent_template(
template_id: str,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""Delete an email template."""
existing = db.execute(
text("""
SELECT id FROM compliance_consent_email_templates
WHERE id = :id AND tenant_id = :tenant_id
"""),
{"id": template_id, "tenant_id": tenant_id},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"Template {template_id} not found")
db.execute(
text("DELETE FROM compliance_consent_email_templates WHERE id = :id AND tenant_id = :tenant_id"),
{"id": template_id, "tenant_id": tenant_id},
)
db.commit()
return {"success": True, "message": f"Template {template_id} deleted"}
# ============================================================================
# GDPR Processes
# ============================================================================
@router.get("/gdpr-processes")
async def list_gdpr_processes(
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""List all GDPR processes for a tenant."""
rows = db.execute(
text("""
SELECT id, tenant_id, process_key, title, description, legal_basis, retention_days, is_active, created_at
FROM compliance_consent_gdpr_processes
WHERE tenant_id = :tenant_id
ORDER BY process_key
"""),
{"tenant_id": tenant_id},
).fetchall()
return [
{
"id": str(r.id),
"tenant_id": r.tenant_id,
"process_key": r.process_key,
"title": r.title,
"description": r.description,
"legal_basis": r.legal_basis,
"retention_days": r.retention_days,
"is_active": r.is_active,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
@router.put("/gdpr-processes/{process_id}")
async def update_gdpr_process(
process_id: str,
request: GDPRProcessUpdate,
db: Session = Depends(get_db),
tenant_id: str = Depends(_get_tenant),
):
"""Update an existing GDPR process."""
existing = db.execute(
text("""
SELECT id FROM compliance_consent_gdpr_processes
WHERE id = :id AND tenant_id = :tenant_id
"""),
{"id": process_id, "tenant_id": tenant_id},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"GDPR process {process_id} not found")
updates = request.dict(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
updates["id"] = process_id
updates["tenant_id"] = tenant_id
row = db.execute(
text(f"""
UPDATE compliance_consent_gdpr_processes
SET {set_clauses}
WHERE id = :id AND tenant_id = :tenant_id
RETURNING id, tenant_id, process_key, title, description, legal_basis, retention_days, is_active, created_at
"""),
updates,
).fetchone()
db.commit()
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"process_key": row.process_key,
"title": row.title,
"description": row.description,
"legal_basis": row.legal_basis,
"retention_days": row.retention_days,
"is_active": row.is_active,
"created_at": row.created_at.isoformat() if row.created_at else None,
}