Apache comdev

Clone this repo:
  1. 92b168e import hardening: recurring events are not supported (yet), make the poster aware by Daniel Gruno · 17 hours ago main
  2. c998f5a Add ics import features by Daniel Gruno · 17 hours ago
  3. ce87b84 add openapi/swagger components by Daniel Gruno · 18 hours ago
  4. 4c0355f More timezone enhancements plus...embeddability by Daniel Gruno · 20 hours ago
  5. e85184c rework base path logic so we can also deploy in a sub-directory by Daniel Gruno · 21 hours ago

ASF Community Calendar

A shared calendar for the Apache Software Foundation. It holds three kinds of events, each with its own rules about who may see and change them:

  • Personal events belong to one person. Nobody else can see them.
  • Project events belong to a project. Public ones are visible to everybody, including people who are not logged in; private ones are visible only to the project's committee. Project members create and edit them.
  • Foundation events belong to the ASF as a whole. Public ones are visible to everybody; private ones are visible only to foundation members. Only members can create or edit them.

Every event has a shortlink, something like https://calendar.apache.org/e/Qvj3wfyH, that opens the event directly. Public events also carry OpenGraph tags on that page, so pasting a shortlink into chat or email shows the event title rather than a bare URL.

The web UI has day, week, month, year and agenda views, filters for calendars, projects and visibility, a text search, and an iCalendar export so events can be pulled into whatever calendar application you already use. When you are logged in it opens showing everything you have access to: your own events, every project you belong to, and the foundation calendar.

It is timezone aware in both directions. When you add an event you say which timezone the times are in, and everybody else sees them on their own clock. The switch at the top of the page draws the whole calendar in your browser‘s timezone or in any of the major timezones, and you can pin up to three more alongside it to see an event in the organiser’s time and in yours at once. There is also a help page, at /help, that explains the views, the filters, the timezone controls and exactly what each kind of user can see and do.

The backend is asfquart (Quart with ASF conventions layered on top), which provides the OAuth login against oauth.apache.org and the project and committee membership that the access rules are built on. The frontend is Svelte 5 built with Vite.

Contents

How access works

After an OAuth login, asfquart puts the user's affiliations into the session: session.projects lists the projects they commit to, session.committees lists the committees (PMCs) they sit on, and session.isMember says whether they are a foundation member. Those three facts decide everything.

CategoryVisibilityWho can read itWho can create, edit and delete
personalprivatethe ownerthe owner
projectpubliceverybody, including anonymoussession.projects for that project
projectprivatesession.committees for that projectsession.committees for that project
foundationpubliceverybody, including anonymousfoundation members
foundationprivatefoundation membersfoundation members

A few details worth knowing:

  • Personal events are always private. If a client asks for a public personal event, it is stored as private anyway.
  • Committee membership implies project membership. A PMC member who is somehow missing from the committer list can still manage their project's public events.
  • Any project member can edit any of their project's events, not only the ones they created. The owner field records who added an event but does not restrict who can change it.
  • An edit has to pass the check twice: once against the event as it is stored, and once against the event as it would be afterwards. That stops somebody moving an event into a calendar they cannot write to, or out of one they cannot write to.
  • The membership test uses asfquart's own Requirements.member, so this app agrees with whatever @asfquart.auth.require({R.member}) would decide elsewhere.

The rules live in one place, backend/asfcalendar/permissions.py, as plain functions over (event, session). backend/tests/test_permissions.py walks through every combination of category, visibility, project and user. The same table, written for users rather than for developers, is in the app itself at /help, along with a note about what the current visitor's own account allows.

The database query that lists events applies the same rules as a SQL WHERE clause, so listing a month does not mean loading every event in the table and filtering in Python. The API then runs can_view over the results anyway. That is deliberate belt and braces: a mistake in the SQL should not become a disclosure bug, and there is a test asserting the two agree.

Requirements

  • Python 3.11 or newer
  • uv for the backend
  • Node.js 20 or newer for the frontend

Nothing else. Events live in a SQLite file, so there is no database server to set up.

Installing

git clone https://github.com/apache/comdev-calendar.git
cd comdev-calendar

# Backend dependencies, into .venv
uv sync --all-groups

# Frontend dependencies
cd frontend && npm ci && cd ..

# Configuration
cp config.yaml.example config.yaml

Or, if you have make:

make install
cp config.yaml.example config.yaml

Configuration

Configuration lives in config.yaml in the repository root, in the YAML format asfquart reads. config.yaml.example documents every key; all of them are optional and the defaults are fine for local work.

server:
  host: 127.0.0.1
  port: 8080
  # Absolute base URL of the deployment, used to build shortlinks. Leave it
  # empty and the base is taken from the incoming request instead.
  base_url: "https://calendar.apache.org"

database:
  path: "calendar.sqlite3"

app:
  title: "ASF Community Calendar"
  frontend_dist: "frontend/dist"
  # Which clock the calendar opens on for someone who has not chosen:
  # "local" or "utc".
  default_display_zone: "local"
  shortlink_prefix: "/e"

