Refactor project structure and enhance Docker configuration
- Updated `.dockerignore` to exclude test and development artifacts, optimizing the Docker image size. - Refactored `main.py` to delegate execution to `duty_teller.run.main()`, simplifying the entry point. - Introduced a new `duty_teller` package to encapsulate core functionality, improving modularity and organization. - Enhanced `pyproject.toml` to define a script for running the application, streamlining the execution process. - Updated README documentation to reflect changes in project structure and usage instructions. - Improved Alembic environment configuration to utilize the new package structure for database migrations.
This commit is contained in:
1
duty_teller/api/__init__.py
Normal file
1
duty_teller/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# HTTP API for Mini App
|
||||
62
duty_teller/api/app.py
Normal file
62
duty_teller/api/app.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""FastAPI app: /api/duties and static webapp."""
|
||||
|
||||
import logging
|
||||
|
||||
import duty_teller.config as config
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from duty_teller.api.calendar_ics import get_calendar_events
|
||||
from duty_teller.api.dependencies import (
|
||||
fetch_duties_response,
|
||||
get_db_session,
|
||||
get_validated_dates,
|
||||
require_miniapp_username,
|
||||
)
|
||||
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/duties", response_model=list[DutyWithUser])
|
||||
def list_duties(
|
||||
request: Request,
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
session: Session = Depends(get_db_session),
|
||||
):
|
||||
from_date_val, to_date_val = dates
|
||||
log.info(
|
||||
"GET /api/duties from %s",
|
||||
request.client.host if request.client else "?",
|
||||
)
|
||||
return fetch_duties_response(session, from_date_val, to_date_val)
|
||||
|
||||
|
||||
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
|
||||
def list_calendar_events(
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
):
|
||||
from_date_val, to_date_val = dates
|
||||
url = config.EXTERNAL_CALENDAR_ICS_URL
|
||||
if not url:
|
||||
return []
|
||||
events = get_calendar_events(url, from_date=from_date_val, to_date=to_date_val)
|
||||
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
124
duty_teller/api/calendar_ics.py
Normal file
124
duty_teller/api/calendar_ics.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Fetch and parse external ICS calendar; in-memory cache with 7-day TTL."""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from icalendar import Calendar
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache: url -> (cached_at_timestamp, raw_ics_bytes)
|
||||
_ics_cache: dict[str, tuple[float, bytes]] = {}
|
||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # 1 week
|
||||
FETCH_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
def _fetch_ics(url: str) -> bytes | None:
|
||||
"""GET url, return response body or None on error."""
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "DutyTeller/1.0"})
|
||||
with urlopen(req, timeout=FETCH_TIMEOUT_SECONDS) as resp:
|
||||
return resp.read()
|
||||
except URLError as e:
|
||||
log.warning("Failed to fetch ICS from %s: %s", url, e)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("Error fetching ICS from %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def _to_date(dt) -> date | None:
|
||||
"""Convert icalendar DATE or DATE-TIME to date. Return None if invalid."""
|
||||
if isinstance(dt, datetime):
|
||||
return dt.date()
|
||||
if isinstance(dt, date):
|
||||
return dt
|
||||
return None
|
||||
|
||||
|
||||
def _event_date_range(component) -> tuple[date | None, date | None]:
|
||||
"""
|
||||
Get (start_date, end_date) for a VEVENT. DTEND is exclusive in iCalendar;
|
||||
last day of event = DTEND date - 1 day. Returns (None, None) if invalid.
|
||||
"""
|
||||
dtstart = component.get("dtstart")
|
||||
if not dtstart:
|
||||
return (None, None)
|
||||
start_d = _to_date(dtstart.dt)
|
||||
if not start_d:
|
||||
return (None, None)
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if not dtend:
|
||||
return (start_d, start_d)
|
||||
|
||||
end_dt = dtend.dt
|
||||
end_d = _to_date(end_dt)
|
||||
if not end_d:
|
||||
return (start_d, start_d)
|
||||
# DTEND is exclusive: last day of event is end_d - 1 day
|
||||
last_d = end_d - timedelta(days=1)
|
||||
return (start_d, last_d)
|
||||
|
||||
|
||||
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
|
||||
"""Parse ICS bytes and return list of {date, summary} in [from_date, to_date]. One-time events only."""
|
||||
result: list[dict] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(raw)
|
||||
if not cal:
|
||||
return result
|
||||
except Exception as e:
|
||||
log.warning("Failed to parse ICS: %s", e)
|
||||
return result
|
||||
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
if component.get("rrule"):
|
||||
continue # skip recurring in first iteration
|
||||
start_d, end_d = _event_date_range(component)
|
||||
if not start_d or not end_d:
|
||||
continue
|
||||
summary = component.get("summary")
|
||||
summary_str = str(summary) if summary else ""
|
||||
|
||||
d = start_d
|
||||
while d <= end_d:
|
||||
if from_d <= d <= to_d:
|
||||
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
|
||||
d += timedelta(days=1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_calendar_events(
|
||||
url: str,
|
||||
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 [].
|
||||
"""
|
||||
if not url or from_date > to_date:
|
||||
return []
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
raw: bytes | None = None
|
||||
if url in _ics_cache:
|
||||
cached_at, cached_raw = _ics_cache[url]
|
||||
if now - cached_at < CACHE_TTL_SECONDS:
|
||||
raw = cached_raw
|
||||
if raw is None:
|
||||
raw = _fetch_ics(url)
|
||||
if raw is None:
|
||||
return []
|
||||
_ics_cache[url] = (now, raw)
|
||||
|
||||
return _get_events_from_ics(raw, from_date, to_date)
|
||||
116
duty_teller/api/dependencies.py
Normal file
116
duty_teller/api/dependencies.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""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
|
||||
]
|
||||
84
duty_teller/api/telegram_auth.py
Normal file
84
duty_teller/api/telegram_auth.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Validate Telegram Web App initData and extract user username."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
|
||||
|
||||
|
||||
def validate_init_data(
|
||||
init_data: str,
|
||||
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."""
|
||||
username, _ = validate_init_data_with_reason(init_data, bot_token, max_age_seconds)
|
||||
return username
|
||||
|
||||
|
||||
def validate_init_data_with_reason(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> tuple[str | None, str]:
|
||||
"""
|
||||
Validate initData signature and return (username, None) or (None, reason).
|
||||
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "user_invalid", "no_username".
|
||||
"""
|
||||
if not init_data or not bot_token:
|
||||
return (None, "empty")
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, _, value = part.partition("=")
|
||||
if not key:
|
||||
continue
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, "no_hash")
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
# HMAC-SHA256(key=WebAppData, message=bot_token) per reference implementations
|
||||
secret_key = hmac.new(
|
||||
b"WebAppData",
|
||||
msg=bot_token.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
computed = hmac.new(
|
||||
secret_key,
|
||||
msg=data_string.encode(),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, "hash_mismatch")
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, "auth_date_expired")
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, "auth_date_expired")
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, "auth_date_expired")
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, "no_user")
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, "user_invalid")
|
||||
if not isinstance(user, dict):
|
||||
return (None, "user_invalid")
|
||||
username = user.get("username")
|
||||
if not username or not isinstance(username, str):
|
||||
return (None, "no_username")
|
||||
return (username.strip().lstrip("@").lower(), "ok")
|
||||
Reference in New Issue
Block a user