Prima Get an API key

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
  • alchemy_labs/backend/services/calendar_integration_service.py
  • alchemy_labs/backend/api/routers/calendar_router.py
  • alchemy_labs/backend/services/calendar/ical_provider.py
  • alchemy_labs/backend/services/calendar/caldav_provider.py
  • alchemy_labs/backend/utils/timezone.py
  • alchemy_labs/backend/models/calendar_integration_model.py
  • alchemy_labs/backend/tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py
  • alchemy_labs/backend/tests/v2/coverage/test_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.py
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

RouteAuthTenancyQuery paramsCodesNotes
GET /api/calendar/public/availability/{slug} (calendar_router.py 1391 to 1449, get_public_availability)none - no auth dependency at allintegration_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 gatederives the PLATFORM integration only (get_platform_integration), never a workspace onestart_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)nonesame integration_id-or-platform-fallback patternnone 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 paint200 HTMLUses 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:

FunctionProviders supportedTimezone sourceReturns
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 existssame 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:

  1. Resolve timezones (546 to 550): calendar_tz from integration.timezone, falling back to CALENDAR_OWNER_TIMEZONE; client_tz from the client_timezone argument, no fallback and no validation inside the service itself (the router validates first via validate_timezone, 1409 to 1411 - a caller that skips the router and calls the service directly with a bad timezone string gets a raw ZoneInfoNotFoundError, not a clean error).
  2. 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] in client_tz, converted to UTC. This is the window sent to every provider and used to filter bookings/blocks.
  3. 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 or HttpError returns {} 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_etag etc.) are mutated in place by the fetch and db.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 provider value 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.
  4. Normalise everything to UTC into one busy_periods list (625 to 686): Google timed events parsed via datetime.fromisoformat with a Z→+00:00 swap (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-day end is exclusive (654 to 665, comment at 654); iCal/CalDAV busy periods arrive already UTC-normalised from their own _to_utc (668 to 672); CalendarBooking rows are filtered status == BookingStatus.CONFIRMED.value AND overlapping the window (611 to 616), not the model's own SLOT_HOLDING_STATUSES tuple - see Gotchas; CalendarBlockedTime rows overlapping the window (619 to 623) are added unfiltered.
  5. Generate candidate slots per calendar day (709 to 801): for each date in [start_date, end_date], look up meeting_type.availability_schedule[day_name] (the day name computed from current_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 with tzinfo=calendar_tz (owner's timezone, 736 to 751); slots walk forward in 30-minute steps regardless of duration_minutes (759, 799, 1040 - a 90-minute meeting still only starts on the half hour) while slot_start + duration <= range_end_cal.
  6. Filter each candidate, in UTC (762 to 777): reject if slot_start_utc < now_utc() + min_notice_hours; reject if it conflicts with any busy_periods entry 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).
  7. Emit surviving slots converted to client_tz (778 to 797): each slot carries start/end (client-tz ISO, what the booking endpoint expects back), start_time/end_time (HH:MM display), and start_utc/end_utc for 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_timezone asserts the emitted start offset 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_date is always clamped to start_date + meeting_type.max_days_ahead at the router, regardless of what the caller requests (calendar_router.py 1430 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_STATUSES comment claims this for "the availability query", but neither get_available_slots nor get_availability_slots imports or filters on it (grep confirms SLOT_HOLDING_STATUSES appears nowhere in calendar_integration_service.py); only BookingStatus.CONFIRMED is 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 no DTSTART to be silently skipped (the hand-written if dtstart is None: continue at ical_provider.py 219 to 220 suggests this); instead recurring_ical_events==3.5.2's own of(cal).between(...) raises KeyError: 'DTSTART' before that guard is ever reached (recurring_ical_events/selection/name.py:96). A malformed feed with a DTSTART-less VEVENT crashes get_available_slots's iCal branch instead of being tolerated - caught by the branch's own except Exception at calendar_integration_service.py 593 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 neither DTEND nor DURATION to default to a 1-hour busy period (ical_provider.py 226 to 235); at the pinned library version recurring_ical_events drops the event from its expansion entirely, so parse_busy_periods returns [] 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 the uq_booking_slot_meeting_type_starts_at partial index from an earlier test in the same run, returning 409 RES_003 instead 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.py 59 to 64 vs services/calendar_integration_service.py 611 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)), but SLOT_HOLDING_STATUSES is not imported anywhere in calendar_integration_service.py (grep confirms zero occurrences) - both get_available_slots and get_availability_slots filter existing bookings on status == BookingStatus.CONFIRMED.value only. 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.py 69 to 76 vs the rest of the class): self.tz_service (a TimezoneService instance, utils/timezone.py) is constructed in __init__ and never called anywhere else in the file (grep for tz_service outside __init__ returns nothing). get_available_slots/get_availability_slots build ZoneInfo objects directly instead of using the TimezoneService methods 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 use zoneinfo.ZoneInfo directly (what the real code does) rather than porting TimezoneService, 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 of ical_provider.parse_busy_periods's own hand-written fallback branches are unreachable dead code - see Tests. A VEVENT with no DTSTART crashes the recurrence expansion (KeyError) rather than being skipped by the function's own if dtstart is None guard; a VEVENT with neither DTEND nor DURATION is silently dropped by the library rather than defaulted to 1 hour by the function's own fallback. Both are caught one level up by get_available_slots's per-provider try/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.py 168 to 171 vs caldav_provider.py 145 to 146): the two "read a feed" providers disagree on all-day events. iCal-feed all-day events (a date, not a datetime) 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_utc helper is imported by both files (caldav_provider.py line 22), but caldav_provider._events_to_busy filters all-day events out BEFORE _to_utc is 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.py 805 to 1044 vs 517 to 803): get_availability_slots (the onboarding-workshop sibling) has no iCal/CalDAV branch at all - it always calls get_calendar_service, which raises on a non-Google integration. The onboarding availability route (calendar_router.py 2372 to 2391) wraps the whole call in a bare except 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 at app_fastapi.py 845, PUBLIC_CORS_PREFIXES entries at app_fastapi.py 466, 473.
  • alchemy_labs/backend/models/calendar_integration_model.py: CalendarIntegration (82 to 159), BookingMeetingType (161 to 257), CalendarBooking (260 to 371, including SLOT_HOLDING_STATUSES at 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 is TestParseBusyPeriods/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).

  1. 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, with the three named failures exactly as in Tests (any other failure signature means something besides the known recurring_ical_events gaps changed).
  2. grep -c "def test_public_availability" tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py prints 5 (the five route-level GET /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 narrower pytest invocation 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-scoped create_all() does not always round-trip cleanly between separate invocations, and in practice a second bare invocation collides on CREATE TYPE clientstatus against state the first invocation left behind).
  3. grep -n "SLOT_HOLDING_STATUSES" services/calendar_integration_service.py prints nothing (confirms the Gotcha: the tuple is not referenced in this file at this commit).
  4. grep -n "self.tz_service" services/calendar_integration_service.py prints exactly one line (76, the constructor assignment; confirms it is otherwise unused).
  5. grep -cE "^ *def test_" tests/v2/coverage/test_booking_g1_public_booking_flow_availability_book_cancel_confirm_.py prints 34; the same pattern against test_booking_g6_pure_service_logic_ics_builder_ical_caldav_parsers_booking_conversion_mapping.py prints 48 (82 total, matching Tests; G6's are all indented inside Test* classes, so an unindented ^def test_ pattern alone undercounts it as 0).
  6. python -c "import importlib.metadata as m; print(m.version('recurring-ical-events'), m.version('icalendar'))" (run inside .venv) prints 3.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 - use importlib.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.