oauth:
  uri: "/auth"

debug: false

# Read by asfquart itself: maximum session lifetime in seconds, 0 for no limit.
MAX_SESSION_AGE: 0

Relative paths are resolved against the directory holding config.yaml.

Two files appear at runtime and should not be committed (both are in .gitignore):

  • calendar.sqlite3 - the event database.
  • apptoken.txt - the key asfquart uses to sign session cookies. It is created with mode 0600 on first start. Delete it and everybody is logged out.

Running it

Development

You want two processes. The backend serves the API and handles OAuth; the Vite dev server serves the UI with hot reloading and proxies /api and /auth through to the backend, so the browser sees a single origin and session cookies work normally.

# Terminal 1
uv run asf-calendar --reload

# Terminal 2
cd frontend && npm run dev

Then open http://localhost:5173.

If your backend is somewhere other than http://127.0.0.1:8080, tell Vite:

BACKEND=http://127.0.0.1:9000 npm run dev

Useful backend flags:

-c, --config PATH   path to the YAML config (default: ./config.yaml)
    --host HOST     bind address, overriding the config
    --port PORT     port, overriding the config
    --reload        restart when a source file changes
    --debug         Quart debug mode, with tracebacks in responses
    --access-log    log every request
-v, --verbose       log at DEBUG level

Production

frontend/dist is committed to the repository, kept up to date by CI (see Committed build output). A deployment is therefore a checkout and the backend, with no Node involved:

uv run asf-calendar

The backend serves frontend/dist as static files and falls back to index.html for client-side routes. If you are working from a branch where the build is stale, or you have just changed the UI locally, rebuild it yourself:

cd frontend && npm ci && npm run build && cd ..

The server is hypercorn. To run it directly, for instance under systemd with your own worker settings:

uv run hypercorn --bind 0.0.0.0:8080 --workers 1 'asfcalendar.app:create_app()'

Use one worker. asfquart keeps the pending OAuth states in a process-local dictionary, so with several workers a login started on one and completed on another will fail. This is a known asfquart limitation.

As a service

pipservice-comdev-calendar.service is a systemd unit for the ASF's pipservice deployment pattern. It runs uv run asf-calendar from the checkout, which finds pyproject.toml and config.yaml by walking up from the package directory.

If you move things around, or install the package rather than running it from a checkout, pin the config explicitly rather than relying on that: either pass --config /path/to/config.yaml, or set ASF_CALENDAR_CONFIG in the unit. The same environment variable is what --reload uses to hand the config path to its worker processes.

Behind httpd or another reverse proxy, pass the original Host header through. asfquart builds the OAuth callback URL from it, and a rewritten host sends users back to the wrong place after login:

ProxyPreserveHost On
ProxyPass        / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/

That is the layout for a calendar at the root of its host. To serve it from a sub-directory instead, see Serving from a sub-directory.

Session cookies are set with Secure, so the deployment has to be served over HTTPS. See Debugging if logins seem to work but do not stick.

Serving from a sub-directory

The calendar normally sits at the root of a host, at https://calendar.apache.org/. It can be mounted in a sub-directory instead by setting one config key:

server:
  base_path: "/calendar"

Everything moves under it in one go: the pages, the whole /api surface, the OAuth endpoint, the static assets and the event shortlinks. A shortlink becomes https://calendar.apache.org/calendar/e/AbCd2345, the login URL becomes /calendar/auth?login=/calendar/, and the callback URL asfquart sends to the OAuth provider comes back to /calendar/auth as well, so the whole login round-trip stays inside the mount point.

The reverse proxy has to pass the prefix through, not strip it. The app answers on the prefixed paths, so:

ProxyPreserveHost On
ProxyPass        /calendar/ http://127.0.0.1:8080/calendar/
ProxyPassReverse /calendar/ http://127.0.0.1:8080/calendar/

Note the /calendar/ on both sides. Getting this wrong is the one likely mistake, so the app watches for it: a request that arrives outside the mount point gets a 404 saying which URL the calendar answers on and printing the ProxyPass line it expects. A plain GET / redirects to /calendar/.

How it works, and why the build is not involved

The obvious way to do this in a Vite app is base: "/calendar/" at build time. That would be wrong here, because frontend/dist is committed to the repository and shared between deployments - baking a prefix in would tie one build to one mount point.

Instead the prefix is applied when a page is served:

  • Vite is configured with base: "./", so the built index.html refers to its script, stylesheet and favicon relatively. Nothing in dist mentions an absolute path.
  • index.html ships with <base href="/"> as the first thing in its <head>. The backend rewrites that one attribute to the configured mount point on the way out. The browser then resolves those relative URLs against it, which is also what makes them work on a nested route such as /calendar/e/AbCd2345, where resolving against the document's own directory would look for the assets under /calendar/e/.
  • The frontend reads the mount point back out of document.baseURI and puts it in front of its own API calls, its client-side routes and its pushState navigation. That lives in frontend/src/lib/base.ts.

