Module kit / Calendar and booking
Availability Computation
calendar-booking/availability-computation
| Kind | backend |
|---|---|
| Domain | calendar-booking |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres-14, python-3.11, icalendar-6.1, recurring-ical-events-3.5, caldav-1.4, google-api-python-client-2.154, google-auth-oauthlib-1.2, requests-2.32, cryptography-46, pydantic-2.12 |
| Depends on | _shared/fastapi-project-layout, _shared/error-envelope, _shared/test-layout |
| Frontends | calendar-booking/ui/calendar-settings-panel |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
Turns a meeting type's weekly schedule (e.g. "09:00-17:00") into a list of bookable time slots by subtracting every busy period it can find - Google Calendar events, an iCal feed's parsed VEVENTs, a CalDAV server's free-busy search, plus this app's own confirmed bookings and manual blocks - and returns the survivors converted from the calendar owner's timezone into the browser-detected client timezone (get_available_slots, calendar_integration_service.py 517 to 803; module docstring 6 to 10). The invariant that makes it trustworthy is that every busy-period source and every internal comparison is normalised to UTC before two intervals are ever compared (# ALL IN UTC for consistent comparison, line 625), so a bug in one provider's own timezone handling cannot silently shift a slot into or out of a real conflict; what it does NOT guarantee, measured below, is that a slot mid-checkout (PENDING_PAYMENT) is excluded from that list (Gotchas).
When to use
Read it before touching get_available_slots, get_availability_slots, ical_provider.get_busy_periods/parse_busy_periods, caldav_provider.get_busy_periods, or anything in utils/timezone.py; before changing what counts as "busy" (all-day events, TRANSPARENT/CANCELLED events, a PENDING_PAYMENT hold); and when someone asks "why is this slot showing as free when it's booked", "why is my Thai availability showing at the wrong hour in Zurich", or "why didn't my iCloud calendar block this time". Trigger phrases: "available slots", "busy periods", "calendar owner timezone", "client timezone", "CALENDAR_OWNER_TIMEZONE", "iCal feed", "CalDAV free-busy", "slot buffer", "min notice hours".
Not this recipe: POST /public/book/{slug} and _lock_slot_or_409 (calendar_router.py 1452 to 1650), the booking-creation and double-book-prevention path that runs AFTER a slot is chosen from this recipe's output, a neighbouring recipe; create_booking's Google-event/CalDAV-event write and .ics email dispatch (calendar_integration_service.py 1050 to 1320); the OAuth connect/callback flow and token encryption (149 to 280); cancel_booking/confirm_booking_by_token (1566 to 1622, calendar_router.py 1685 to 1784); the Meta CAPI booking-conversion firing (services/calendar/booking_conversion.py, covered by G6 but not by this recipe); the ICS builder (services/calendar/ics_builder.py), read for context in G6 but not part of computing a slot.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, synchronous handlers on sqlalchemy.orm.Session (SQLAlchemy==2.0.43), Postgres 14 in the test harness (docker-compose.test.yml), icalendar==6.1.0 + recurring-ical-events==3.5.2 for iCal parsing, caldav==1.4.0 for CalDAV, google-api-python-client==2.154.0/google-auth-oauthlib==1.2.1 for the Google Calendar API, requests==2.32.5 for the iCal feed HTTP fetch, cryptography==46.0.0 (Fernet) for feed/CalDAV credential encryption, pydantic==2.12.0 for the response schemas, tzdata==2025.2 backing zoneinfo.ZoneInfo. Foundations assumed: S4 (fastapi-project-layout) for the app and router mount; S7 (error-envelope) for the {"error", "code", "request_id"} shape every HTTPException on these routes is wrapped into (measured directly below); S9 (test-layout) for the tests/v2/coverage/ file this recipe's tests live in and the db_session/client fixtures they use.
Environment, all in core/config.py: CALENDAR_OWNER_TIMEZONE (629 to 632, default "Asia/Bangkok", the timezone the weekly schedule strings are interpreted in - this is per-app config, not per-tenant, though a workspace-level CalendarIntegration.timezone column overrides it per integration, see Data model); FALLBACK_CLIENT_TIMEZONE (634 to 636, default "Europe/Zurich", used when a caller omits timezone, e.g. the embed widget's server-rendered preload); GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALENDAR_REDIRECT_URI (619 to 624, Google-provider only). Fail behaviour is per-provider and asymmetric: a Google API failure (HttpError, or get_calendar_service raising on missing/invalid credentials) is caught and get_available_slots returns {} for the WHOLE query range - not "Google's events are just missing", the entire availability call comes back empty (calendar_integration_service.py 566 to 570); an iCal or CalDAV fetch failure is caught narrower, inside the try/except at 586 to 605, and degrades to external_busy = [] - the function keeps going and computes slots as if that source had no busy periods at all, which is fail-OPEN (a broken CalDAV credential silently stops blocking real appointments; there is no surfaced error to the client or an admin at this seam).
Data model
No table exists solely for availability; this recipe reads three others and writes none. All in models/calendar_integration_model.py, base tables created in baseline_prod_schema_001.py, iCal/CalDAV columns added by ical_caldav_001 (20260427b_add_ical_caldav.py).
calendar_integrations ( -- one per Google OAuth / iCal feed / CalDAV connection
id uuid PRIMARY KEY,
workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE, -- NULL = platform-level (Alchemy's own calendar)
provider varchar(50) NOT NULL DEFAULT 'google', -- google | outlook* | apple* | ical_feed | caldav (* unimplemented)
timezone varchar(100) DEFAULT 'Europe/Zurich', -- the CALENDAR OWNER's timezone for this integration; overrides CALENDAR_OWNER_TIMEZONE
calendar_id varchar(255) DEFAULT 'primary', -- Google calendar id
ical_feed_url text, feed_auth_type varchar(20) DEFAULT 'none',
feed_auth_value_encrypted text, feed_auth_username varchar(255),
feed_etag varchar(255), feed_last_modified varchar(255), feed_last_fetched_at timestamptz, -- conditional-GET cache, mutated in place by ical_provider.fetch_feed_text
caldav_url text, caldav_username varchar(255), caldav_password_encrypted text,
is_active boolean DEFAULT true,
...
)
booking_meeting_types ( -- the bookable offering: duration + weekly schedule
id uuid PRIMARY KEY,
calendar_integration_id uuid NOT NULL REFERENCES calendar_integrations(id) ON DELETE CASCADE,
duration_minutes integer NOT NULL DEFAULT 60,
buffer_before_minutes integer DEFAULT 0, buffer_after_minutes integer DEFAULT 15,
min_notice_hours integer DEFAULT 24, max_days_ahead integer DEFAULT 28,
availability_schedule jsonb DEFAULT '{"mon":["09:00-17:00"], ... "sat":[], "sun":[]}', -- strings in the INTEGRATION's timezone
is_active boolean DEFAULT true,
UNIQUE (calendar_integration_id, slug)
)
calendar_bookings ( -- what this recipe reads back as one busy-period source
id uuid PRIMARY KEY,
meeting_type_id uuid REFERENCES booking_meeting_types(id) ON DELETE SET NULL,
calendar_integration_id uuid NOT NULL REFERENCES calendar_integrations(id) ON DELETE CASCADE,
starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, -- stored UTC
status varchar(50) DEFAULT 'confirmed', -- pending | pending_payment | confirmed | cancelled | rescheduled | completed | no_show
...
-- Partial UNIQUE index (added by booking_slot_lock_001, not this recipe's own
-- concern but the reason SLOT_HOLDING_STATUSES exists, see Gotchas):
-- uq_booking_slot_meeting_type_starts_at ON (meeting_type_id, starts_at)
-- WHERE meeting_type_id IS NOT NULL AND status IN ('confirmed','pending_payment')
)
calendar_blocked_times ( -- manual admin blocks, the fourth busy-period source
id uuid PRIMARY KEY,
calendar_integration_id uuid NOT NULL REFERENCES calendar_integrations(id) ON DELETE CASCADE,
starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, -- stored UTC
...
)
What is NOT stored: a computed slot itself - every call to get_available_slots/get_availability_slots recomputes from the four live sources, nothing is cached or materialised (the only cache in this recipe is the iCal feed's raw .ics text, ical_provider._FEED_CACHE, a module-level dict keyed by integration id, process-local, not shared across workers, TTL FRESH_TTL_SECONDS = 60, ical_provider.py 29, 107 to 157); a CalDAV busy-period cache of any kind (caldav_provider.get_busy_periods hits the server on every call, 172 to 208).
API surface
| Route | Auth | Tenancy | Query params | Codes | Notes |
|---|---|---|---|---|---|
GET /api/calendar/public/availability/{slug} (calendar_router.py 1391 to 1449, get_public_availability) | none - no auth dependency at all | integration_id optional query param; omitted falls back to get_platform_integration (Google-only, workspace_id IS NULL) | start_date, end_date (both date, default: today in client tz / +max_days_ahead), timezone (default "Europe/Zurich"), integration_id (uuid) | 200 AvailabilityResponse; 400 invalid timezone; 404 "Meeting type not found" / "Booking not available" | end_date is clamped server-side to start_date + meeting_type.max_days_ahead even if the caller asks for more (1431 to 1433, proven by test_public_availability_clamps_to_max_days_ahead) |
GET /api/calendar/onboarding/availability/{token} (calendar_router.py 2306 to 2391, get_onboarding_availability) | none; the onboarding session token in the path is the only gate | derives the PLATFORM integration only (get_platform_integration), never a workspace one | start_date, end_date, timezone (default "Europe/Zurich") | 200 {duration_minutes, slots_by_date}; 400 invalid timezone; 404 session/calendar not found; 500 on any other exception (2389 to 2391) | Calls get_availability_slots, the Google-only sibling (see Application logic) - an onboarding token whose platform integration is an iCal feed or CalDAV gets a 500 here, not a graceful degrade, because that path calls get_calendar_service unconditionally |
GET /api/calendar/embed/{slug} (calendar_router.py 1943 onward; availability pre-fetch at 2031 to 2071) | none | same integration_id-or-platform-fallback pattern | none availability-specific; the route pre-fetches 8 weeks (timedelta(days=56), line 2035) server-side and inlines up to 5 dates' worth of slots as JSON into the returned HTML for first paint | 200 HTML | Uses FALLBACK_CLIENT_TIMEZONE, not a detected client timezone - the embed's client-side JS re-fetches /public/availability/{slug} with the real browser timezone once it loads; this server-rendered preload is Zurich-time by construction |
The two service entrypoints these routes call, the actual surface a rebuild reimplements:
| Function | Providers supported | Timezone source | Returns |
|---|---|---|---|
get_available_slots(db, meeting_type, start_date, end_date, client_timezone) (517 to 803) | Google, iCal feed, CalDAV (565 to 607) | meeting_type.calendar_integration.timezone, fallback CALENDAR_OWNER_TIMEZONE (547) | Dict[str_date, List[{start, end, start_time, end_time, start_utc, end_utc}]] |
get_availability_slots(db, integration_id, start_date, end_date, duration_minutes, client_timezone, min_notice_hours, buffer_minutes, availability_schedule) (805 to 1044) | Google only - unconditionally calls self.get_calendar_service(integration) (861), no iCal/CalDAV branch exists | same precedence as above (856) | same slot shape |
Every HTTPException raised on these routes is rendered by the app's ErrorLoggingMiddleware as {"error": <detail>, "code": <e.g. "RES_003">, "request_id": <uuid>, "timestamp": <iso>, "details": null}, not FastAPI's default {"detail": ...} - measured directly: test_create_public_booking_source_onboarding (a neighbouring-recipe test, not this one) receives exactly this envelope with "code":"RES_003" on a 409 from _lock_slot_or_409. This recipe's own routes surface HTTPException(400, "Invalid timezone: ...") and HTTPException(404, ...) through the same envelope; no test in the two files this recipe is measured against asserts the envelope shape for an availability-route error specifically, only for the neighbouring booking route - treat the shape as measured, the coverage as partial.
Application logic
get_available_slots (517 to 803), in the order the code runs:
- Resolve timezones (546 to 550):
calendar_tzfromintegration.timezone, falling back toCALENDAR_OWNER_TIMEZONE;client_tzfrom theclient_timezoneargument, no fallback and no validation inside the service itself (the router validates first viavalidate_timezone, 1409 to 1411 - a caller that skips the router and calls the service directly with a bad timezone string gets a rawZoneInfoNotFoundError, not a clean error). - Compute the UTC query window from the client's calendar-day range (554 to 558):
[start_date 00:00, end_date 23:59:59.999999]inclient_tz, converted to UTC. This is the window sent to every provider and used to filter bookings/blocks. - Fetch busy periods, per provider (565 to 607), each independently caught so one provider's failure never raises out of the function:
- Google (565 to 583): lists events via
service.events().list(singleEvents=True, orderBy='startTime'); a service-acquisition failure orHttpErrorreturns{}for the ENTIRE function (566 to 570, 581 to 583) - this is a whole-range failure, not a per-event one. - iCal feed (585 to 595):
ical_provider.get_busy_periods(integration, range_start, range_end); the integration's feed cache columns (feed_etagetc.) are mutated in place by the fetch anddb.commit()'d here (591) - the ONLY database write in this whole recipe, and it is a caching side-effect, not domain data. - CalDAV (597 to 605):
caldav_provider.get_busy_periods(integration, range_start, range_end). - Any other
providervalue logs a warning and produces no busy periods at all (606 to 607) - an integration row is not itself validated to have a supported provider before it reaches here.
- Google (565 to 583): lists events via
- Normalise everything to UTC into one
busy_periodslist (625 to 686): Google timed events parsed viadatetime.fromisoformatwith aZ→+00:00swap (635 to 640); Google all-day events are expanded into one full calendar-owner-timezone day per date in[start, end)- Google's own all-dayendis exclusive (654 to 665, comment at 654); iCal/CalDAV busy periods arrive already UTC-normalised from their own_to_utc(668 to 672);CalendarBookingrows are filteredstatus == BookingStatus.CONFIRMED.valueAND overlapping the window (611 to 616), not the model's ownSLOT_HOLDING_STATUSEStuple - see Gotchas;CalendarBlockedTimerows overlapping the window (619 to 623) are added unfiltered. - Generate candidate slots per calendar day (709 to 801): for each date in
[start_date, end_date], look upmeeting_type.availability_schedule[day_name](the day name computed fromcurrent_date.weekday(), i.e. the CLIENT's calendar date, not reinterpreted into the owner's date - a schedule difference across the international date line at the day boundary is not corrected for, only individual slot instants are timezone-correct); each"HH:MM-HH:MM"range is parsed and both ends stamped withtzinfo=calendar_tz(owner's timezone, 736 to 751); slots walk forward in 30-minute steps regardless ofduration_minutes(759, 799, 1040 - a 90-minute meeting still only starts on the half hour) whileslot_start + duration <= range_end_cal. - Filter each candidate, in UTC (762 to 777): reject if
slot_start_utc < now_utc() + min_notice_hours; reject if it conflicts with anybusy_periodsentry under a SYMMETRIC buffer,gap = max(buffer_before_minutes, buffer_after_minutes)applied to BOTH ends of the candidate (comment 694 to 705 documents this is a deliberate fix - buffer used to apply asymmetrically, letting back-to-back bookings skip a "Puffer nachher"-only configuration). - Emit surviving slots converted to
client_tz(778 to 797): each slot carriesstart/end(client-tz ISO, what the booking endpoint expects back),start_time/end_time(HH:MMdisplay), andstart_utc/end_utcfor clarity.
get_availability_slots (805 to 1044) is a near-duplicate of steps 1 to 7 for onboarding-workshop bookings that have no meeting_type row (a bare integration_id + duration_minutes + optional availability_schedule), with one structural difference: it has no provider branch at all and always calls self.get_calendar_service(integration) (861 to 865) - an iCal-feed or CalDAV platform integration raises inside get_calendar_service (ValueError("Invalid credentials"), calendar_integration_service.py 402 to 407) which the route's outer except Exception turns into a 500 (calendar_router.py 2389 to 2391), not the empty-{} degrade get_available_slots gives Google callers.
ical_provider.parse_busy_periods (175 to 249), the pure function get_available_slots reaches through get_busy_periods: parses with icalendar.Calendar.from_ical, expands recurrence with recurring_ical_events.of(cal).between(range_start, range_end) when that package is importable (201 to 206, it is, per Stack assumptions), skips STATUS:CANCELLED and (unless skip_transparent=False) TRANSP:TRANSPARENT events (211 to 215), computes end from DTEND, else DURATION, else a 1-hour default when NEITHER is present (219 to 235) - this 1-hour-default branch and the "no DTSTART, skip" branch are dead code at the pinned library versions, see Gotchas, the actual behavior is decided by recurring_ical_events before the hand-written fallback ever runs. Drops non-overlapping and zero-or-negative-length events (237 to 241).
caldav_provider.get_busy_periods (172 to 208): opens EVERY calendar on the account via _open_read_calendars (76 to 118) - a bare discovery URL (e.g. iCloud's https://caldav.icloud.com) enumerates all calendars via principal().calendars(); a URL with a real path opens that one calendar directly, falling back to discovery if that fails - and unions their busy periods (comment 178 to 182 explains this was a fix: a single arbitrary calendars[0] guess was missing iCloud appointments that live in a different calendar). _events_to_busy (121 to 169) additionally SKIPS whole-day (date, not datetime) events entirely (145 to 146, deliberately, to avoid a subscribed holiday calendar zeroing out every day) - this is a real behavioral divergence from the iCal-feed provider, which DOES block all-day events (Gotchas).
Invariants
- Every busy-period comparison happens in UTC, regardless of source (module docstring 6 to 10; comment at line 625;
test_service_get_available_slots_shape_and_timezoneasserts the emittedstartoffset matches the CLIENT timezone, proving the conversion chain ran end to end). - A slot's break/buffer clearance is enforced symmetrically on both sides of every candidate and every existing booking, independent of which side (
buffer_before/buffer_after) the operator configured it on (694 to 705, dated fix comment in the source itself). end_dateis always clamped tostart_date + meeting_type.max_days_aheadat the router, regardless of what the caller requests (calendar_router.py1430 to 1433;test_public_availability_clamps_to_max_days_ahead).- A wrong-email or unknown lookup on the NEIGHBOURING booking endpoints never reveals a booking's existence (out of this recipe's scope, cited because the same public-surface posture applies to availability: no auth, so nothing here should leak beyond what the schedule itself is willing to publish).
- Not actually invariant, measured false: "a slot occupied by an in-flight payment is excluded from availability" - the model's own
SLOT_HOLDING_STATUSEScomment claims this for "the availability query", but neitherget_available_slotsnorget_availability_slotsimports or filters on it (grep confirmsSLOT_HOLDING_STATUSESappears nowhere incalendar_integration_service.py); onlyBookingStatus.CONFIRMEDis excluded. See Gotchas.
Frontend
The module that would render this, by id:
calendar-booking/ui/calendar-settings-panel the workspace-facing panel where an operator configures
a meeting type's weekly availability_schedule,
buffers and min-notice - the settings this
recipe's schedule-parsing step (Application
logic, step 5) consumes. The slot GRID itself
(what a booking visitor sees and clicks) is the
embed widget rendered server-side by
calendar_router.get_embed_widget
(calendar_router.py 1943 onward) plus its
client-side re-fetch of /public/availability -
at 62ba4fc there is no separate componentised
frontend module for that booking-facing grid in
this repo's own frontend/src tree; it ships as
inline HTML/JS from the embed route.
Minimum admin/debug surface: no dedicated availability-debug route exists at this commit. GET /api/calendar/integrations/test-ical and /test-caldav (calendar_router.py 362 to 380) are the closest operational check - they prove a feed URL or CalDAV credential resolves, but do not exercise the actual slot-generation path. The [AVAILABILITY]-prefixed logger.info lines (552, 580, 592, 602, 785) are the only in-band visibility into which provider branch ran and how many busy periods it found; there is no structured metric or admin query for "why is this slot unavailable" beyond reading those logs.
Security & tenancy
Auth dependency: none on any of the three availability routes - get_public_availability, get_onboarding_availability, and the embed pre-fetch all take only db plus query/path params (calendar_router.py 1392 to 1399, 2307 to 2313). Tenancy is derived entirely from the caller-supplied integration_id (or, when omitted, the single platform-level Google integration) - there is no session, no workspace-derivation middleware, and no check that the caller is entitled to see this integration's schedule; anyone who knows or guesses a slug and integration_id can read a workspace's full availability. This is a deliberate public-surface design (the routes power embeddable client-site booking widgets, PUBLIC_CORS_PREFIXES in app_fastapi.py explicitly opens CORS for /api/calendar/public/ and /api/calendar/embed/, comment at 466 to 473 explaining why), not an oversight, but it means this recipe's only tenancy boundary is "know the integration id and the meeting-type slug", weaker than _shared/workspace-scoping's session-derived model used elsewhere in this app.
Fail direction is mixed and provider-dependent (see Stack assumptions): Google failures fail CLOSED (empty result, no slots shown); iCal/CalDAV failures fail OPEN (the fetch error is logged and swallowed, the function proceeds as if that source had zero busy periods) - a broken CalDAV password would silently make every real appointment on that calendar bookable again by a second client, with nothing user-facing to signal it.
Secrets at rest: feed_auth_value_encrypted and caldav_password_encrypted are Fernet-encrypted with the same key as Google OAuth tokens (calendar_integration_service._get_or_create_key, out of this recipe's scope but the key this recipe's providers decrypt with via _decrypt_feed_secret/_decrypt_password, ical_provider.py 53 to 58, caldav_provider.py 31 to 35). Attacker-controlled inputs on these routes, all unauthenticated: slug, integration_id, start_date/end_date (bounded server-side by max_days_ahead, unbounded below - a caller can request an arbitrarily distant PAST range, which simply returns empty months rather than erroring), timezone (validated against zoneinfo via validate_timezone at the router, 400 on failure, but NOT re-validated inside the service functions themselves, so a direct service call bypasses that guard).
Tests
Run 2026-09-12 against the Postgres 14 test service on :5434 (docker-compose.test.yml's postgres-test), from alchemy_labs/backend with .venv:
python -m pytest tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py \
tests/v2/coverage/test_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.py \
-q --no-cov -o addopts="" -p no:cacheprovider
prints 3 failed, 79 passed (82 def test_ total between the two files: 34 in G1, 48 in G6). Hermetic vs live: fully hermetic, no real Google/CalDAV/iCal network reached - G1's patch_ical_busy fixture monkeypatches ical_provider.get_busy_periods to [] (250 to 255) and its ical_integration fixture uses an iCal-feed provider specifically so create_booking's Google path is never exercised (comment 24 to 30); G6's TestParseBusyPeriods feeds literal .ics text with no network; TestFeedConnectivity patches requests.get directly. Known-answer vectors, directly relevant to this recipe: test_service_get_available_slots_shape_and_timezone (owner tz Europe/Zurich, client tz Asia/Bangkok, asserts the emitted slot's UTC offset equals Bangkok's for that instant - the clearest end-to-end proof the owner→UTC→client conversion chain runs); TestParseBusyPeriods.test_all_day_event_blocks_the_day, test_naive_dtstart_treated_as_utc, test_event_outside_range_excluded.
3 tests fail at this exact pin, reproduced standalone (not order-dependent - each fails run alone too):
TestParseBusyPeriods::test_event_missing_dtstart_skipped- the test expects a VEVENT with noDTSTARTto be silently skipped (the hand-writtenif dtstart is None: continueatical_provider.py219 to 220 suggests this); insteadrecurring_ical_events==3.5.2's ownof(cal).between(...)raisesKeyError: 'DTSTART'before that guard is ever reached (recurring_ical_events/selection/name.py:96). A malformed feed with a DTSTART-less VEVENT crashesget_available_slots's iCal branch instead of being tolerated - caught by the branch's ownexcept Exceptionatcalendar_integration_service.py593 to 595, so it degrades to "no iCal busy periods" rather than a 500, but the source-level intent (skip just that VEVENT) is not what happens.TestParseBusyPeriods::test_missing_dtend_defaults_to_one_hour- expects a VEVENT with neitherDTENDnorDURATIONto default to a 1-hour busy period (ical_provider.py226 to 235); at the pinned library versionrecurring_ical_eventsdrops the event from its expansion entirely, soparse_busy_periodsreturns[]and the 1-hour-default code path (234 to 235) never runs.test_create_public_booking_source_onboarding(G1, a neighbouring-recipe test, not this recipe's own subject) fails because the fixed test slot collides with theuq_booking_slot_meeting_type_starts_atpartial index from an earlier test in the same run, returning 409RES_003instead of the 500 the test's own comment says it tolerates ("FK violation path... at minimum must not be a 2xx with wrong source"). Cited here only because it demonstrates the error-envelope shape this recipe's own 400/404 routes share.
What a new app adds and this measured slice lacks: no test asserts the error-envelope shape ({"error","code","request_id"}) for THIS recipe's own routes (400 invalid timezone, 404 not found) - only the neighbouring booking-conflict path is proven to use it; no test exercises the CalDAV get_busy_periods path at all (no CalDAV fixture exists in either file); no test exercises get_availability_slots' Google-only onboarding path end to end (only get_available_slots is driven service-level); no test pins the "iCal fails open, Google fails closed" asymmetry described in Stack assumptions.
Deploy & ops
Config keys per app: CALENDAR_OWNER_TIMEZONE, FALLBACK_CLIENT_TIMEZONE, GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALENDAR_REDIRECT_URI (all above; none of these three Google keys gate the iCal/CalDAV code paths this recipe also covers - an app can run availability computation for iCal/CalDAV-only tenants with no Google credentials configured at all, _validate_google_config only logs a warning, calendar_integration_service.py 114 to 135). Migrations: baseline_prod_schema_001.py creates all four tables this recipe reads (calendar_integrations, booking_meeting_types, calendar_bookings, calendar_blocked_times) as part of the pre-chain baseline; ical_caldav_001 (20260427b_add_ical_caldav.py, down-revision content_review_001) adds every iCal/CalDAV column on calendar_integrations and drops the old (workspace_id, provider) uniqueness so one workspace can hold multiple feeds; booking_slot_lock_001 (20260610_0004_booking_slot_lock.py) adds the partial unique index that motivates SLOT_HOLDING_STATUSES (a neighbouring recipe's migration, cited because it is the reason the Gotcha below matters).
No scheduler job computes or caches availability; every call is synchronous and on-request. Router mount: mount_router(app, calendar_router.router, prefix="/api/calendar", ..., surfaces=LABS_SURFACES) (app_fastapi.py 845) - no feature flag, unconditional. Rollback: nothing stateful to unwind; this recipe writes only the iCal feed-cache columns (feed_etag/feed_last_modified/feed_last_fetched_at), which self-heal on the next successful fetch.
Argus-style check: GET /api/calendar/public/availability/{a-known-slug}?integration_id=<platform-integration-id>&timezone=Europe/Zurich returns 200 with total_slots > 0 for a meeting type with a non-empty weekly schedule and a horizon >= 1 day past min_notice_hours - if it returns {}/zero slots when the calendar SHOULD have openings, check the provider branch: a Google failure returns {} for the whole range (fail closed, loud in logs), an iCal/CalDAV failure returns computed slots as if nothing were busy (fail open, quiet unless the [AVAILABILITY] log lines are read).
Gotchas
- 2026-09-12 (measured at 62ba4fc,
models/calendar_integration_model.py59 to 64 vsservices/calendar_integration_service.py611 to 616, 898 to 903): the model's own comment claims "Both the availability query and the... slot-lock... use this set" (SLOT_HOLDING_STATUSES = (CONFIRMED, PENDING_PAYMENT)), butSLOT_HOLDING_STATUSESis not imported anywhere incalendar_integration_service.py(grep confirms zero occurrences) - bothget_available_slotsandget_availability_slotsfilter existing bookings onstatus == BookingStatus.CONFIRMED.valueonly. A booking mid-Payrexx-checkout (PENDING_PAYMENT, which DOES hold the row under the slot-lock unique index) is invisible to availability computation: a second browsing client can be shown and select the same slot while the first buyer is still paying. This is the single highest-value fact in this recipe for anyone rebuilding it faithfully vs. rebuilding it CORRECTLY - a faithful port reproduces the gap, a correct one asks whether to close it. - 2026-09-12 (measured at 62ba4fc,
services/calendar_integration_service.py69 to 76 vs the rest of the class):self.tz_service(aTimezoneServiceinstance,utils/timezone.py) is constructed in__init__and never called anywhere else in the file (grep fortz_serviceoutside__init__returns nothing).get_available_slots/get_availability_slotsbuildZoneInfoobjects directly instead of using theTimezoneServicemethods that exist specifically for this (convert_availability_slot,get_query_range_utc, etc.). The dedicated timezone-conversion utility module is dead weight on the actual availability path; a rebuild can usezoneinfo.ZoneInfodirectly (what the real code does) rather than portingTimezoneService, or port it and actually wire it in - but not both partially, as here. - 2026-09-12 (measured at 62ba4fc against the pinned
recurring-ical-events==3.5.2/icalendar==6.1.0): two ofical_provider.parse_busy_periods's own hand-written fallback branches are unreachable dead code - see Tests. A VEVENT with noDTSTARTcrashes the recurrence expansion (KeyError) rather than being skipped by the function's ownif dtstart is Noneguard; a VEVENT with neitherDTENDnorDURATIONis silently dropped by the library rather than defaulted to 1 hour by the function's own fallback. Both are caught one level up byget_available_slots's per-providertry/except, so the practical effect is "a malformed feed event, or one with only a start time, contributes nothing to busy periods" rather than the source comments' stated intent. - 2026-09-12 (measured at 62ba4fc,
ical_provider.py168 to 171 vscaldav_provider.py145 to 146): the two "read a feed" providers disagree on all-day events. iCal-feed all-day events (adate, not adatetime) are treated as a full UTC day of busy time (_to_utc's date branch, comment "Conservative - better to over-block"). CalDAV all-day events are explicitly SKIPPED (comment: avoids a subscribed holiday/birthday calendar zeroing out every day). The SAME underlying_to_utchelper is imported by both files (caldav_provider.pyline 22), butcaldav_provider._events_to_busyfilters all-day events out BEFORE_to_utcis ever called on them (145 to 146), so the shared helper's own all-day handling never runs for CalDAV. A tenant who switches their calendar owner's connection from an iCal feed to CalDAV (or vice versa) changes whether their holiday calendar blocks bookings, with no configuration surfaced anywhere for this. - 2026-09-12 (measured at 62ba4fc,
services/calendar_integration_service.py805 to 1044 vs 517 to 803):get_availability_slots(the onboarding-workshop sibling) has no iCal/CalDAV branch at all - it always callsget_calendar_service, which raises on a non-Google integration. The onboarding availability route (calendar_router.py2372 to 2391) wraps the whole call in a bareexcept Exception: raise HTTPException(500, "Failed to get availability"), so an operator whose PLATFORM integration is an iCal feed or CalDAV connection (a fully supported configuration for the ordinary meeting-type path) gets a 500 on every onboarding-workshop availability request, with no test covering this combination (Tests).
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/services/calendar_integration_service.py(1717 lines): module docstring (1 to 11);__init__/self.tz_service(69 to 76);get_available_slots(517 to 803);get_availability_slots(805 to 1044); the rest of the class (OAuth, booking creation, cancellation) out of this recipe's scope, cited in "When to use".alchemy_labs/backend/services/calendar/ical_provider.py(308 lines, whole file in scope):BusyPeriod(34 to 46);fetch_feed_text+ module cache_FEED_CACHE(107 to 157);_to_utc(160 to 172);parse_busy_periods(175 to 249);get_busy_periods(252 to 262);test_feed(265 to 307, the connectivity-check surface, not availability itself but the same file).alchemy_labs/backend/services/calendar/caldav_provider.py(336 lines, whole file in scope):_open_read_calendars(76 to 118);_events_to_busy(121 to 169);get_busy_periods(172 to 208);create_event/cancel_event(211 to 302, booking-write, out of this recipe's scope but same file).alchemy_labs/backend/utils/timezone.py(457 lines, whole file in scope):TimezoneService(42 to 383, constructed but effectively unused on the availability path, see Gotchas);validate_timezone(438 to 452, the function the router actually calls);today_in_tz(394 to 396, used for date-range defaults).alchemy_labs/backend/api/routers/calendar_router.py:AvailabilityResponse(153 to 158);get_public_availability(1391 to 1449);get_onboarding_availability(2306 to 2391); the embed pre-fetch (2031 to 2071); router mount atapp_fastapi.py845,PUBLIC_CORS_PREFIXESentries atapp_fastapi.py466, 473.alchemy_labs/backend/models/calendar_integration_model.py:CalendarIntegration(82 to 159),BookingMeetingType(161 to 257),CalendarBooking(260 to 371, includingSLOT_HOLDING_STATUSESat 59 to 64 and the partial unique index at 353 to 371),CalendarBlockedTime(from 414).alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py: the four base tables (545, 666, 682, 729).20260427b_add_ical_caldav.py(ical_caldav_001): iCal/CalDAV columns (1 to 60+).20260610_0004_booking_slot_lock.py(booking_slot_lock_001): the neighbouring slot-lock index.- Tests:
alchemy_labs/backend/tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py(738 lines);test_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.py(618 lines, availability-relevant portion isTestParseBusyPeriods/TestToUtc/TestIsFresh/TestFeedConnectivity, lines 192 to 434; the ICS-builder and Meta-conversion classes in the same file are out of this recipe's scope).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: PENDING_PAYMENT holds are not excluded from availability despite the model comment claiming they are; the two providers disagree on all-day-event handling with no configuration surfaced; the onboarding sibling function has no iCal/CalDAV support and 500s instead of degrading; two of ical_provider's own documented fallback branches are unreachable at the pinned library versions; no CalDAV test fixture exists in the coverage suite.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc with the Postgres 14 test service reachable on :5434 (docker-compose.test.yml's postgres-test; already running is sufficient, tests/conftest.py does not start it for you).
python -m pytest tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py tests/v2/coverage/test_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.py -q --no-cov -o addopts="" -p no:cacheproviderprints3 failed, 79 passed, with the three named failures exactly as in Tests (any other failure signature means something besides the knownrecurring_ical_eventsgaps changed).grep -c "def test_public_availability" tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.pyprints5(the five route-levelGET /public/availability/{slug}tests:_returns_slots_for_owner,_invalid_timezone_400,_unknown_slug_404,_no_platform_integration_404,_clamps_to_max_days_ahead- none of the three known failures from check 1 is among them; do not re-run these as a separate narrowerpytestinvocation against the same live test database without resetting its schema first,tests/v2/coverage/conftest.py's own docstring (lines 20 to 24) warns a session-scopedcreate_all()does not always round-trip cleanly between separate invocations, and in practice a second bare invocation collides onCREATE TYPE clientstatusagainst state the first invocation left behind).grep -n "SLOT_HOLDING_STATUSES" services/calendar_integration_service.pyprints nothing (confirms the Gotcha: the tuple is not referenced in this file at this commit).grep -n "self.tz_service" services/calendar_integration_service.pyprints exactly one line (76, the constructor assignment; confirms it is otherwise unused).grep -cE "^ *def test_" tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.pyprints34; the same pattern againsttest_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.pyprints48(82 total, matching Tests; G6's are all indented insideTest*classes, so an unindented^def test_pattern alone undercounts it as 0).python -c "import importlib.metadata as m; print(m.version('recurring-ical-events'), m.version('icalendar'))"(run inside.venv) prints3.5.2 6.1.0, confirming the environment matches the pin the two known iCal-parsing failures in check 1 are measured against (neither package exposes a__version__attribute - useimportlib.metadata, not<module>.__version__).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.