diff --git a/.env.example b/.env.example index a1f44f9..afc129a 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ ADMIN_USERNAMES=admin1,admin2 # Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production). # MINI_APP_SKIP_AUTH=1 + +# Optional: URL of a public ICS calendar (e.g. holidays). Days from this calendar are highlighted on the duty grid; click "i" for summary. +# EXTERNAL_CALENDAR_ICS_URL=https://example.com/holidays.ics diff --git a/README.md b/README.md index 9109c07..b014138 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co - `MINI_APP_SKIP_AUTH` – Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure). - `INIT_DATA_MAX_AGE_SECONDS` – Reject Telegram initData older than this (e.g. `86400` = 24h). `0` = disabled (default). - `CORS_ORIGINS` – Comma-separated allowed origins for CORS, or leave unset for `*`. + - `EXTERNAL_CALENDAR_ICS_URL` – URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap «i» on a cell to see the event summary. Empty = no external calendar. ## Run diff --git a/api/app.py b/api/app.py index 9150982..9e49dd8 100644 --- a/api/app.py +++ b/api/app.py @@ -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") diff --git a/api/calendar_ics.py b/api/calendar_ics.py new file mode 100644 index 0000000..39da214 --- /dev/null +++ b/api/calendar_ics.py @@ -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) diff --git a/config.py b/config.py index 64d3443..3cde1dd 100644 --- a/config.py +++ b/config.py @@ -41,6 +41,9 @@ CORS_ORIGINS = ( else ["*"] ) +# Optional: URL of a public ICS calendar (e.g. holidays). Empty = no external calendar; /api/calendar-events returns []. +EXTERNAL_CALENDAR_ICS_URL = os.getenv("EXTERNAL_CALENDAR_ICS_URL", "").strip() + def is_admin(username: str) -> bool: """True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES.""" diff --git a/db/models.py b/db/models.py index 7f879db..72101e0 100644 --- a/db/models.py +++ b/db/models.py @@ -32,7 +32,8 @@ class Duty(Base): user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id"), nullable=False ) - start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 - end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 + # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z) + start_at: Mapped[str] = mapped_column(Text, nullable=False) + end_at: Mapped[str] = mapped_column(Text, nullable=False) user: Mapped["User"] = relationship("User", back_populates="duties") diff --git a/db/repository.py b/db/repository.py index a0539d7..b717e9a 100644 --- a/db/repository.py +++ b/db/repository.py @@ -40,7 +40,12 @@ def get_duties( from_date: str, to_date: str, ) -> list[tuple[Duty, str]]: - """Return list of (Duty, full_name) overlapping the given date range (ISO date strings).""" + """Return list of (Duty, full_name) overlapping the given date range. + + from_date/to_date are YYYY-MM-DD (first/last day of month in client's local calendar). + Duty.start_at and end_at are stored in UTC (ISO 8601 with Z); lexicographic comparison + with date strings yields correct overlap. + """ q = ( session.query(Duty, User.full_name) .join(User, Duty.user_id == User.id) @@ -55,6 +60,7 @@ def insert_duty( start_at: str, end_at: str, ) -> Duty: + """Create a duty. start_at and end_at must be UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).""" duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at) session.add(duty) session.commit() diff --git a/db/schemas.py b/db/schemas.py index 9e048eb..6fdd654 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -23,8 +23,8 @@ class UserInDb(UserBase): class DutyBase(BaseModel): user_id: int - start_at: str # ISO 8601 - end_at: str # ISO 8601 + start_at: str # UTC, ISO 8601 with Z + end_at: str # UTC, ISO 8601 with Z class DutyCreate(DutyBase): @@ -43,3 +43,10 @@ class DutyWithUser(DutyInDb): full_name: str model_config = ConfigDict(from_attributes=True) + + +class CalendarEvent(BaseModel): + """External calendar event (e.g. holiday) for a single day.""" + + date: str # YYYY-MM-DD + summary: str diff --git a/requirements.txt b/requirements.txt index 5d63ab2..f0c1c0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ uvicorn[standard]>=0.32,<1.0 sqlalchemy>=2.0,<3.0 alembic>=1.14,<2.0 pydantic>=2.0,<3.0 +icalendar>=5.0,<6.0 diff --git a/tests/test_app.py b/tests/test_app.py index 7bc2a22..3614200 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -92,8 +92,8 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): { "id": 1, "user_id": 10, - "start_at": "2025-01-15T09:00:00", - "end_at": "2025-01-15T18:00:00", + "start_at": "2025-01-15T09:00:00Z", + "end_at": "2025-01-15T18:00:00Z", "full_name": "Иван Иванов", } ] diff --git a/webapp/app.js b/webapp/app.js index d34028b..d14dd5d 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -20,8 +20,12 @@ const prevBtn = document.getElementById("prevMonth"); const nextBtn = document.getElementById("nextMonth"); - function isoDate(d) { - return d.toISOString().slice(0, 10); + /** YYYY-MM-DD in local time (for calendar keys, "today", request range). */ + function localDateString(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return y + "-" + m + "-" + day; } function firstDayOfMonth(d) { @@ -112,16 +116,21 @@ dutyListEl.hidden = false; } + function buildFetchOptions(initData) { + var headers = {}; + if (initData) headers["X-Telegram-Init-Data"] = initData; + var controller = new AbortController(); + var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS); + return { headers: headers, signal: controller.signal, timeoutId: timeoutId }; + } + async function fetchDuties(from, to) { const base = window.location.origin; const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); const initData = getInitData(); - const headers = {}; - if (initData) headers["X-Telegram-Init-Data"] = initData; - var controller = new AbortController(); - var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS); + const opts = buildFetchOptions(initData); try { - var res = await fetch(url, { headers: headers, signal: controller.signal }); + var res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (res.status === 403) { var detail = "Доступ запрещён"; try { @@ -142,35 +151,113 @@ } throw e; } finally { - clearTimeout(timeoutId); + clearTimeout(opts.timeoutId); } } - function renderCalendar(year, month, dutiesByDate) { + /** Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403 (caller uses duties 403). */ + async function fetchCalendarEvents(from, to) { + const base = window.location.origin; + const url = base + "/api/calendar-events?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); + const initData = getInitData(); + const opts = buildFetchOptions(initData); + try { + var res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + if (!res.ok) return []; + return res.json(); + } catch (e) { + return []; + } finally { + clearTimeout(opts.timeoutId); + } + } + + /** Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local date for calendar grid. */ + function calendarEventsByDate(events) { + const byDate = {}; + (events || []).forEach(function (e) { + var utcMidnight = new Date(e.date + "T00:00:00Z"); + var key = localDateString(utcMidnight); + if (!byDate[key]) byDate[key] = []; + if (e.summary) byDate[key].push(e.summary); + }); + return byDate; + } + + function renderCalendar(year, month, dutiesByDate, calendarEventsByDate) { const first = firstDayOfMonth(new Date(year, month, 1)); const last = lastDayOfMonth(new Date(year, month, 1)); const start = getMonday(first); - const today = isoDate(new Date()); + const today = localDateString(new Date()); + calendarEventsByDate = calendarEventsByDate || {}; calendarEl.innerHTML = ""; let d = new Date(start); const cells = 42; for (let i = 0; i < cells; i++) { - const key = isoDate(d); + const key = localDateString(d); const isOther = d.getMonth() !== month; const dayDuties = dutiesByDate[key] || []; const isToday = key === today; + const eventSummaries = calendarEventsByDate[key] || []; + const hasEvent = eventSummaries.length > 0; const cell = document.createElement("div"); - cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : ""); + cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : ""); cell.innerHTML = "" + d.getDate() + "" + - (dayDuties.length ? "" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "" : ""); + (dayDuties.length ? "" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "" : "") + + (hasEvent ? "" : ""); calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } monthTitleEl.textContent = MONTHS[month] + " " + year; + bindInfoButtonTooltips(); + } + + function bindInfoButtonTooltips() { + var hintEl = document.getElementById("calendarEventHint"); + if (!hintEl) { + hintEl = document.createElement("div"); + hintEl.id = "calendarEventHint"; + hintEl.className = "calendar-event-hint"; + hintEl.setAttribute("role", "tooltip"); + hintEl.hidden = true; + document.body.appendChild(hintEl); + } + calendarEl.querySelectorAll(".info-btn").forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.stopPropagation(); + var summary = btn.getAttribute("data-summary") || ""; + if (hintEl.hidden || hintEl.textContent !== summary) { + hintEl.textContent = summary; + hintEl.hidden = false; + var rect = btn.getBoundingClientRect(); + hintEl.style.left = (rect.left + window.scrollX) + "px"; + hintEl.style.top = (rect.top + window.scrollY - 4) + "px"; + hintEl.dataset.active = "1"; + } else { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + } + }); + }); + if (!document._calendarHintBound) { + document._calendarHintBound = true; + document.addEventListener("click", function () { + if (hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + } + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + } + }); + } } function renderDutyList(duties) { @@ -180,7 +267,7 @@ } const grouped = {}; duties.forEach(function (d) { - const date = d.start_at.slice(0, 10); + const date = localDateString(new Date(d.start_at)); if (!grouped[date]) grouped[date] = []; grouped[date].push(d); }); @@ -190,8 +277,10 @@ const list = grouped[date]; html += "