Add configuration rules, refactor settings management, and enhance import functionality

- Introduced a new configuration file `.cursorrules` to define coding standards, error handling, testing requirements, and project-specific guidelines.
- Refactored `config.py` to implement a `Settings` dataclass for better management of environment variables, improving testability and maintainability.
- Updated the import duty schedule handler to utilize session management with `session_scope`, ensuring proper database session handling.
- Enhanced the import service to streamline the duty schedule import process, improving code organization and readability.
- Added new service layer functions to encapsulate business logic related to group duty pinning and duty schedule imports.
- Updated README documentation to reflect the new configuration structure and improved import functionality.
This commit is contained in:
2026-02-18 12:35:11 +03:00
parent 8697b9e30b
commit 5331fac334
25 changed files with 1032 additions and 397 deletions

View File

@@ -1,59 +1,78 @@
"""FastAPI app: /api/duties and static webapp."""
import logging
import re
from pathlib import Path
from typing import Annotated, Generator
import config
from fastapi import FastAPI, Header, HTTPException, Query, Request
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from db.session import session_scope
from db.repository import get_duties
from db.schemas import DutyWithUser, CalendarEvent
from api.telegram_auth import validate_init_data_with_reason
from api.calendar_ics import get_calendar_events
from utils.dates import validate_date_range
log = logging.getLogger(__name__)
# ISO date YYYY-MM-DD
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _validate_duty_dates(from_date: str, to_date: str) -> None:
"""Raise HTTPException 400 if dates are invalid or from_date > to_date."""
if not _DATE_RE.match(from_date) or not _DATE_RE.match(to_date):
raise HTTPException(
status_code=400,
detail="Параметры from и to должны быть в формате YYYY-MM-DD",
)
if from_date > to_date:
raise HTTPException(
status_code=400,
detail="Дата from не должна быть позже to",
)
try:
validate_date_range(from_date, to_date)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
def _fetch_duties_response(from_date: str, to_date: str) -> list[DutyWithUser]:
"""Fetch duties in range and return list of DutyWithUser. Uses config.DATABASE_URL."""
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]:
"""FastAPI dependency: validate from_date/to_date and return (from_date, to_date). Raises 400 if invalid."""
_validate_duty_dates(from_date, to_date)
return (from_date, to_date)
def get_db_session() -> Generator[Session, None, None]:
"""FastAPI dependency: yield a DB session from session_scope."""
with session_scope(config.DATABASE_URL) as session:
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
]
yield session
def require_miniapp_username(
request: Request,
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
) -> str:
"""FastAPI dependency: return authenticated username or raise 403."""
return get_authenticated_username(request, x_telegram_init_data)
def _fetch_duties_response(
session: Session, from_date: str, to_date: str
) -> list[DutyWithUser]:
"""Fetch duties in range and return list of 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
]
def _auth_error_detail(auth_reason: str) -> str:
@@ -131,33 +150,30 @@ app.add_middleware(
@app.get("/api/duties", response_model=list[DutyWithUser])
def list_duties(
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"),
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
session: Session = Depends(get_db_session),
) -> list[DutyWithUser]:
_validate_duty_dates(from_date, to_date)
from_date_val, to_date_val = dates
log.info(
"GET /api/duties from %s, has initData: %s",
request.client.host if request.client else "?",
bool((x_telegram_init_data or "").strip()),
)
get_authenticated_username(request, x_telegram_init_data)
return _fetch_duties_response(from_date, to_date)
return _fetch_duties_response(session, from_date_val, to_date_val)
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
def list_calendar_events(
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"),
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
) -> list[CalendarEvent]:
_validate_duty_dates(from_date, to_date)
get_authenticated_username(request, x_telegram_init_data)
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, to_date=to_date)
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]