So the same committed build serves both layouts, and switching between them is a config edit and a restart.

One thing to keep in mind if you are editing the frontend: because <base href> is set, every relative URL in the page resolves against the mount point, not against the current route. Build paths with the helpers in base.ts rather than writing /api/..., /auth?login=/ or /icon.png by hand, or they will break the moment somebody mounts the app in a sub-directory:

HelperFor
apiUrl("/events")an API endpoint
appPath("/help")a client-side route, including pushState targets
assetUrl("icon.png")a file shipped in the build
loginUrl()the OAuth login link
logoutUrl()the OAuth logout link
withoutBase(path)turning location.pathname back into an app route

The login and logout links are a special case worth knowing about. /api/session returns the real URLs, because a deployment can move the endpoint with the oauth.uri config key, and those always win. loginUrl() and logoutUrl() are the fallbacks used before that request has come back or if it fails - which is precisely when somebody is most likely to be reaching for the login link, so they have to be right too.

Developing against a mounted backend

The Vite dev server always serves the app at the root. If the backend you are proxying to has a base_path set, tell the proxy where to find it:

BASE_PATH=/calendar npm run dev

For everyday work it is simpler to leave base_path empty in your local config.yaml and test the sub-directory setup against the built frontend.

Embedding the agenda elsewhere

The agenda view can be dropped into another site as an iframe: a project's own website can carry a live list of its upcoming events without anybody copying dates about by hand.

<iframe
  src="https://calendar.apache.org/embed/agenda?project=httpd&limit=8&title=Upcoming+httpd+events"
  title="Upcoming httpd events"
  width="100%"
  height="420"
  loading="lazy"
  style="border: 1px solid #d9dee5; border-radius: 6px;"
></iframe>

The embed is a separate, much smaller app than the calendar: no header, no filter panel, no editing, and it asks the backend for nothing but the events it is going to draw. Clicking an event opens its shortlink in a new tab rather than navigating inside the frame.

What it shows

Public events only, always. Session cookies are SameSite=Strict, so a cross-site iframe never carries one and the embed is an anonymous view. This is the intended behaviour rather than a limitation to route around: a page on somebody else's site should not be able to display private project or foundation events, even to somebody who would be allowed to see them on the calendar itself.

Parameters

All optional, all on the query string.

ParameterDefaultWhat it does
project, projectsallproject names; repeat the parameter or comma-separate
category, categoriesallpersonal, project or foundation
q-text search over title, description, location and project
days60how far ahead to look, 1 to 366
limit20how many events to show, 1 to 100
zonelocallocal, or an IANA name such as UTC or Europe/Berlin
title-a heading above the list
showzone1whether to say which clock the times are on
transparent0drop the background so the host page shows through
themeautoauto, light or dark; auto follows the reader's setting
credit1whether to show the “ASF Community Calendar” link at the foot

Numbers outside their range are clamped rather than rejected, and anything unrecognised falls back to the default, so a mistyped URL degrades to a sensible embed instead of an error.

Sizing the frame

An iframe cannot size itself to its content. The embed measures itself and posts its height to the page framing it, so the host can resize:

<script>
  window.addEventListener("message", (event) => {
    // Check the origin: any page can post a message.
    if (event.origin !== "https://calendar.apache.org") return;
    if (event.data?.type !== "asf-calendar-embed:height") return;
    document.querySelector("iframe.asf-calendar").style.height = `${event.data.height}px`;
  });
</script>

The message carries nothing but a pixel height. It is sent with a * target origin, because the embed has no way of knowing who is framing it, which is why the listener above has to check event.origin itself.

If you would rather not run any script, give the iframe a fixed height and a limit that fits it.

Headers the deployment needs

The application does not set framing or CORS headers - that is the reverse proxy's job, and deliberately so, since only the deployment knows who should be allowed to embed it. Nothing in the app forbids framing, so there is nothing to undo; what is needed is permission.

To allow framing, set frame-ancestors on the calendar's responses, and do not send X-Frame-Options: DENY or SAMEORIGIN, which would override it in older browsers:

# Who may embed the calendar. Be specific; 'self' alone blocks other sites.
Header always set Content-Security-Policy "frame-ancestors 'self' https://*.apache.org"
Header always unset X-Frame-Options

Scoping that to the embed route only, so the rest of the calendar stays unframeable:

<Location "/embed/">
    Header always set Content-Security-Policy "frame-ancestors 'self' https://*.apache.org"
    Header always unset X-Frame-Options
</Location>

CORS is not needed for the iframe embed. A framed page fetches its own origin, so no cross-origin request happens. You only need CORS if somebody wants to call /api/events from their own JavaScript and render it themselves:

<Location "/api/">
    Header always set Access-Control-Allow-Origin "https://example.apache.org"
    Header always append Vary "Origin"
</Location>

