import hardening: recurring events are not supported (yet), make the poster aware
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:
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.
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.
| Category | Visibility | Who can read it | Who can create, edit and delete |
|---|---|---|---|
| personal | private | the owner | the owner |
| project | public | everybody, including anonymous | session.projects for that project |
| project | private | session.committees for that project | session.committees for that project |
| foundation | public | everybody, including anonymous | foundation members |
| foundation | private | foundation members | foundation members |
A few details worth knowing:
owner field records who added an event but does not restrict who can change it.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.
Nothing else. Events live in a SQLite file, so there is no database server to set up.
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 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.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
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.
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.
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/.
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:
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/.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:
| Helper | For |
|---|---|
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.
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.
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.
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.
All optional, all on the query string.
| Parameter | Default | What it does |
|---|---|---|
project, projects | all | project names; repeat the parameter or comma-separate |
category, categories | all | personal, project or foundation |
q | - | text search over title, description, location and project |
days | 60 | how far ahead to look, 1 to 366 |
limit | 20 | how many events to show, 1 to 100 |
zone | local | local, or an IANA name such as UTC or Europe/Berlin |
title | - | a heading above the list |
showzone | 1 | whether to say which clock the times are on |
transparent | 0 | drop the background so the host page shows through |
theme | auto | auto, light or dark; auto follows the reader's setting |
credit | 1 | whether 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.
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.
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:
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.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.
# 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.
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:
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.app.default_display_zone decides what a first-time visitor gets, and takes local or any IANA name.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.
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.
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 file | What happens |
|---|---|
DTSTART;TZID=Europe/Berlin | kept as the event's organiser timezone |
DTSTART;VALUE=DATE | an all-day event, whole UTC days |
DURATION instead of DTEND | used to work out the end |
| no end at all | an hour, or a day for an all-day event |
| a floating time with no zone | read as UTC, with a note |
| a title longer than the limit | trimmed, with a note |
a mailto: or other non-http URL | dropped, with a note |
RRULE | imported 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 DTSTART | skipped, and counted in the file's warnings |
VTODO, VJOURNAL | ignored; 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.
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.
Everything under /api speaks JSON, errors included. Anonymous reads are allowed; anything that writes needs a session.
| Method | Path | What it does |
|---|---|---|
| GET | /api/session | who is logged in, plus login and logout URLs |
| GET | /api/calendars | which calendars this session can read and write |
| GET | /api/events | list events (see the filters below) |
| POST | /api/events | create 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>.ics | one event as iCalendar |
| GET | /api/events.ics | a filtered iCalendar feed |
| GET | /api/shortlink/<token> | look an event up by its shortlink token |
| POST | /api/import/preview | read an .ics file without saving anything |
| POST | /api/import | turn an .ics file into events |
| GET | /api/healthz | liveness 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:
| Parameter | Meaning |
|---|---|
start, end | ISO 8601 or epoch seconds; returns events overlapping [start, end) |
category, categories | personal, project or foundation; repeat or comma-separate |
project, projects | project names; repeat or comma-separate |
visibility | public or private |
owner | a uid, or me |
q | text search over title, description, location and project |
sort | start, -start, title, -title, created, -created |
limit, offset | paging; 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.
The API describes itself in OpenAPI 3.1, and the calendar serves an interactive Swagger UI for it.
| Where | What |
|---|---|
API button, or /docs | Swagger UI, with “try it out” against this deployment |
/api/openapi.json | the description, for tooling |
/api/openapi.yaml | the 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.
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:
url_map, so adding an endpoint without describing it fails the build, and so does leaving a description behind after deleting one.Event schema matches Event.to_json(), field for field. A new field on the dataclass that nobody documented fails too.readOnly is accepted as input, and the writable schema is a subset of the readable one.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-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.
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
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:
| Persona | Projects | Committees | Member |
|---|---|---|---|
| alice | httpd, tomcat | - | no |
| bob | httpd | httpd | no |
| carol | tomcat | tomcat | yes |
| dave | maven | - | no |
Between them they cover committer without committee, committee member, foundation member, and complete outsider.
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.
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.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:
GITHUB_TOKEN. The !frontend/dist/** path exclusion is a second line of defence, for the day somebody swaps in a PAT.git add -A, so the previous hashed filenames go away instead of accumulating.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.
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.
Worth being clear about, so nobody goes looking:
Apache License 2.0.