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

@@ -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

View File

@@ -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

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)

View File

@@ -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."""

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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": "Иван Иванов",
}
]

View File

@@ -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 =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "");
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "") +
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
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 += "<h2>" + date + "</h2>";
list.forEach(function (d) {
const start = d.start_at.slice(11, 16);
const end = d.end_at.slice(11, 16);
const startDate = new Date(d.start_at);
const endDate = new Date(d.end_at);
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
});
});
@@ -204,15 +293,20 @@
return div.innerHTML;
}
/** Group duties by local date (start_at/end_at are UTC). */
function dutiesByDate(duties) {
const byDate = {};
duties.forEach(function (d) {
const start = new Date(d.start_at);
const end = new Date(d.end_at);
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
const key = isoDate(t);
const endLocal = localDateString(end);
let t = new Date(start);
while (true) {
const key = localDateString(t);
if (!byDate[key]) byDate[key] = [];
byDate[key].push(d);
if (key === endLocal) break;
t.setDate(t.getDate() + 1);
}
});
return byDate;
@@ -267,12 +361,16 @@
setNavEnabled(false);
loadingEl.classList.remove("hidden");
errorEl.hidden = true;
const from = isoDate(firstDayOfMonth(current));
const to = isoDate(lastDayOfMonth(current));
const from = localDateString(firstDayOfMonth(current));
const to = localDateString(lastDayOfMonth(current));
try {
const duties = await fetchDuties(from, to);
const dutiesPromise = fetchDuties(from, to);
const eventsPromise = fetchCalendarEvents(from, to);
const duties = await dutiesPromise;
const events = await eventsPromise;
const byDate = dutiesByDate(duties);
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
const calendarByDate = calendarEventsByDate(events);
renderCalendar(current.getFullYear(), current.getMonth(), byDate, calendarByDate);
renderDutyList(duties);
} catch (e) {
if (e.message === "ACCESS_DENIED") {

View File

@@ -105,6 +105,57 @@ body {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, rgba(187, 154, 247, 0.15) 100%);
border: 1px solid rgba(187, 154, 247, 0.35);
}
.day {
position: relative;
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.calendar-event-hint {
position: fixed;
z-index: 1000;
max-width: 280px;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
transform: translateY(-100%);
}
.day-duties {
font-size: 0.6rem;
color: var(--duty);