Two things to get right there:

  • Do not send Access-Control-Allow-Credentials: true. Combined with a permissive origin it would let another site read a logged-in user's private events. Anonymous cross-origin reads are the only thing that should work.
  • Send Vary: Origin if the allowed origin varies, or a cache will hand one site's response to another.

Access-Control-Allow-Origin: * is defensible here, since the anonymous API returns only public events, but it is worth deciding deliberately rather than by default.

Checking it works

# The route serves the app
curl -sI https://calendar.apache.org/embed/agenda | head -1

# The data it will draw, as an anonymous caller sees it
curl -s 'https://calendar.apache.org/api/events?project=httpd&limit=5&sort=start' | jq '.events[].title'

# The framing header is present and permissive enough
curl -sI https://calendar.apache.org/embed/agenda | grep -i -e content-security-policy -e x-frame-options

If the frame comes up blank, look in the browser console: a frame-ancestors refusal is reported there and nowhere else.

Timezones

An event happens at one moment in time, but that moment reads differently on different clocks. The app keeps those two things apart, and the distinction runs all the way through:

  • Instants are what the backend stores. Every event's start and end are epoch seconds in UTC, and the API talks in ISO 8601 UTC. There is no ambiguity about when an event actually is.
  • The event timezone is what the organiser meant when they typed the times in. It is stored alongside the event as an IANA name such as Europe/Berlin, validated against the system tz database. An event form entry of “15:00” in Berlin means 13:00 UTC in July and 14:00 UTC in January, and the app works that out. Changing the timezone in the form keeps the clock reading and moves the instant, which is what an organiser almost always means.
  • The display timezone is the clock the reader wants the calendar drawn on. The switch in the header has two sides: Local, which follows the browser and is labelled with the place it resolves to, and a picker holding the major timezones, which starts on UTC. Choosing either redraws everything - the grid columns, the position of each block in the day, the times on each event, and which day an event falls on. The choice is remembered in the browser; app.default_display_zone decides what a first-time visitor gets, and takes local or any IANA name.
  • Comparison timezones are extra clocks shown next to the display one. Add up to three under “Timezones” in the filter panel and they appear as extra hour columns beside the week and day grids, after the time on each agenda row, and in an event's details. A grid has one time axis, so these are labels rather than extra columns of events - which is all you need to answer “what time is that for me”.

An event‘s details show the headline time on your clock, then a line per other clock worth knowing about: the organiser’s own zone, and each comparison zone you have added. Clocks that read the same as yours are left out rather than repeated.

Zone names such as Europe/Copenhagen are how the tz database identifies a clock, not a claim about where anyone is sitting, so the UI shows the city together with the current abbreviation and offset - “Copenhagen (CEST, UTC+02:00)” - to make it obvious why that particular clock. Where a zone has no letter abbreviation the offset stands on its own, since Intl would otherwise just repeat it back as “GMT+9”.

All-day events are dates, not instants. They are snapped to whole UTC days and have no organiser timezone, exactly as iCalendar treats a VALUE=DATE event. An all-day event on 14 March is on 14 March for everybody, and does not move when the switch is flipped. Their stored end is exclusive: midnight of the day after the last day.

In the iCalendar export, DTSTART and DTEND are always UTC, which every client reads correctly. The organiser's zone rides along as an X-ASF-EVENT-TIMEZONE property for anything that cares.

The display picker offers a short list of well-known zones rather than all ~400 the tz database knows, because it answers “show me this calendar in Tokyo time”. The event form still offers the full list, since an organiser really might be anywhere.

The conversions live in frontend/src/lib/timezone.ts. The trick it uses is worth knowing about if you go reading it: a “wall date” is an ordinary JavaScript Date whose local getters read as the wall clock in some other zone. Converting at the edges means the whole grid arithmetic works unchanged, whichever clock is on display. Offsets for named zones come from Intl.DateTimeFormat, so daylight saving is handled by the browser's own tz data rather than by us.

Importing an iCalendar file

A logged-in user can upload an .ics file and have every event in it added to one calendar. Import appears next to New event once you are signed in.

The form works in two steps, on purpose. Choosing a file shows a preview of what was found, and only then does it ask which calendar the events belong to. That second question is not defaulted: an import can create dozens of events at once, and a wrong default would publish somebody's private meetings or file them under the wrong project. The Import button stays disabled, with a line saying what is still to answer, until the calendar, the visibility and (for a project import) the project have all been chosen.

Which options are offered follows the same rules as anywhere else: a committer is shown their projects but not the private option, a committee member gets both, and the foundation calendar only appears for foundation members.

What the importer does with a file

Files come from Google Calendar, Outlook, Thunderbird and hand-rolled scripts, so the reading is forgiving. Rather than refusing a whole upload over one awkward entry, it repairs what it can and says what it changed - every note appears in the preview before anything is saved:

