Arag / app /api /gdpr.py
AuthorBot
Allow SuperAdmin to proxy author admin growth routes.
9e4dfdc
Raw
History Blame Contribute Delete
6.06 kB
"""Author RAG — GDPR / Data Rights API.
Handles data subject rights under GDPR, CCPA, and similar regulations.
Routes:
POST /api/admin/{slug}/data/export → download all personal data as JSON
POST /api/admin/{slug}/data/delete-request → schedule account + data deletion
GET /api/admin/{slug}/data/delete-status → check deletion status
POST /api/admin/{slug}/data/anonymize → anonymise conversation messages
"""
import json
import io
from datetime import datetime, timezone, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_author_access, get_db
from app.models.user import User
from app.models.chat_session import ChatSession, ChatMessage
logger = structlog.get_logger(__name__)
router = APIRouter()
# Track deletion requests (Phase 4: move to DB table)
_deletion_requests: dict[str, dict] = {}
@router.post("/{slug}/data/export")
async def export_personal_data(
slug: str,
db: AsyncSession = Depends(get_db),
access=Depends(get_admin_author_access),
):
"""Export all personal data for this author as a JSON file (GDPR Article 20)."""
author = access.author
# Gather all data
sessions_r = await db.execute(
select(ChatSession).where(ChatSession.author_id == slug)
)
sessions = sessions_r.scalars().all()
session_data = []
for s in sessions:
msgs_r = await db.execute(
select(ChatMessage).where(ChatMessage.session_id == s.session_id)
)
session_data.append({
"session_id": s.session_id,
"started_at": s.created_at.isoformat() if s.created_at else None,
"messages": [
{"role": m.role, "content": m.content,
"at": m.created_at.isoformat() if m.created_at else None}
for m in msgs_r.scalars()
],
})
export = {
"export_date": datetime.now(timezone.utc).isoformat(),
"author": {
"id": author.id,
"email": author.email,
"full_name": author.full_name,
"created_at": author.created_at.isoformat() if author.created_at else None,
},
"conversations": session_data,
"note": "This export contains all personal data we hold for your account.",
}
content = json.dumps(export, indent=2, ensure_ascii=False)
filename = f"data_export_{slug}_{datetime.now().strftime('%Y%m%d')}.json"
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{slug}/data/delete-request", status_code=202)
async def request_data_deletion(
slug: str,
db: AsyncSession = Depends(get_db),
access=Depends(get_admin_author_access),
):
"""Schedule account + all associated data for deletion in 30 days (GDPR Article 17).
30-day grace period allows the author to cancel if requested in error.
"""
author = access.author
if slug in _deletion_requests:
existing = _deletion_requests[slug]
return {
"status": "already_requested",
"scheduled_at": existing["scheduled_at"],
"deletion_date": existing["deletion_date"],
"message": "Deletion already scheduled. Contact support to cancel.",
}
scheduled_at = datetime.now(timezone.utc)
deletion_date = scheduled_at + timedelta(days=30)
_deletion_requests[slug] = {
"author_id": slug,
"scheduled_at": scheduled_at.isoformat(),
"deletion_date": deletion_date.isoformat(),
"status": "pending",
}
# Flag the user as pending deletion
await db.execute(
update(User).where(User.id == slug).values(
notes=f"GDPR_DELETE_REQUESTED:{deletion_date.isoformat()}"
)
)
await db.commit()
logger.warning("Data deletion requested", author_id=slug, deletion_date=deletion_date.isoformat())
return {
"status": "scheduled",
"scheduled_at": scheduled_at.isoformat(),
"deletion_date": deletion_date.isoformat(),
"message": (
"Your account and all data will be permanently deleted on "
f"{deletion_date.strftime('%B %d, %Y')}. "
"To cancel, contact support@chatbotai.io within 30 days."
),
}
@router.get("/{slug}/data/delete-status")
async def get_deletion_status(slug: str, access=Depends(get_admin_author_access)):
"""Check if a deletion request is pending."""
req = _deletion_requests.get(slug)
if not req:
return {"status": "none", "message": "No deletion request on file."}
return req
@router.post("/{slug}/data/anonymize")
async def anonymize_conversations(
slug: str,
db: AsyncSession = Depends(get_db),
access=Depends(get_admin_author_access),
):
"""Anonymise all visitor messages in conversation history.
Replaces message content with [ANONYMISED] for analytics retention
while removing any PII the visitor may have shared.
"""
author = access.author
# Get all sessions for this author
sessions_r = await db.execute(
select(ChatSession.session_id).where(ChatSession.author_id == slug)
)
session_ids = [row[0] for row in sessions_r]
if not session_ids:
return {"message": "No conversations to anonymise.", "affected": 0}
result = await db.execute(
update(ChatMessage)
.where(ChatMessage.session_id.in_(session_ids), ChatMessage.role == "user")
.values(content="[ANONYMISED]")
)
await db.commit()
affected = result.rowcount
logger.info("Conversations anonymised", author_id=slug, rows=affected)
return {
"message": f"Anonymised {affected} visitor messages. Bot responses retained for quality analysis.",
"affected": affected,
}