Serve conferences in iCalendar format.

This commit is contained in:
Edward Betts 2025-11-14 15:40:05 +00:00
parent 5eab5361b2
commit 7e2b79c672
3 changed files with 162 additions and 60 deletions

View file

@ -3,6 +3,7 @@
"""Web page to show upcoming events."""
import decimal
import hashlib
import inspect
import json
import operator
@ -11,7 +12,7 @@ import sys
import time
import traceback
from collections import defaultdict
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
import flask
import UniAuth.auth
@ -29,7 +30,7 @@ import agenda.thespacedevs
import agenda.trip
import agenda.trip_schengen
import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
from agenda import ical, calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip
app = flask.Flask(__name__)
@ -327,6 +328,86 @@ def build_conference_list() -> list[StrDict]:
return items
def _conference_uid(conf: StrDict) -> str:
"""Generate deterministic UID for conference events."""
start = agenda.utils.as_date(conf["start"])
raw = f"conference|{start.isoformat()}|{conf.get('name','unknown')}"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"conference-{digest}@agenda-codex"
def _conference_location(conf: StrDict) -> str | None:
"""Build conference location string."""
parts: list[str] = []
venue = conf.get("venue")
location = conf.get("location")
if isinstance(venue, str) and venue.strip():
parts.append(venue.strip())
if isinstance(location, str) and location.strip():
parts.append(location.strip())
if country_code := conf.get("country"):
country = agenda.get_country(country_code)
if country:
parts.append(country.name)
return ", ".join(parts) if parts else None
def _conference_description(conf: StrDict) -> str:
"""Build textual description for conferences."""
lines: list[str] = []
if topic := conf.get("topic"):
lines.append(f"Topic: {topic}")
if venue := conf.get("venue"):
lines.append(f"Venue: {venue}")
if address := conf.get("address"):
lines.append(f"Address: {address}")
if url := conf.get("url"):
lines.append(f"URL: {url}")
status_bits: list[str] = []
if conf.get("going"):
status_bits.append("attending")
if conf.get("speaking"):
status_bits.append("speaking")
if status_bits:
lines.append(f"Status: {', '.join(status_bits)}")
return "\n".join(lines) if lines else "Conference"
def build_conference_ical(items: list[StrDict]) -> bytes:
"""Build iCalendar feed for all conferences."""
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Agenda Codex//Conferences//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Conferences",
]
generated = datetime.now(tz=timezone.utc)
for conf in items:
start_date = agenda.utils.as_date(conf["start"])
end_date = agenda.utils.as_date(conf["end"])
end_exclusive = end_date + timedelta(days=1)
lines.append("BEGIN:VEVENT")
ical.append_property(lines, "UID", _conference_uid(conf))
ical.append_property(lines, "DTSTAMP", ical.format_datetime_utc(generated))
ical.append_property(lines, "DTSTART;VALUE=DATE", ical.format_date(start_date))
ical.append_property(lines, "DTEND;VALUE=DATE", ical.format_date(end_exclusive))
summary = ical.escape_text(f"conference: {conf['name']}")
ical.append_property(lines, "SUMMARY", summary)
description = ical.escape_text(_conference_description(conf))
ical.append_property(lines, "DESCRIPTION", description)
if location := _conference_location(conf):
ical.append_property(lines, "LOCATION", ical.escape_text(location))
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
ical_text = "\r\n".join(lines) + "\r\n"
return ical_text.encode("utf-8")
@app.route("/conference")
def conference_list() -> str:
"""Page showing a list of conferences."""
@ -363,6 +444,16 @@ def past_conference_list() -> str:
)
@app.route("/conference/ical")
def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed."""
items = build_conference_list()
ical_data = build_conference_ical(items)
response = flask.Response(ical_data, mimetype="text/calendar")
response.headers["Content-Disposition"] = "inline; filename=conferences.ics"
return response
@app.route("/accommodation")
def accommodation_list() -> str:
"""Page showing a list of past, present and future accommodation."""