In the fileWhat happens
DTSTART;TZID=Europe/Berlinkept as the event's organiser timezone
DTSTART;VALUE=DATEan all-day event, whole UTC days
DURATION instead of DTENDused to work out the end
no end at allan hour, or a day for an all-day event
a floating time with no zoneread as UTC, with a note
a title longer than the limittrimmed, with a note
a mailto: or other non-http URLdropped, with a note
RRULEimported as a single occurrence, with a note
RECURRENCE-ID (a moved occurrence)imported as its own event, under the series' UID
STATUS:CANCELLED, or no DTSTARTskipped, and counted in the file's warnings
VTODO, VJOURNALignored; only VEVENT is an event

An event that survives all that still goes through exactly the same validation as one typed in by hand, so nothing gets in through the importer that could not have been created normally. The whole import is one transaction: every event is validated and the permission checked before any of them is written, so a file either lands completely or not at all.

A UID is therefore not unique within an import, and neither is a warning: a series and each of its moved occurrences share one UID, and two entries can raise word-for-word the same note. Nothing that renders or stores a preview may treat either as an identifier.

At most 200 events and 1 MiB per file.

From a script

Two endpoints, both needing a session. POST /api/import/preview reads a file and says what is in it without writing anything; POST /api/import does the import. Either takes the file as the file part of a multipart form, or as the whole request body:

# What is in this file?
curl -s -X POST --data-binary @events.ics \
  -H 'Content-Type: text/calendar' -H 'X-No-Redirect: 1' \
  -b cookies.txt https://calendar.apache.org/api/import/preview | jq

# Import it as public httpd events
curl -s -X POST -b cookies.txt -H 'X-No-Redirect: 1' \
  -F file=@events.ics -F category=project -F project=httpd -F visibility=public \
  https://calendar.apache.org/api/import

When the file is the request body there is nowhere to put the form fields, so they go on the query string instead:

curl -s -X POST --data-binary @events.ics \
  -H 'Content-Type: text/calendar' -H 'X-No-Redirect: 1' -b cookies.txt \
  'https://calendar.apache.org/api/import?category=project&project=httpd'

category is required. visibility defaults to public, the same as POST /api/events - the API keeps the single-event default, and it is the form that insists on an explicit answer. The response carries the created events and any warnings.

The API

Everything under /api speaks JSON, errors included. Anonymous reads are allowed; anything that writes needs a session.

MethodPathWhat it does
GET/api/sessionwho is logged in, plus login and logout URLs
GET/api/calendarswhich calendars this session can read and write
GET/api/eventslist events (see the filters below)
POST/api/eventscreate an event
GET/api/events/<id>one event
PUT, PATCH/api/events/<id>replace an event
DELETE/api/events/<id>delete an event
GET/api/events/<id>.icsone event as iCalendar
GET/api/events.icsa filtered iCalendar feed
GET/api/shortlink/<token>look an event up by its shortlink token
POST/api/import/previewread an .ics file without saving anything
POST/api/importturn an .ics file into events
GET/api/healthzliveness check

Outside /api, the backend serves the built frontend: / for the calendar, /help for the help page, and /e/<token> for an event shortlink. All three are client-side routes, so the backend answers them with the same SPA shell.

GET /api/events accepts:

ParameterMeaning
start, endISO 8601 or epoch seconds; returns events overlapping [start, end)
category, categoriespersonal, project or foundation; repeat or comma-separate
project, projectsproject names; repeat or comma-separate
visibilitypublic or private
ownera uid, or me
qtext search over title, description, location and project
sortstart, -start, title, -title, created, -created
limit, offsetpaging; limit is capped at 2000

A minimal create:

curl -X POST http://localhost:8080/api/events \
  -H 'Content-Type: application/json' \
  -H 'X-No-Redirect: 1' \
  -b cookies.txt \
  -d '{
        "title": "Release party",
        "category": "project",
        "project": "httpd",
        "visibility": "public",
        "start": "2026-04-01T17:00:00Z",
        "end": "2026-04-01T19:00:00Z"
      }'

Timestamps go in and come out as ISO 8601 in UTC. Epoch seconds are accepted on the way in. All-day events are snapped to whole UTC days, and their end is exclusive, matching iCalendar's DTEND.

An event may also carry a timezone, an IANA name such as "Europe/Berlin", recording the clock the organiser entered the times on. It defaults to "UTC", is validated against the tz database, and is forced to "UTC" for all-day events.

It has one effect on parsing: a timestamp sent with no offset is read in the event's timezone rather than in UTC, so the obvious thing works.

{"start": "2026-07-10T15:00:00",      "timezone": "Europe/Berlin"}  ->  13:00Z
{"start": "2026-01-10T15:00:00",      "timezone": "Europe/Berlin"}  ->  14:00Z
{"start": "2026-07-10T15:00:00Z",     "timezone": "Europe/Berlin"}  ->  15:00Z
{"start": "2026-07-10T15:00:00+09:00","timezone": "Europe/Berlin"}  ->  06:00Z

A timestamp that says what offset it is in, or ends in Z, is always taken at face value. Epoch seconds are unaffected. The frontend always sends an absolute UTC instant, so this only matters to anything talking to the API directly.

