1"""User accounts, magic links, consent, data deletion (Loi 25)."""23from __future__ import annotations45from datetime import timedelta67from sqlalchemy import delete, select89from app.core.config import Settings10from app.core.security import new_magic_token, role_for_email11from app.db import SessionLocal12from app.models import (13 Conversation,14 MagicLink,15 Message,16 Quiz,17 QuizAttempt,18 ToolCall,19 User,20 utcnow,21)22from app.services import files as file_service232425async def get_or_create_user(email: str, settings: Settings) -> User:26 email = email.lower().strip()27 async with SessionLocal() as session:28 user = await session.scalar(select(User).where(User.email == email))29 role = role_for_email(email, settings)30 if user is None:31 user = User(email=email, role=role, display_name=email.split("@")[0],32 preferences={"tutoiement": True, "course": settings.courses[0],33 "deep": False, "locale": "fr-CA"})34 session.add(user)35 elif role != "student" and user.role == "student":36 user.role = role # promoted via env37 user.last_seen_at = utcnow()38 await session.commit()39 await session.refresh(user)40 return user414243async def get_user(user_id: str) -> User | None:44 async with SessionLocal() as session:45 return await session.get(User, user_id)464748async def touch(user_id: str) -> None:49 async with SessionLocal() as session:50 user = await session.get(User, user_id)51 if user:52 user.last_seen_at = utcnow()53 await session.commit()545556async def update_preferences(user_id: str, prefs: dict, display_name: str | None = None) -> User:57 async with SessionLocal() as session:58 user = await session.get(User, user_id)59 if user is None:60 raise ValueError("user not found")61 merged = dict(user.preferences or {})62 merged.update({k: v for k, v in prefs.items() if k in63 {"tutoiement", "course", "deep", "locale", "font_scale"}})64 user.preferences = merged65 if display_name is not None:66 user.display_name = display_name[:120]67 await session.commit()68 await session.refresh(user)69 return user707172async def set_consent(user_id: str) -> None:73 async with SessionLocal() as session:74 user = await session.get(User, user_id)75 if user and not user.consent_at:76 user.consent_at = utcnow()77 await session.commit()787980async def create_magic_link(email: str, ttl_minutes: int = 20) -> str:81 token = new_magic_token()82 async with SessionLocal() as session:83 session.add(MagicLink(token=token, email=email.lower().strip(),84 expires_at=utcnow() + timedelta(minutes=ttl_minutes)))85 await session.commit()86 return token878889async def consume_magic_link(token: str) -> str | None:90 async with SessionLocal() as session:91 link = await session.get(MagicLink, token)92 if not link or link.used_at or link.expires_at < utcnow():93 return None94 link.used_at = utcnow()95 await session.commit()96 return link.email979899async def set_role(email: str, role: str) -> bool:100 async with SessionLocal() as session:101 user = await session.scalar(select(User).where(User.email == email.lower().strip()))102 if not user:103 return False104 user.role = role105 await session.commit()106 return True107108109async def delete_user_data(user_id: str) -> None:110 """Purge everything about a user (conversations, files, quizzes) then the account."""111 await file_service.purge_user(user_id)112 async with SessionLocal() as session:113 conv_ids = [c for c in (await session.execute(114 select(Conversation.id).where(Conversation.user_id == user_id))).scalars()]115 if conv_ids:116 msg_ids = [m for m in (await session.execute(117 select(Message.id).where(Message.conversation_id.in_(conv_ids)))).scalars()]118 if msg_ids:119 await session.execute(delete(ToolCall).where(ToolCall.message_id.in_(msg_ids)))120 await session.execute(delete(Message).where(Message.id.in_(msg_ids)))121 await session.execute(delete(Conversation).where(Conversation.id.in_(conv_ids)))122 quiz_ids = [q for q in (await session.execute(123 select(Quiz.id).where(Quiz.user_id == user_id))).scalars()]124 if quiz_ids:125 await session.execute(delete(QuizAttempt).where(QuizAttempt.quiz_id.in_(quiz_ids)))126 await session.execute(delete(Quiz).where(Quiz.id.in_(quiz_ids)))127 await session.execute(delete(User).where(User.id == user_id))128 await session.commit()129