"""Server-authoritative login session activity for the admin account list.""" from __future__ import annotations import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Iterable from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.models.user_login_session import UserLoginSession @dataclass(frozen=True) class UserLoginSummary: status: str = "OFFLINE" last_login_at: datetime | None = None last_seen_at: datetime | None = None client_type: str | None = None active_session_count: int = 0 def _now() -> datetime: return datetime.now(timezone.utc) def _as_utc(value: datetime) -> datetime: return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) def _text_header(headers: Any, name: str, limit: int) -> str | None: value = (headers.get(name) or "").strip() return value[:limit] or None def session_id_from_payload(payload: dict[str, Any]) -> uuid.UUID: raw_session_id = payload.get("sid") try: return uuid.UUID(str(raw_session_id)) except (TypeError, ValueError): # Tokens issued before session tracking are mapped to a deterministic # legacy session without persisting the token or a token-derived value. legacy_key = ":".join( [ str(payload.get("sub") or ""), str(payload.get("orig_iat") or payload.get("iat") or ""), str(payload.get("client_type") or "web"), ] ) return uuid.uuid5(uuid.NAMESPACE_URL, f"ctms:legacy-session:{legacy_key}") def session_client_type(payload: dict[str, Any], headers: Any) -> str: from_payload = (payload.get("client_type") or "").strip().lower() from_header = (headers.get("x-ctms-client-type") or "").strip().lower() return "desktop" if "desktop" in {from_payload, from_header} else "web" async def create_login_session( db: AsyncSession, *, session_id: uuid.UUID, user_id: uuid.UUID, request: Any, login_at: datetime | None = None, ) -> UserLoginSession: occurred_at = login_at or _now() session = UserLoginSession( id=session_id, user_id=user_id, client_type=session_client_type({}, request.headers), client_platform=_text_header(request.headers, "x-ctms-client-platform", 32), client_version=_text_header(request.headers, "x-ctms-client-version", 64), client_source=_text_header(request.headers, "x-ctms-client-source", 32), login_at=occurred_at, last_seen_at=occurred_at, ) db.add(session) await db.commit() await db.refresh(session) return session async def touch_login_session( db: AsyncSession, *, user_id: uuid.UUID, payload: dict[str, Any], request: Any, ) -> UserLoginSession | None: session_id = session_id_from_payload(payload) session = await db.get(UserLoginSession, session_id) now = _now() if session is None: session = UserLoginSession( id=session_id, user_id=user_id, client_type=session_client_type(payload, request.headers), client_platform=_text_header(request.headers, "x-ctms-client-platform", 32), client_version=_text_header(request.headers, "x-ctms-client-version", 64), client_source=_text_header(request.headers, "x-ctms-client-source", 32), login_at=now, last_seen_at=now, ) db.add(session) elif session.user_id != user_id or session.ended_at is not None: return None else: session.last_seen_at = now session.client_platform = _text_header(request.headers, "x-ctms-client-platform", 32) or session.client_platform session.client_version = _text_header(request.headers, "x-ctms-client-version", 64) or session.client_version await db.commit() await db.refresh(session) return session async def end_login_session( db: AsyncSession, *, user_id: uuid.UUID, payload: dict[str, Any], reason: str = "logout", ) -> bool: session_id = session_id_from_payload(payload) result = await db.execute( update(UserLoginSession) .where( UserLoginSession.id == session_id, UserLoginSession.user_id == user_id, UserLoginSession.ended_at.is_(None), ) .values(ended_at=_now(), end_reason=reason, last_seen_at=_now()) ) await db.commit() return bool(result.rowcount) async def get_login_summaries( db: AsyncSession, user_ids: Iterable[uuid.UUID], ) -> dict[uuid.UUID, UserLoginSummary]: ids = list(user_ids) if not ids: return {} rows = ( await db.execute( select(UserLoginSession) .where(UserLoginSession.user_id.in_(ids)) .order_by(UserLoginSession.user_id, UserLoginSession.login_at.desc()) ) ).scalars().all() cutoff = _now() - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS) summaries: dict[uuid.UUID, UserLoginSummary] = {} mutable: dict[uuid.UUID, dict[str, Any]] = {} for row in rows: current = mutable.setdefault( row.user_id, { "last_login_at": row.login_at, "last_seen_at": row.last_seen_at, "client_type": row.client_type, "active_session_count": 0, }, ) row_last_seen_at = _as_utc(row.last_seen_at) if row_last_seen_at and ( current["last_seen_at"] is None or row_last_seen_at > _as_utc(current["last_seen_at"]) ): current["last_seen_at"] = row_last_seen_at if row.ended_at is None and row_last_seen_at >= cutoff: current["active_session_count"] += 1 for user_id, item in mutable.items(): summaries[user_id] = UserLoginSummary( status="ONLINE" if item["active_session_count"] else "OFFLINE", last_login_at=item["last_login_at"], last_seen_at=item["last_seen_at"], client_type=item["client_type"], active_session_count=item["active_session_count"], ) return summaries async def list_login_activities( db: AsyncSession, *, user_id: uuid.UUID, limit: int, ) -> list[UserLoginSession]: result = await db.execute( select(UserLoginSession) .where(UserLoginSession.user_id == user_id) .order_by(UserLoginSession.login_at.desc()) .limit(limit) ) return list(result.scalars().all())