X-No-Redirect: 1 matters. Without it, asfquart answers an unauthenticated request by redirecting to the OAuth provider, which is right for a browser following a link and useless for a fetch() or a curl. With it you get a JSON 401. The frontend sends it on every request.

Errors look like this, with field present when a particular field was at fault:

{ "error": "'end' must be after 'start'", "field": "end" }

The table above is a summary. The full reference - every parameter, every schema, every status code - is generated from the running service and described in the next section.

API documentation

The API describes itself in OpenAPI 3.1, and the calendar serves an interactive Swagger UI for it.

WhereWhat
API button, or /docsSwagger UI, with “try it out” against this deployment
/api/openapi.jsonthe description, for tooling
/api/openapi.yamlthe same thing, for reading
curl -s https://calendar.apache.org/api/openapi.json | jq '.paths | keys'

# Generate a client
npx @openapitools/openapi-generator-cli generate \
  -i https://calendar.apache.org/api/openapi.json -g python -o ./client

The servers entry is filled in from the host serving the document, including the mount point when the app is in a sub-directory, so “try it out” calls the deployment the reader is already on. Paths in the document itself stay mount-point free, so one description covers either layout.

Requests from the docs page carry X-No-Redirect: 1 and same-origin credentials, so an unauthenticated call returns a readable 401 rather than a redirect, and a logged-in reader can exercise the endpoints that need a session.

How it is kept from going stale

A hand-written API description rots quietly: someone adds a route, nobody updates the YAML, and six months later the docs are lying. This one is built in backend/asfcalendar/openapi.py, in Python, out of the same constants the code enforces - CATEGORIES, VISIBILITIES, MAX_TITLE_LENGTH, SORT_COLUMNS, MAX_LIMIT, the project name pattern, the shortlink alphabet. Raise a field limit and the published schema follows with nothing to remember.

What cannot be derived - prose, and which status codes an endpoint returns - is written out by hand, so backend/tests/test_openapi.py guards it instead:

  • Every route is documented, and nothing is documented that does not exist. Both directions are checked against the application's real url_map, so adding an endpoint without describing it fails the build, and so does leaving a description behind after deleting one.
  • The Event schema matches Event.to_json(), field for field. A new field on the dataclass that nobody documented fails too.
  • Nothing marked readOnly is accepted as input, and the writable schema is a subset of the readable one.
  • The document validates against openapi-spec-validator, at the root and in a sub-directory, both as built and as served.

So the answer to “is this up to date?” is that CI will not let it be otherwise. When you change the API, expect a test to tell you what you forgot.

Swagger UI is vendored, not fetched

swagger-ui-dist is copied into frontend/public/vendor/ by scripts/vendor-swagger.mjs, which runs automatically before npm run dev and npm run build. Nothing is loaded from a CDN, which matters for a deployment with a strict Content-Security-Policy.

It is a static asset rather than a bundled import on purpose. It keeps 1.5MB of third-party JavaScript out of the app bundle and its source map - the calendar itself is still about 110KB - and, because frontend/dist is committed, it means those files only change when Swagger UI is upgraded rather than being rewritten by every UI change. The cost is that dist is about 2.6MB rather than 750KB, most of it in dist/vendor/, added to git once.

To upgrade: npm install -D swagger-ui-dist@latest && npm run vendor in frontend/, then rebuild.

Project layout

backend/
  asfcalendar/
    __main__.py      command line entry point, starts hypercorn
    app.py           builds the Quart app, static files, shortlink pages
    api.py           the /api blueprint
    permissions.py   the access rules, as pure functions
    storage.py       SQLite, including the visibility SQL
    models.py        the Event type and payload validation
    ics.py           iCalendar output
    icsimport.py     reading an uploaded iCalendar file
    openapi.py       the OpenAPI description, built from the real constants
    shortlink.py     shortlink tokens
    config.py        reading config.yaml
  tests/             pytest suite

frontend/
  src/
    App.svelte       state, loading and routing
    components/      Header, TimezoneSwitch, FilterPanel, the five views,
                     EventDialog, ImportDialog, HelpPage, EmbedAgenda, ApiDocs
    lib/
      api.ts         the API client
      base.ts        the deployment's mount point, for sub-directory installs
      dates.ts       date arithmetic and grid maths
      timezone.ts    wall-clock conversions, zone naming, the zone picker
      embed.ts       options for the embeddable agenda
      importing.ts   where uploaded events should land
      events.ts      grouping, overlap layout, colours
      filters.ts     client-side filtering and sorting
      drafts.ts      new and edited events, and a local canEdit
      types.ts       shared types
    tests/           component tests and fixtures
  public/vendor/     Swagger UI, copied from node_modules at build time
  scripts/           that copy step

config.yaml.example  documented configuration
images.png           the site logo; copied to frontend/public/icon.png

Tests

Backend

uv run pytest                       # the whole suite
uv run pytest -k permissions        # one area
uv run pytest --cov=backend/asfcalendar --cov-report=term-missing
uv run mypy                         # strict type checking
uv run ruff check backend           # lint

