- Added `bot_username` to settings for dynamic retrieval of the bot's username. - Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats. - Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information. - Enhanced API responses to include contact details for users, ensuring better communication. - Introduced a new current duty view in the web app, displaying active duty information along with contact options. - Updated CSS styles for better presentation of contact information in duty cards. - Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
|
|
|
import logging
|
|
import re
|
|
from typing import Annotated, Generator
|
|
|
|
from fastapi import Depends, 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,
|
|
get_user_by_telegram_id,
|
|
can_access_miniapp_for_telegram_user,
|
|
)
|
|
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
|
from duty_teller.db.session import session_scope
|
|
from duty_teller.i18n import t
|
|
from duty_teller.i18n.lang import normalize_lang
|
|
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
|
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
|
|
|
|
|
|
def _parse_first_language_code(header: str | None) -> str | None:
|
|
"""Extract the first language code from Accept-Language header.
|
|
|
|
Args:
|
|
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
|
|
|
Returns:
|
|
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
|
|
"""
|
|
if not header or not header.strip():
|
|
return None
|
|
first = header.strip().split(",")[0].strip()
|
|
m = _ACCEPT_LANG_CODE_RE.match(first)
|
|
return m.group(1).lower() if m else None
|
|
|
|
|
|
def _lang_from_accept_language(header: str | None) -> str:
|
|
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
|
|
|
Args:
|
|
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
|
|
|
Returns:
|
|
'ru' or 'en'.
|
|
"""
|
|
code = _parse_first_language_code(header)
|
|
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
|
|
|
|
|
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
|
"""Return translated auth error message."""
|
|
if auth_reason == "hash_mismatch":
|
|
return t(lang, "api.auth_bad_signature")
|
|
return t(lang, "api.auth_invalid")
|
|
|
|
|
|
def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
|
"""Validate date range; raise HTTPException with translated detail."""
|
|
try:
|
|
validate_date_range(from_date, to_date)
|
|
except DateRangeValidationError as e:
|
|
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
|
|
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
|
except ValueError as e:
|
|
# Backward compatibility if something else raises ValueError.
|
|
raise HTTPException(status_code=400, detail=t(lang, "dates.bad_format")) from e
|
|
|
|
|
|
def get_validated_dates(
|
|
request: Request,
|
|
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 from/to date query params; use Accept-Language for error messages.
|
|
|
|
Args:
|
|
request: FastAPI request (for Accept-Language).
|
|
from_date: Start date YYYY-MM-DD.
|
|
to_date: End date YYYY-MM-DD.
|
|
|
|
Returns:
|
|
(from_date, to_date) as strings.
|
|
|
|
Raises:
|
|
HTTPException: 400 if format invalid or from_date > to_date.
|
|
"""
|
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
|
_validate_duty_dates(from_date, to_date, lang)
|
|
return (from_date, to_date)
|
|
|
|
|
|
def get_db_session() -> Generator[Session, None, None]:
|
|
"""Yield a DB session for the request; closed automatically by FastAPI."""
|
|
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,
|
|
session: Session = Depends(get_db_session),
|
|
) -> str:
|
|
"""FastAPI dependency: require valid Miniapp auth; return username/identifier.
|
|
|
|
Raises:
|
|
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
|
"""
|
|
return get_authenticated_username(request, x_telegram_init_data, session)
|
|
|
|
|
|
def get_authenticated_username(
|
|
request: Request,
|
|
x_telegram_init_data: str | None,
|
|
session: Session,
|
|
) -> str:
|
|
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.
|
|
|
|
Args:
|
|
request: FastAPI request (for Accept-Language in error messages).
|
|
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
|
session: DB session (for phone allowlist lookup).
|
|
|
|
Returns:
|
|
Username, full_name, or "id:<telegram_id>"; empty string only if MINI_APP_SKIP_AUTH.
|
|
|
|
Raises:
|
|
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
|
"""
|
|
if config.MINI_APP_SKIP_AUTH:
|
|
log.warning(
|
|
"MINI_APP_SKIP_AUTH is set — no auth check (insecure, dev only); "
|
|
"do not use in production"
|
|
)
|
|
return ""
|
|
init_data = (x_telegram_init_data or "").strip()
|
|
if not init_data:
|
|
log.warning("no X-Telegram-Init-Data header")
|
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
|
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
|
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
|
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
|
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
|
)
|
|
if auth_reason != "ok":
|
|
log.warning("initData validation failed: %s", auth_reason)
|
|
raise HTTPException(
|
|
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
|
)
|
|
if telegram_user_id is None:
|
|
log.warning("initData valid but telegram_user_id missing")
|
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
|
user = get_user_by_telegram_id(session, telegram_user_id)
|
|
if not user:
|
|
log.warning(
|
|
"user not in DB (username=%s, telegram_id=%s)",
|
|
username,
|
|
telegram_user_id,
|
|
)
|
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
|
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
|
failed_phone = config.normalize_phone(user.phone) if user.phone else None
|
|
log.warning(
|
|
"access denied (username=%s, telegram_id=%s, phone=%s)",
|
|
username,
|
|
telegram_user_id,
|
|
failed_phone or "—",
|
|
)
|
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
|
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
|
|
|
|
|
def fetch_duties_response(
|
|
session: Session, from_date: str, to_date: str
|
|
) -> list[DutyWithUser]:
|
|
"""Load duties in range and return as DutyWithUser list for API response.
|
|
|
|
Args:
|
|
session: DB session.
|
|
from_date: Start date YYYY-MM-DD.
|
|
to_date: End date YYYY-MM-DD.
|
|
|
|
Returns:
|
|
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
|
|
"""
|
|
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_EVENT_TYPES else "duty"
|
|
),
|
|
phone=phone,
|
|
username=username,
|
|
)
|
|
for duty, full_name, phone, username in rows
|
|
]
|