Files
crr-shifts/app/calendar.py
T

120 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"