Implement external calendar integration and enhance API functionality

- Added support for fetching and parsing external ICS calendars, allowing events to be displayed on the duty grid.
- Introduced a new API endpoint `/api/calendar-events` to retrieve calendar events within a specified date range.
- Updated configuration to include `EXTERNAL_CALENDAR_ICS_URL` for specifying the ICS calendar URL.
- Enhanced the web application to visually indicate days with events and provide event summaries on hover.
- Improved documentation in the README to include details about the new calendar integration and configuration options.
- Updated tests to cover the new calendar functionality and ensure proper integration.
This commit is contained in:
2026-02-17 20:58:59 +03:00
parent 4e6756025d
commit bf9fc59a3f
12 changed files with 366 additions and 30 deletions

View File

@@ -11,8 +11,9 @@ from fastapi.staticfiles import StaticFiles
from db.session import session_scope
from db.repository import get_duties
from db.schemas import DutyWithUser
from db.schemas import DutyWithUser, CalendarEvent
from api.telegram_auth import validate_init_data_with_reason
from api.calendar_ics import get_calendar_events
log = logging.getLogger(__name__)
@@ -132,6 +133,46 @@ def list_duties(
return _fetch_duties_response(from_date, to_date)
def _require_same_auth(
request: Request,
x_telegram_init_data: str | None,
) -> None:
"""Raise HTTPException 403 if not allowed (same logic as list_duties)."""
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:
return
log.warning("calendar-events: 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("calendar-events: 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("calendar-events: username not in allowlist")
raise HTTPException(status_code=403, detail="Доступ запрещён")
@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"),
) -> list[CalendarEvent]:
_validate_duty_dates(from_date, to_date)
_require_same_auth(request, x_telegram_init_data)
url = config.EXTERNAL_CALENDAR_ICS_URL
if not url:
return []
events = get_calendar_events(url, from_date=from_date, to_date=to_date)
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
webapp_path = Path(__file__).resolve().parent.parent / "webapp"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

124
api/calendar_ics.py Normal file
View 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)