"""User accounts, magic links, consent, data deletion (Loi 25).""" from __future__ import annotations from datetime import timedelta from sqlalchemy import delete, select from app.core.config import Settings from app.core.security import new_magic_token, role_for_email from app.db import SessionLocal from app.models import ( Conversation, MagicLink, Message, Quiz, QuizAttempt, ToolCall, User, utcnow, ) from app.services import files as file_service async def get_or_create_user(email: str, settings: Settings) -> User: email = email.lower().strip() async with SessionLocal() as session: user = await session.scalar(select(User).where(User.email == email)) role = role_for_email(email, settings) if user is None: user = User(email=email, role=role, display_name=email.split("@")[0], preferences={"tutoiement": True, "course": settings.courses[0], "deep": False, "locale": "fr-CA"}) session.add(user) elif role != "student" and user.role == "student": user.role = role # promoted via env user.last_seen_at = utcnow() await session.commit() await session.refresh(user) return user async def get_user(user_id: str) -> User | None: async with SessionLocal() as session: return await session.get(User, user_id) async def touch(user_id: str) -> None: async with SessionLocal() as session: user = await session.get(User, user_id) if user: user.last_seen_at = utcnow() await session.commit() async def update_preferences(user_id: str, prefs: dict, display_name: str | None = None) -> User: async with SessionLocal() as session: user = await session.get(User, user_id) if user is None: raise ValueError("user not found") merged = dict(user.preferences or {}) merged.update({k: v for k, v in prefs.items() if k in {"tutoiement", "course", "deep", "locale", "font_scale"}}) user.preferences = merged if display_name is not None: user.display_name = display_name[:120] await session.commit() await session.refresh(user) return user async def set_consent(user_id: str) -> None: async with SessionLocal() as session: user = await session.get(User, user_id) if user and not user.consent_at: user.consent_at = utcnow() await session.commit() async def create_magic_link(email: str, ttl_minutes: int = 20) -> str: token = new_magic_token() async with SessionLocal() as session: session.add(MagicLink(token=token, email=email.lower().strip(), expires_at=utcnow() + timedelta(minutes=ttl_minutes))) await session.commit() return token async def consume_magic_link(token: str) -> str | None: async with SessionLocal() as session: link = await session.get(MagicLink, token) if not link or link.used_at or link.expires_at < utcnow(): return None link.used_at = utcnow() await session.commit() return link.email async def set_role(email: str, role: str) -> bool: async with SessionLocal() as session: user = await session.scalar(select(User).where(User.email == email.lower().strip())) if not user: return False user.role = role await session.commit() return True async def delete_user_data(user_id: str) -> None: """Purge everything about a user (conversations, files, quizzes) then the account.""" await file_service.purge_user(user_id) async with SessionLocal() as session: conv_ids = [c for c in (await session.execute( select(Conversation.id).where(Conversation.user_id == user_id))).scalars()] if conv_ids: msg_ids = [m for m in (await session.execute( select(Message.id).where(Message.conversation_id.in_(conv_ids)))).scalars()] if msg_ids: await session.execute(delete(ToolCall).where(ToolCall.message_id.in_(msg_ids))) await session.execute(delete(Message).where(Message.id.in_(msg_ids))) await session.execute(delete(Conversation).where(Conversation.id.in_(conv_ids))) quiz_ids = [q for q in (await session.execute( select(Quiz.id).where(Quiz.user_id == user_id))).scalars()] if quiz_ids: await session.execute(delete(QuizAttempt).where(QuizAttempt.quiz_id.in_(quiz_ids))) await session.execute(delete(Quiz).where(Quiz.id.in_(quiz_ids))) await session.execute(delete(User).where(User.id == user_id)) await session.commit()