The suite covers the access rules exhaustively, the storage layer including the agreement between the SQL filter and can_view, payload validation, the iCalendar output, the OpenAPI description (see above), and the HTTP API end to end through Quart's test client.

Sessions in the API tests are set through the real signed session cookie rather than by patching, so they take the same path asfquart does in production. The personas live in backend/tests/conftest.py:

PersonaProjectsCommitteesMember
alicehttpd, tomcat-no
bobhttpdhttpdno
caroltomcattomcatyes
davemaven-no

Between them they cover committer without committee, committee member, foundation member, and complete outsider.

Frontend

cd frontend
npm run test        # vitest, once
npm run test:watch  # vitest, watching
npm run check       # svelte-check: types across .ts and .svelte
npm run coverage

The date and layout maths, the timezone conversions, the filters, the draft handling and the API client are tested as plain functions. The components are rendered into jsdom with Testing Library and driven the way a user would drive them.

The suite pins TZ to UTC so assertions about local time are stable; src/tests/setup.ts does that. That would hide any bug in the display-zone switch, since local and UTC agree there, so src/lib/events.zone.test.ts puts the browser in Asia/Tokyo for its duration and checks that events move to the right day, land in the right place in the grid, and that all-day events do not move at all.

CI

Three GitHub workflows, all path-filtered so a backend change does not start the frontend jobs and vice versa:

  • .github/workflows/backend.yml runs ruff, ruff format --check, mypy, and pytest on Python 3.11, 3.12 and 3.13, then boots the real server and calls it.
  • .github/workflows/frontend.yml runs svelte-check and vitest on Node 20 and 22, builds the UI, and uploads frontend/dist as an artifact. This is the one that runs on pull requests.
  • .github/workflows/build-dist.yml rebuilds frontend/dist on pushes to the default branch and commits the result back if it differs. See below.

Committed build output

frontend/dist is tracked in git rather than ignored, so that deploying the app needs nothing but a checkout and Python. Keeping it honest by hand would be tedious and easy to forget, so build-dist.yml does it: on every push that touches frontend/ it runs npm ci && npm run build, compares the result with what is committed, and pushes a “Rebuild frontend/dist” commit when they differ. When they match, which is the usual case for a backend-only or docs change, it does nothing.

