"""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.core.request_context import resolve_client_ip from app.models.user_login_session import UserLoginSession from app.services.ip_location import resolve_ip_location @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" def login_activity_status(session: UserLoginSession, *, reference_time: datetime | None = None) -> str: if session.ended_at is not None: return "ENDED" cutoff = (reference_time or _now()) - timedelta(seconds=settings.USER_SESSION_ONLINE_SECONDS) return "ONLINE" if _as_utc(session.last_seen_at) >= cutoff else "OFFLINE" def login_activity_payload( session: UserLoginSession, *, reference_time: datetime | None = None, ) -> dict[str, Any]: ip_location = resolve_ip_location(session.login_ip) if session.login_ip else None return { "id": session.id, "client_type": session.client_type, "client_platform": session.client_platform, "client_version": session.client_version, "client_source": session.client_source, "login_ip": session.login_ip, "ip_location": ip_location.location if ip_location else None, "login_at": session.login_at, "last_seen_at": session.last_seen_at, "ended_at": session.ended_at, "end_reason": session.end_reason, "activity_status": login_activity_status(session, reference_time=reference_time), } 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_ip=resolve_client_ip(request), 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_ip=resolve_client_ip(request), 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_sources": set(), }, ) 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: source_key = (row.client_type, row.login_ip) if row.login_ip else ("session", row.id) current["active_sources"].add(source_key) for user_id, item in mutable.items(): active_session_count = len(item["active_sources"]) summaries[user_id] = UserLoginSummary( status="ONLINE" if 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=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())