chore: add changelog and documentation updates
All checks were successful
CI / lint-and-test (push) Successful in 17s
All checks were successful
CI / lint-and-test (push) Successful in 17s
- Created a new `CHANGELOG.md` file to document all notable changes to the project, adhering to the Keep a Changelog format. - Updated `CONTRIBUTING.md` to include instructions for building and previewing documentation using MkDocs. - Added `mkdocs.yml` configuration for documentation generation, including navigation structure and theme settings. - Enhanced various documentation files, including API reference, architecture overview, configuration reference, and runbook, to provide comprehensive guidance for users and developers. - Included new sections in the README for changelog and documentation links, improving accessibility to project information.
This commit is contained in:
@@ -1 +1 @@
|
||||
# HTTP API for Mini App
|
||||
"""HTTP API for the calendar Mini App: duties, calendar events, and static webapp."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastAPI app: /api/duties and static webapp."""
|
||||
"""FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app."""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
@@ -33,7 +33,12 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/duties", response_model=list[DutyWithUser])
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
summary="List duties",
|
||||
description="Returns duties for the given date range. Requires Telegram Mini App initData (or MINI_APP_SKIP_AUTH / private IP in dev).",
|
||||
)
|
||||
def list_duties(
|
||||
request: Request,
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
@@ -48,7 +53,12 @@ def list_duties(
|
||||
return fetch_duties_response(session, from_date_val, to_date_val)
|
||||
|
||||
|
||||
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
|
||||
@app.get(
|
||||
"/api/calendar-events",
|
||||
response_model=list[CalendarEvent],
|
||||
summary="List calendar events",
|
||||
description="Returns calendar events for the date range, including external ICS when EXTERNAL_CALENDAR_ICS_URL is set. Auth same as /api/duties.",
|
||||
)
|
||||
def list_calendar_events(
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
@@ -61,7 +71,11 @@ def list_calendar_events(
|
||||
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
|
||||
|
||||
|
||||
@app.get("/api/calendar/ical/{token}.ics")
|
||||
@app.get(
|
||||
"/api/calendar/ical/{token}.ics",
|
||||
summary="Personal calendar ICS",
|
||||
description="Returns an ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.",
|
||||
)
|
||||
def get_personal_calendar_ical(
|
||||
token: str,
|
||||
session: Session = Depends(get_db_session),
|
||||
|
||||
@@ -102,9 +102,18 @@ def get_calendar_events(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Return list of {date: "YYYY-MM-DD", summary: "..."} for events in [from_date, to_date].
|
||||
Uses in-memory cache with TTL 7 days. On fetch/parse error returns [].
|
||||
"""Fetch ICS from URL and return events in the given date range.
|
||||
|
||||
Uses in-memory cache with TTL 7 days. Recurring events are skipped.
|
||||
On fetch or parse error returns an empty list.
|
||||
|
||||
Args:
|
||||
url: URL of the ICS calendar.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys "date" (YYYY-MM-DD) and "summary". Empty on error.
|
||||
"""
|
||||
if not url or from_date > to_date:
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastAPI dependencies: DB session, auth, date validation."""
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -22,7 +22,14 @@ _ACCEPT_LANG_TAG_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-[a-zA-Z0-9]+)?\s*(?:;|,|$
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE."""
|
||||
"""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'.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return config.DEFAULT_LANGUAGE
|
||||
first = header.strip().split(",")[0].strip()
|
||||
@@ -58,13 +65,26 @@ 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 from/to dates; lang from Accept-Language for error messages."""
|
||||
"""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
|
||||
|
||||
@@ -76,6 +96,11 @@ def require_miniapp_username(
|
||||
] = 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)
|
||||
|
||||
|
||||
@@ -100,7 +125,20 @@ def get_authenticated_username(
|
||||
x_telegram_init_data: str | None,
|
||||
session: Session,
|
||||
) -> str:
|
||||
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth."""
|
||||
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.
|
||||
|
||||
Args:
|
||||
request: FastAPI request (client host for private-IP bypass).
|
||||
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 if MINI_APP_SKIP_AUTH
|
||||
or private IP and no initData.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("allowing without any auth check (MINI_APP_SKIP_AUTH is set)")
|
||||
return ""
|
||||
@@ -142,6 +180,16 @@ def get_authenticated_username(
|
||||
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).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
DutyWithUser(
|
||||
|
||||
@@ -25,10 +25,14 @@ def _parse_utc_iso(iso_str: str) -> datetime:
|
||||
|
||||
|
||||
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
|
||||
"""
|
||||
Build a single VCALENDAR with one VEVENT per duty.
|
||||
duties_with_name: list of (Duty, full_name); full_name is unused for SUMMARY
|
||||
if we use event_type only; can be used later for DESCRIPTION.
|
||||
"""Build a VCALENDAR (ICS) with one VEVENT per duty.
|
||||
|
||||
Args:
|
||||
duties_with_name: List of (Duty, full_name). full_name is available for
|
||||
DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).
|
||||
|
||||
Returns:
|
||||
ICS file content as bytes (UTF-8).
|
||||
"""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Duty Teller//Personal Calendar//EN")
|
||||
|
||||
@@ -15,7 +15,16 @@ def validate_init_data(
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> str | None:
|
||||
"""Validate initData and return username; see validate_init_data_with_reason for failure reason."""
|
||||
"""Validate Telegram Web App initData and return username if valid.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from tgWebAppData.
|
||||
bot_token: Bot token (must match the bot that signed the data).
|
||||
max_age_seconds: Reject if auth_date older than this; None to disable.
|
||||
|
||||
Returns:
|
||||
Username (lowercase, no @) or None if validation fails.
|
||||
"""
|
||||
_, username, _, _ = validate_init_data_with_reason(
|
||||
init_data, bot_token, max_age_seconds
|
||||
)
|
||||
@@ -35,12 +44,19 @@ def validate_init_data_with_reason(
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> tuple[int | None, str | None, str, str]:
|
||||
"""
|
||||
Validate initData signature and return (telegram_user_id, username, reason, lang).
|
||||
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired",
|
||||
"no_user", "user_invalid", "no_user_id", "no_username" (legacy; no_user_id used when user.id missing).
|
||||
lang is from user.language_code normalized to 'ru' or 'en'; 'en' when no user.
|
||||
On success: (user.id, user.get('username') or None, "ok", lang) — username may be None.
|
||||
"""Validate initData signature and return user id, username, reason, and lang.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from tgWebAppData.
|
||||
bot_token: Bot token (must match the bot that signed the data).
|
||||
max_age_seconds: Reject if auth_date older than this; None to disable.
|
||||
|
||||
Returns:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"""
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
|
||||
Reference in New Issue
Block a user