Some details that matter if you are changing that workflow:

  • It cannot set itself off. GitHub does not start new workflow runs from commits pushed with GITHUB_TOKEN. The !frontend/dist/** path exclusion is a second line of defence, for the day somebody swaps in a PAT.
  • The build is reproducible. Vite names its output by content hash, so an unchanged source tree produces byte-identical files and the “did anything change” check is trustworthy rather than approximate.
  • Stale assets are removed. The commit uses git add -A, so the previous hashed filenames go away instead of accumulating.
  • Concurrent pushes are handled. Runs are serialised per branch and an in-flight build is cancelled by a newer push, since its output is obsolete anyway. If a push is still rejected because the branch moved, the workflow replays the build onto the new tip, up to three times.

You will get merge conflicts in frontend/dist if two branches both change the UI. Resolve them by rebuilding rather than by editing: npm run build in frontend/, then git add frontend/dist. Or simply take either side and let CI correct it on the next push to the default branch.

The build includes a source map, which is most of the roughly 750 KB in dist and changes whenever the UI does. It is there because it makes production problems debuggable. If you would rather not carry it in the history, set sourcemap: false in frontend/vite.config.ts.

Debugging

Nothing but a page saying there is no frontend build. The backend could not find frontend/dist/index.html. It is committed to the repository, so this usually means a make clean, a stray rm -rf, or a checkout of a branch from before it was tracked. Run npm run build in frontend/, or use the Vite dev server and open port 5173 instead of 8080. The page tells you which path it looked in.

The deployed UI is not the code you just merged. frontend/dist is committed, and the Build dist workflow updates it on pushes to the default branch. Check that workflow ran and pushed its commit; a deployment from before that commit will still be serving the previous build.

The UI loads but every request fails. Open the browser's network tab. If /api/session returns HTML, the Vite proxy is not reaching the backend; check the backend is running and that BACKEND points at it. If it returns a 502, the backend crashed - look at its terminal.

Login redirects to oauth.apache.org and comes back to the wrong host. asfquart builds the callback URL from the Host header. Behind a proxy, set ProxyPreserveHost On. Locally, use the Vite dev server rather than opening the backend port directly, so everything is on one origin.

Login appears to work but you are still logged out. Session cookies are set Secure, HttpOnly and SameSite=Strict, so the browser will not store them over plain http on a non-localhost host. In development use localhost; in production terminate TLS in front of the app. Secure is relaxed only when the app is constructed with testing=True, which is for the test suite.

An API call redirects to /auth instead of returning JSON. Send X-No-Redirect: 1. asfquart's redirect-to-login behaviour is deliberate for browsers and unhelpful for scripts.

A 403 with a message about a project or committee. That is the access rules working. GET /api/session shows what asfquart thinks you are a member of, and GET /api/calendars shows what that entitles you to. If the lists look wrong, the problem is in LDAP or the OAuth response, not here; logging out and back in picks up changed affiliations.

Wanting to see the rules decide something in isolation. They are pure functions and take a plain object:

from asfquart.session import ClientSession
from asfcalendar.permissions import can_view, can_write

session = ClientSession({"uid": "bob", "pmcs": ["httpd"], "projects": ["httpd"]})
can_view(some_event, session)

An import says “No events could be read from that file”. Either it is not iCalendar, or every entry in it was skipped. Run it through POST /api/import/preview, whose warnings say which: entries with no DTSTART, and ones marked STATUS:CANCELLED, are counted there.

An imported event is an hour out. Look at its DTSTART in the file. A time with no Z and no TZID is “floating” and is read as UTC, which the preview warns about; the fix is in whatever produced the file.

The API documentation page is blank, or says Swagger UI could not be loaded. The vendored files are missing from the build. Run npm run build in frontend/, which copies them in first, and check curl -sI https://your.host/vendor/swagger-ui-bundle.js comes back 200. If the browser console reports a CSP violation instead, the deployment‘s script-src/style-src needs to allow the calendar’s own origin.

A test fails saying an endpoint is “missing from openapi.py”. That is the anti-drift check doing its job: a route was added or changed without updating the description. The message names the method and path; add it to _paths() in backend/asfcalendar/openapi.py.

An embedded agenda shows an empty frame. Open the browser console on the host page. A frame-ancestors refusal is reported there and nowhere else, and no request reaches the calendar at all. See Embedding the agenda elsewhere for the header the deployment needs.

An embedded agenda is missing events somebody expects to see. It is an anonymous view by design: SameSite=Strict session cookies are not sent to a cross-site frame, so only public events appear. Compare with curl -s '.../api/events?...' with no cookie, which sees exactly what the embed sees.

Under a sub-directory, the page loads but is unstyled and blank. The browser is fetching the assets from the wrong place. Look at the served HTML: the <base href> should be /calendar/, matching server.base_path. If it says /, the config did not take effect; if the assets 404, the proxy is probably stripping the prefix, which the section above covers.

Under a sub-directory, everything 404s with a message about ProxyPass. That is the app telling you the request arrived outside its mount point. Either server.base_path does not match what the proxy sends, or the proxy is rewriting /calendar/x to /x. The message prints the directive it expects.

Shortlinks come out as /calendar/calendar/e/.... Older configs put the path in server.base_url. Only the scheme and host are read from it now, so this should not happen; if it does, the base_path value itself has the prefix twice.

The comparison hour columns are an hour out later in the week. A week view shares one set of hour gutters across all seven days, so the labels are computed for the first day shown. If one of the two zones changes for daylight saving mid-week, the rest of that week reads an hour off. The day view, which has one column, is always exact.

An event shows up on the wrong day. Check the timezone switch in the header. An event at 22:00 UTC is the following morning in Tokyo, and the calendar will correctly put it there when the browser's clock is on display. If the event is all-day, it should not move at all; if it does, that is a bug worth reporting.

An event was posted at the wrong time. The form‘s times are in the timezone picked in the form, which is not necessarily the one the calendar is being displayed in. The event’s details show both once it is saved.

'Europe/Berlin' is not a known timezone. The Python process cannot find a tz database. The tzdata package is a dependency for exactly this reason, so uv sync should fix it; on a system with its own zoneinfo it is not used.

database is locked. SQLite allows one writer at a time. It should not happen with a single worker; if it does, something else has the file open - a second copy of the app, or a sqlite3 shell.

Changes to Python files are ignored. Start with --reload. Note that app.runx(), asfquart‘s own watcher, is not what this app uses: asfquart 0.1.12 restarts by calling quart.utils.restart(), which newer Quart releases renamed to run_reloader, so the process dies on the first reload. --reload uses hypercorn’s reloader instead.

More logging. -v turns on DEBUG for everything, --access-log logs requests. --debug additionally puts Quart in debug mode, which returns tracebacks in HTTP responses; that is for local use only.

Looking directly at the data.

sqlite3 calendar.sqlite3 'SELECT id, shortlink, category, visibility, project, title FROM events;'

Everybody suddenly logged out. apptoken.txt was deleted or replaced, so existing session cookies no longer verify. Expected; they just need to log in again.

Things it does not do

Worth being clear about, so nobody goes looking:

  • No recurring events. Every event is a single occurrence, and a repeating event in an imported file arrives as one.
  • No invitations, attendance or reminders. It is a calendar, not a scheduler.
  • No per-event access lists. Access follows the category and visibility, and nothing else.
  • No per-viewer timezone beyond the local/UTC switch. You cannot ask to read the calendar in a third zone that is neither yours nor UTC.
  • One process only, because of the OAuth state limitation described above.

Licence

Apache License 2.0.