Spaces:
Running
Running
| """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] = {} | |
| 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}"'}, | |
| ) | |
| 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." | |
| ), | |
| } | |
| 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 | |
| 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, | |
| } | |