"""FastAPI dependencies: DB session, auth, date validation.""" import logging from typing import Annotated, Generator from fastapi import Header, HTTPException, Query, Request from sqlalchemy.orm import Session import duty_teller.config as config from duty_teller.api.telegram_auth import validate_init_data_with_reason from duty_teller.db.repository import get_duties from duty_teller.db.schemas import DutyWithUser from duty_teller.db.session import session_scope from duty_teller.utils.dates import validate_date_range log = logging.getLogger(__name__) def _validate_duty_dates(from_date: str, to_date: str) -> None: try: validate_date_range(from_date, to_date) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e def get_validated_dates( from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"), to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"), ) -> tuple[str, str]: _validate_duty_dates(from_date, to_date) return (from_date, to_date) def get_db_session() -> Generator[Session, None, None]: with session_scope(config.DATABASE_URL) as session: yield session def require_miniapp_username( request: Request, x_telegram_init_data: Annotated[ str | None, Header(alias="X-Telegram-Init-Data") ] = None, ) -> str: return get_authenticated_username(request, x_telegram_init_data) def _auth_error_detail(auth_reason: str) -> str: if auth_reason == "hash_mismatch": return ( "Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, " "из которого открыт календарь (тот же бот, что в меню)." ) return "Неверные данные авторизации" def _is_private_client(client_host: str | None) -> bool: if not client_host: return False if client_host in ("127.0.0.1", "::1"): return True parts = client_host.split(".") if len(parts) == 4: try: a, b, c, d = (int(x) for x in parts) if (a == 10) or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168): return True except (ValueError, IndexError): pass return False def get_authenticated_username( request: Request, x_telegram_init_data: str | None ) -> str: init_data = (x_telegram_init_data or "").strip() if not init_data: client_host = request.client.host if request.client else None if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH: if config.MINI_APP_SKIP_AUTH: log.warning("allowing without initData (MINI_APP_SKIP_AUTH is set)") return "" log.warning("no X-Telegram-Init-Data header (client=%s)", client_host) raise HTTPException(status_code=403, detail="Откройте календарь из Telegram") max_age = config.INIT_DATA_MAX_AGE_SECONDS or None username, auth_reason = validate_init_data_with_reason( init_data, config.BOT_TOKEN, max_age_seconds=max_age ) if username is None: log.warning("initData validation failed: %s", auth_reason) raise HTTPException(status_code=403, detail=_auth_error_detail(auth_reason)) if not config.can_access_miniapp(username): log.warning("username not in allowlist: %s", username) raise HTTPException(status_code=403, detail="Доступ запрещён") return username def fetch_duties_response( session: Session, from_date: str, to_date: str ) -> list[DutyWithUser]: rows = get_duties(session, from_date=from_date, to_date=to_date) return [ DutyWithUser( id=duty.id, user_id=duty.user_id, start_at=duty.start_at, end_at=duty.end_at, full_name=full_name, event_type=( duty.event_type if duty.event_type in ("duty", "unavailable", "vacation") else "duty" ), ) for duty, full_name in rows ]