"""User accounts, magic links, consent, data deletion (Loi 25).""" from __future__ import annotations from datetime import timedelta from sqlalchemy import delete, func, select from app.core.config import Settings from app.core.security import hash_password, new_magic_token, role_for_email, verify_password from app.db import SessionLocal from app.models import ( AppSetting, 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, purpose: str = "login") -> str: """Single-use token. purpose: "login" (direct sign-in), "invite" / "reset" (choose a password).""" token = new_magic_token() async with SessionLocal() as session: session.add(MagicLink(token=token, email=email.lower().strip(), purpose=purpose, expires_at=utcnow() + timedelta(minutes=ttl_minutes))) await session.commit() return token def _link_valid(link: MagicLink | None) -> bool: return bool(link and not link.used_at and link.expires_at >= utcnow()) async def peek_magic_link(token: str) -> MagicLink | None: """Validate without consuming (used by the set-password page to show the e-mail).""" async with SessionLocal() as session: link = await session.get(MagicLink, token) return link if _link_valid(link) else None async def consume_magic_link(token: str, purposes: set[str] | None = None) -> str | None: async with SessionLocal() as session: link = await session.get(MagicLink, token) if not _link_valid(link): return None if purposes is not None and (link.purpose or "login") not in purposes: return None link.used_at = utcnow() await session.commit() return link.email async def invalidate_links(email: str, purposes: set[str] | None = None) -> int: """Burn every outstanding token of an address (after a password was set).""" async with SessionLocal() as session: rows = (await session.execute(select(MagicLink).where( MagicLink.email == email.lower().strip(), MagicLink.used_at.is_(None)))).scalars().all() n = 0 for link in rows: if purposes is None or (link.purpose or "login") in purposes: link.used_at = utcnow() n += 1 await session.commit() return n async def get_user_by_email(email: str) -> User | None: async with SessionLocal() as session: return await session.scalar(select(User).where(User.email == email.lower().strip())) async def mark_invited(user_ids: list[str]) -> None: if not user_ids: return async with SessionLocal() as session: rows = (await session.execute(select(User).where(User.id.in_(user_ids)))).scalars().all() now = utcnow() for u in rows: u.invited_at = now await session.commit() async def users_without_password(only_never_invited: bool = False) -> list[User]: async with SessionLocal() as session: q = select(User).where(User.password_hash.is_(None)).order_by(User.email) if only_never_invited: q = q.where(User.invited_at.is_(None)) return list((await session.execute(q)).scalars().all()) 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() # ------------------------------------------------------------------ professor: students async def list_users() -> list[dict]: async with SessionLocal() as session: counts = dict((await session.execute( select(Conversation.user_id, func.count(Message.id)).join(Message) .where(Message.role == "user").group_by(Conversation.user_id))).all()) rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars() return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name, "has_password": bool(u.password_hash), "invited_at": u.invited_at.isoformat() if u.invited_at else None, "created_at": u.created_at.isoformat(), "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None, "consent_at": u.consent_at.isoformat() if u.consent_at else None, "messages": int(counts.get(u.id, 0))} for u in rows] async def create_users(emails: list[str], settings: Settings, role: str = "student", display_names: dict[str, str] | None = None) -> dict: created, existing, invalid = [], [], [] created_ids: list[str] = [] async with SessionLocal() as session: for raw in emails: email = raw.strip().lower() if not email or "@" not in email or " " in email: if raw.strip(): invalid.append(raw.strip()) continue if email in created: continue user = await session.scalar(select(User).where(User.email == email)) if user: existing.append(email) continue r = role_for_email(email, settings) u = User(email=email, role=r if r != "student" else role, display_name=(display_names or {}).get(email) or email.split("@")[0], preferences={"tutoiement": True, "course": settings.courses[0], "deep": False, "locale": "fr-CA"}) session.add(u) created.append(email) await session.flush() created_ids.append(u.id) await session.commit() return {"created": created, "existing": existing, "invalid": invalid, "created_ids": created_ids} async def email_registered(email: str) -> bool: async with SessionLocal() as session: return (await session.scalar(select(User.id).where(User.email == email.lower().strip()))) is not None async def get_setting(key: str) -> str | None: async with SessionLocal() as session: row = await session.get(AppSetting, key) return row.value if row else None async def set_setting(key: str, value: str) -> None: async with SessionLocal() as session: row = await session.get(AppSetting, key) if row: row.value = value else: session.add(AppSetting(key=key, value=value)) await session.commit() # ------------------------------------------------------------------ passwords async def set_password(user_id: str, password: str) -> bool: async with SessionLocal() as session: user = await session.get(User, user_id) if not user: return False user.password_hash = hash_password(password) await session.commit() return True async def authenticate(email: str, password: str) -> User | None: async with SessionLocal() as session: user = await session.scalar(select(User).where(User.email == email.lower().strip())) if user and verify_password(password, user.password_hash): user.last_seen_at = utcnow() await session.commit() await session.refresh(user) return user return None