120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""iCalendar feed of the rota, so members can subscribe from any calendar app.
|
||
|
||
Events use floating local times (`DTSTART:20261017T203000`): the rota is read in
|
||
the bar's own timezone, and every client resolves a floating time against the
|
||
device it is viewed on, which keeps the feed free of a timezone database.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, time, timedelta
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session, selectinload
|
||
|
||
from .config import settings
|
||
from .i18n import day_long, day_numeric_long, window_label
|
||
from .models import Assignment, Schedule, User
|
||
|
||
# How much of the rota the feed publishes: recent history and the next months.
|
||
MONTHS_BACK = 1
|
||
MONTHS_FORWARD = 6
|
||
|
||
|
||
def _stamp(moment: datetime) -> str:
|
||
return moment.strftime("%Y%m%dT%H%M%S")
|
||
|
||
|
||
def _fold(line: str) -> list[str]:
|
||
"""RFC 5545 folds long lines at 75 octets."""
|
||
raw = line.encode("utf-8")
|
||
if len(raw) <= 75:
|
||
return [line]
|
||
chunks = [raw[:75]]
|
||
rest = raw[75:]
|
||
while rest:
|
||
chunks.append(rest[:74])
|
||
rest = rest[74:]
|
||
return [chunks[0].decode("utf-8")] + [" " + c.decode("utf-8") for c in chunks[1:]]
|
||
|
||
|
||
def _escape(value: str) -> str:
|
||
return (
|
||
value.replace("\\", "\\\\")
|
||
.replace(";", "\\;")
|
||
.replace(",", "\\,")
|
||
.replace("\n", "\\n")
|
||
)
|
||
|
||
|
||
def _event(assignment: Assignment, person: User | None) -> list[str]:
|
||
schedule = assignment.schedule
|
||
start = datetime.combine(assignment.date, schedule.start_time)
|
||
end = datetime.combine(assignment.date, schedule.end_time)
|
||
if end <= start: # a shift that runs past midnight
|
||
end += timedelta(days=1)
|
||
summary = f"{schedule.name} · {window_label(schedule.start_time, schedule.end_time)}"
|
||
description = [
|
||
day_long(assignment.date),
|
||
f"{schedule.start_time:%H:%M}–{schedule.end_time:%H:%M}",
|
||
]
|
||
if person is not None:
|
||
description.append(f"Pessoa: {person.name}")
|
||
description.append(settings.app_name)
|
||
return [
|
||
"BEGIN:VEVENT",
|
||
f"UID:assignment-{assignment.id}@{_host()}",
|
||
f"DTSTAMP:{_stamp(datetime.utcnow())}",
|
||
f"DTSTART:{start:%Y%m%dT%H%M%S}",
|
||
f"DTEND:{end:%Y%m%dT%H%M%S}",
|
||
f"SUMMARY:{_escape(summary)}",
|
||
f"DESCRIPTION:{_escape(chr(10).join(description))}",
|
||
f"LOCATION:{_escape(settings.app_name)}",
|
||
"TRANSP:TRANSPARENT",
|
||
"END:VEVENT",
|
||
]
|
||
|
||
|
||
def _host() -> str:
|
||
return (settings.base_url.split("//")[-1] or "localhost").split("/")[0]
|
||
|
||
|
||
def build_feed(db: Session, person: User | None, name: str) -> str:
|
||
"""The whole rota, or only the shifts of one person."""
|
||
today = date.today()
|
||
first = (today.replace(day=1) - timedelta(days=31 * MONTHS_BACK)).replace(day=1)
|
||
last = (today.replace(day=1) + timedelta(days=32 * MONTHS_FORWARD)).replace(day=1) - timedelta(days=1)
|
||
|
||
schedules = db.scalars(select(Schedule).where(Schedule.is_active.is_(True))).all()
|
||
assignments = db.scalars(
|
||
select(Assignment)
|
||
.where(
|
||
Assignment.date >= first,
|
||
Assignment.date <= last,
|
||
Assignment.schedule_id.in_([s.id for s in schedules]),
|
||
)
|
||
.options(selectinload(Assignment.schedule), selectinload(Assignment.user))
|
||
.order_by(Assignment.date)
|
||
).all()
|
||
if person is not None:
|
||
assignments = [a for a in assignments if a.user_id == person.id]
|
||
|
||
lines = [
|
||
"BEGIN:VCALENDAR",
|
||
"VERSION:2.0",
|
||
f"PRODID:-//{_escape(settings.app_name)}//Rota//PT",
|
||
"CALSCALE:GREGORIAN",
|
||
"METHOD:PUBLISH",
|
||
f"X-WR-CALNAME:{_escape(name)}",
|
||
f"X-WR-CALDESC:{_escape(f'{settings.app_name} — {day_numeric_long(first)} a {day_numeric_long(last)}')}",
|
||
"REFRESH-INTERVAL;VALUE=DURATION:PT12H",
|
||
"X-PUBLISHED-TTL:PT12H",
|
||
]
|
||
for assignment in assignments:
|
||
lines.extend(_event(assignment, assignment.user))
|
||
lines.append("END:VCALENDAR")
|
||
folded: list[str] = []
|
||
for line in lines:
|
||
folded.extend(_fold(line))
|
||
return "\r\n".join(folded) + "\r\n"
|