167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_db, require_operations_manager
|
|
from app.core.config import get_settings
|
|
from app.models.booking import Booking
|
|
from app.models.customer import Customer
|
|
from app.schemas import (
|
|
CurrentUser,
|
|
CustomerAnonymizeRequest,
|
|
CustomerAnonymizeResult,
|
|
PrivacyRetentionOut,
|
|
)
|
|
from app.services.audit import record_audit_event
|
|
|
|
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
|
|
settings = get_settings()
|
|
|
|
|
|
def _retention_cutoff() -> datetime:
|
|
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
|
|
|
|
|
|
def _customer_is_eligible(db: Session, customer_id) -> bool:
|
|
blocking = db.scalar(
|
|
select(func.count())
|
|
.select_from(Booking)
|
|
.where(
|
|
Booking.customer_id == customer_id,
|
|
or_(
|
|
Booking.status.in_(("reserved", "active")),
|
|
Booking.ends_at > _retention_cutoff(),
|
|
),
|
|
)
|
|
)
|
|
return not blocking
|
|
|
|
|
|
@router.get("/retention", response_model=PrivacyRetentionOut)
|
|
def retention_status(
|
|
db: Session = Depends(get_db),
|
|
_user: CurrentUser = Depends(require_operations_manager),
|
|
) -> PrivacyRetentionOut:
|
|
customers = db.scalars(select(Customer)).all()
|
|
return PrivacyRetentionOut(
|
|
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
|
|
audit_retention_days=settings.privacy_audit_retention_days,
|
|
customers_total=len(customers),
|
|
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
|
|
customers_eligible=sum(
|
|
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
|
|
for customer in customers
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/customers/{public_ref}/export")
|
|
def export_customer_data(
|
|
public_ref: str,
|
|
db: Session = Depends(get_db),
|
|
actor: CurrentUser = Depends(require_operations_manager),
|
|
) -> JSONResponse:
|
|
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
|
|
if customer is None:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
bookings = db.scalars(
|
|
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
|
|
).all()
|
|
payload = {
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"customer": {
|
|
"public_ref": customer.public_ref,
|
|
"first_name": customer.first_name,
|
|
"last_name": customer.last_name,
|
|
"email": customer.email,
|
|
"phone": customer.phone,
|
|
"postal_code": customer.postal_code,
|
|
"city": customer.city,
|
|
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
|
|
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
|
|
},
|
|
"bookings": [
|
|
{
|
|
"public_ref": booking.public_ref,
|
|
"starts_at": booking.starts_at.isoformat(),
|
|
"ends_at": booking.ends_at.isoformat(),
|
|
"status": booking.status,
|
|
}
|
|
for booking in bookings
|
|
],
|
|
}
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_label=actor.display_name,
|
|
action="privacy_customer_exported",
|
|
entity_type="customer",
|
|
entity_id=customer.id,
|
|
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
|
|
)
|
|
db.commit()
|
|
return JSONResponse(
|
|
payload,
|
|
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
|
|
)
|
|
|
|
|
|
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
|
|
def anonymize_customer(
|
|
public_ref: str,
|
|
body: CustomerAnonymizeRequest,
|
|
db: Session = Depends(get_db),
|
|
actor: CurrentUser = Depends(require_operations_manager),
|
|
) -> CustomerAnonymizeResult:
|
|
customer = db.scalar(
|
|
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
|
|
)
|
|
if customer is None:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
if body.confirmation != public_ref:
|
|
raise HTTPException(
|
|
status_code=422, detail="Customer reference confirmation does not match"
|
|
)
|
|
if customer.anonymized_at is not None:
|
|
return CustomerAnonymizeResult(
|
|
public_ref=public_ref,
|
|
anonymized_at=customer.anonymized_at,
|
|
status="already_anonymized",
|
|
)
|
|
if not _customer_is_eligible(db, customer.id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Customer has an active/recent booking within the minimum retention period",
|
|
)
|
|
anonymized_at = datetime.now(UTC)
|
|
customer.first_name = "Anoniem"
|
|
customer.last_name = public_ref
|
|
customer.email = None
|
|
customer.phone = None
|
|
customer.postal_code = None
|
|
customer.city = None
|
|
customer.date_of_birth = None
|
|
customer.anonymized_at = anonymized_at
|
|
record_audit_event(
|
|
db,
|
|
actor_type="user",
|
|
actor_label=actor.display_name,
|
|
action="privacy_customer_anonymized",
|
|
entity_type="customer",
|
|
entity_id=customer.id,
|
|
before={"anonymized": False},
|
|
after={"anonymized": True},
|
|
metadata={"reason": body.reason, "customer_ref": public_ref},
|
|
)
|
|
db.commit()
|
|
return CustomerAnonymizeResult(
|
|
public_ref=public_ref,
|
|
anonymized_at=anonymized_at,
|
|
status="anonymized",
|
|
)
|