# -*- coding: utf-8 -*- """Client Turni-Live — turni.loogle.it (JWT Bearer).""" import logging import os import threading import time from typing import Any, Optional from urllib.parse import urljoin import httpx LOGGER = logging.getLogger("loogle_mcp.turni") MCP_USERS = ("daniele", "lucia", "davide", "luca") MCP_TO_SERVICE_USER = { "daniele": "daniely", "lucia": "lucia", "davide": "davide", "luca": "luca", } PUBLIC_URL = os.environ.get("TURNI_URL", "https://turni.loogle.it").rstrip("/") API_URL = os.environ.get("TURNI_API_URL", PUBLIC_URL).rstrip("/") _jwt_cache: dict[str, tuple[str, float]] = {} _jwt_lock = threading.Lock() JWT_TTL = 3600 * 6 def _service_username(mcp_username: str) -> str: return MCP_TO_SERVICE_USER.get(mcp_username.lower(), mcp_username.lower()) def _password_for_user(mcp_username: str) -> Optional[str]: user = mcp_username.lower() pwd = os.environ.get(f"TURNI_PASSWORD_{user.upper()}", "").strip() if pwd: return pwd return os.environ.get("TURNI_PASSWORD", "").strip() or None def _jwt_for_user(mcp_username: str) -> Optional[str]: user = mcp_username.lower() direct = os.environ.get(f"TURNI_JWT_{user.upper()}", "").strip() if direct: return direct return os.environ.get("TURNI_JWT", "").strip() or None def is_configured(username: Optional[str] = None) -> bool: user = (username or "daniele").lower() if _jwt_for_user(user): return True if _password_for_user(user): return True return False def _store_jwt(mcp_username: str, token: str) -> None: with _jwt_lock: _jwt_cache[mcp_username.lower()] = (token, time.time() + JWT_TTL) def _cached_jwt(mcp_username: str) -> Optional[str]: with _jwt_lock: row = _jwt_cache.get(mcp_username.lower()) if not row: return None token, expires = row if time.time() > expires: _jwt_cache.pop(mcp_username.lower(), None) return None return token def login(mcp_username: str) -> str: cached = _cached_jwt(mcp_username) if cached: return cached preset = _jwt_for_user(mcp_username) if preset: _store_jwt(mcp_username, preset) return preset password = _password_for_user(mcp_username) if not password: raise RuntimeError( f"Turni non configurato per {mcp_username}. " f"Imposta TURNI_PASSWORD_{mcp_username.upper()} o TURNI_JWT_{mcp_username.upper()}" ) service_user = _service_username(mcp_username) verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( "0", "false", "no", "off", ) url = urljoin(API_URL + "/", "api/auth/login") with httpx.Client(timeout=30.0, verify=verify) as client: resp = client.post(url, json={"username": service_user, "password": password}) if resp.status_code != 200: raise RuntimeError(f"Login Turni fallito: HTTP {resp.status_code}") data = resp.json() token = data.get("token") if not token: raise RuntimeError("Login Turni: token JWT mancante") _store_jwt(mcp_username, token) return token def _request( method: str, path: str, *, mcp_username: str, params: Optional[dict] = None, ) -> Any: token = login(mcp_username) verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( "0", "false", "no", "off", ) url = path if path.startswith("http") else urljoin(API_URL + "/", path.lstrip("/")) headers = {"Authorization": f"Bearer {token}"} with httpx.Client(timeout=60.0, verify=verify) as client: resp = client.request(method, url, headers=headers, params=params) if resp.status_code == 401: with _jwt_lock: _jwt_cache.pop(mcp_username.lower(), None) headers["Authorization"] = f"Bearer {login(mcp_username)}" resp = client.request(method, url, headers=headers, params=params) if resp.status_code >= 400: raise RuntimeError(f"Turni {path}: HTTP {resp.status_code} {resp.text[:200]}") return resp.json() def get_status() -> dict: verify = os.environ.get("TURNI_VERIFY_SSL", "true").strip().lower() not in ( "0", "false", "no", "off", ) url = urljoin(API_URL + "/", "api/status") with httpx.Client(timeout=30.0, verify=verify) as client: resp = client.get(url) resp.raise_for_status() return resp.json() def list_doctors(mcp_username: str) -> Any: return _request("GET", "/api/doctors", mcp_username=mcp_username) def get_shift_assignments( mcp_username: str, *, from_date: Optional[str] = None, to_date: Optional[str] = None, limit: int = 100, ) -> Any: params: dict = {} if from_date: params["from"] = from_date if to_date: params["to"] = to_date data = _request("GET", "/api/shift-assignments", mcp_username=mcp_username, params=params or None) if isinstance(data, list): return data[:limit] if isinstance(data, dict): items = data.get("assignments") or data.get("items") or data.get("results") if isinstance(items, list): return items[:limit] return data def get_my_shifts( mcp_username: str, *, from_date: Optional[str] = None, to_date: Optional[str] = None, limit: int = 50, ) -> dict: """Turni dell'utente MCP: filtra per doctorId collegato o per nome medico.""" user_info = _request("GET", "/api/users/me", mcp_username=mcp_username) doctor_id = user_info.get("doctorId") assignments = get_shift_assignments( mcp_username, from_date=from_date, to_date=to_date, limit=500, ) if not isinstance(assignments, list): return {"user": user_info, "assignments": assignments} if doctor_id: mine = [a for a in assignments if a.get("doctorId") == doctor_id or a.get("doctor_id") == doctor_id] else: service_user = _service_username(mcp_username) doctors = list_doctors(mcp_username) doc_ids = set() if isinstance(doctors, list): for doc in doctors: name = (doc.get("name") or doc.get("fullName") or "").lower() if service_user.lower() in name or mcp_username.lower() in name: doc_ids.add(doc.get("id") or doc.get("doctorId")) mine = [ a for a in assignments if (a.get("doctorId") or a.get("doctor_id")) in doc_ids ] if doc_ids else assignments[:limit] return { "user": { "username": user_info.get("username"), "role": user_info.get("role"), "doctorId": doctor_id, }, "assignments": mine[:limit], "count": len(mine), }