# stapel-calendar 0.5.0

Calendar and scheduling: events with participants and RSVP, RFC 5545 recurring series (virtual expansion, on-demand materialization, per-instant cancellation and reschedule), availability windows with free/busy and bookable slot computation, ICS export, and event-driven reminders.

Contract: axes 1 · surface 31 · extension points 5 · operations 10 · error codes 49.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- VISIBILITY [enum, default "participants"] — Who can see an event
  Controls the read surface of every event. "participants" (default) shows an event only to the people invited to it — the private-calendar model. "scope" makes events visible to everyone in the same workspace/org/tenant (as resolved by the scope provider) — a shared team calendar. Values: participants, scope. It changes what listing, calendar and detail endpoints return, not which endpoints exist.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### predicate
- is_rule_instant — stapel_calendar.recurrence.is_rule_instant
  Is this datetime an actual instant of the rule, inside its UNTIL/COUNT bounds — compared in UTC space, so a DST-transition wall time still matches. Validate a requested occurrence start with this before materializing or booking it.
### factory
- add_duration — stapel_calendar.recurrence.add_duration
  instead of: datetime.datetime.__add__
  start + duration in instant space rather than wall-clock. Use it for anything derived from an occurrence start: plain datetime addition across a spring-forward gap yields end < start, and across a fall-back fold it inflates the duration.
- as_utc — stapel_calendar.recurrence.as_utc
  Normalize an instant to UTC before comparing or keying it (a naive datetime passes through unchanged). Every occurrence comparison in the engine goes through this: PEP 495 makes == and hash of inter-zone aware datetimes disagree on DST gap/fold wall times, so a raw dict lookup misses precisely the occurrences that matter.
- build_rrule — stapel_calendar.recurrence.build_rrule
  instead of: dateutil.rrule.rrule
  Build the canonical RRULE string for a preset plus runtime params, with the RFC 5545 rules enforced at BUILD time: count and until are mutually exclusive, and a naive UNTIL against an aware start is refused here rather than 500-ing on every later expansion of the stored series. Instead of assembling an RRULE by hand or going to dateutil directly.
- cancel_occurrence — stapel_calendar.services.cancel_occurrence
  Cancel a single instant of a series — the RFC 5545 EXDATE analog — by tombstoning it. Use this and never a hard delete of the occurrence row: deleting lets the virtual occurrence at that instant resurrect, and the slot goes busy again.
- compute_slots — stapel_calendar.services.compute_slots
  List-only compute_slots_detailed — good for a preview. A real booking flow wants the detailed variant: a slot computed from a truncated expansion may collide with an occurrence nobody computed.
- compute_slots_detailed — stapel_calendar.services.compute_slots_detailed
  Bookable slots for a user: their availability windows inside the range, minus busy time, chunked into fixed blocks — the booking primitive a scheduling product should call instead of writing its own slot cutter. Days are iterated in each window's OWN timezone (deriving them from the range's zone drops a window on the edge of the range), and truncated must be honoured before a slot is offered.
- create_event — stapel_calendar.services.create_event
  Create an event — or a series master — the way the engine expects: end >= start validated, the canonical RRULE derived from a preset, and the owner seeded as an ACCEPTED participant in the same batch as the invitees. Call this rather than Event.objects.create(): a hand-built row carries no owner participant, so it is invisible to free/busy and to its own owner's calendar.
- event_to_vevent — stapel_calendar.ics.event_to_vevent
  Serialize one event to its VEVENT lines (a series master carries its RRULE, a concrete event omits it) — the per-event grain, for assembling a VCALENDAR of your own shape. to_ics() is the whole-calendar wrapper.
- expand_event — stapel_calendar.services.expand_event
  List-only expand_event_detailed, for display code that does no availability math. If you are deciding whether something can be booked, take the detailed variant and honour truncated.
- expand_event_detailed — stapel_calendar.services.expand_event_detailed
  The occurrences of a series over a range, persisting nothing — materialized instants report their concrete id and actual (possibly rescheduled) times, cancelled ones are omitted. The read primitive behind any calendar view; its truncated flag means the MAX_EXPANSION_OCCURRENCES cap was hit and everything past it merely looks free.
- expand_rule — stapel_calendar.recurrence.expand_rule
  instead of: dateutil.rrule.rrule.between
  List-only expand_rule_detailed, with the same caveat as everywhere else here: availability math needs the truncated flag, a date preview does not.
- expand_rule_detailed — stapel_calendar.recurrence.expand_rule_detailed
  instead of: dateutil.rrule.rrule.between
  Expand a bare RRULE over a range with no Event behind it — the preview path for a rule the user has not saved yet. Lazily iterated and capped at MAX_EXPANSION_OCCURRENCES with a truncated flag, so an unbounded rule over a wide range stays bounded work; dateutil's own between() would materialize the lot.
- free_busy — stapel_calendar.services.free_busy
  List-only free_busy_detailed. Do not base a booking decision on it: it drops the truncated flag, and a truncated expansion makes the tail of the range look free.
- free_busy_detailed — stapel_calendar.services.free_busy_detailed
  Busy intervals for a user over a range: concrete events plus the virtual occurrences of every series they are on, without double-counting the materialized ones, minus cancelled and declined ones, merged and clipped to the range. This is the in-process entry point behind the calendar.free_busy comm Function — call it directly in a monolith, call the Function across services.
- get_presets — stapel_calendar.recurrence.get_presets
  The effective preset map — built-ins merged with STAPEL_CALENDAR["PRESETS"] and any runtime registration, names set to None removed. Read this to render a recurrence picker; merging BUILTIN_PRESETS with the setting yourself gets the removals wrong.
- get_reminder_policy — stapel_calendar.reminders.get_reminder_policy
  The configured ReminderPolicy instance, already resolved from the REMINDER_POLICY seam. Reach for it only to drive the policy for a single event outside the cron scan — an admin "remind now" action — and never to read the setting yourself; the periodic path is run_reminders.
- materialize — stapel_calendar.services.materialize
  Persist one occurrence of a series so it can carry its own state, and fire calendar.occurrence.materialized for whoever attaches the resource. Idempotent and race-safe under concurrent booking; an off-rule instant is refused unless you pass off_rule=True. Creating the child row yourself produces a ghost occurrence — counted busy, invisible to expansion — and no resource hook.
- merge_intervals — stapel_calendar.services.merge_intervals
  Sort and coalesce overlapping or adjacent intervals, dropping degenerate ones — the general-purpose sweep every availability answer here is built on. Reach for it instead of writing one more merge loop; mishandling zero-length and inverted intervals is what shifts a slot grid.
- normalize_weekdays — stapel_calendar.recurrence.normalize_weekdays
  Coerce a CSV string or an iterable into a sorted, unique, range-checked list of 0=Mon..6=Sun ints — the one place weekday input is validated (out of range raises InvalidRecurrence). Reach for it when importing legacy recurrence_days data instead of splitting the string yourself.
- parse_ics — stapel_calendar.ics.parse_ics
  Read VEVENT blocks back into dicts (uid/summary/description/start/end/rrule/status). Deliberately minimal — enough for an export/import round-trip and a simple sync consumer; parsing arbitrary foreign calendars is still a job for a full iCalendar library.
- parse_rrule — stapel_calendar.recurrence.parse_rrule
  instead of: dateutil.rrule.rrulestr
  Parse a stored canonical rule anchored at its DTSTART, converting dateutil's ValueError/TypeError into InvalidRecurrence — so a bad stored rule surfaces as this module's error class and not as a 500 from a third-party parser.
- register_preset — stapel_calendar.recurrence.register_preset
  Register or override a recurrence preset at runtime (a dict of dateutil.rrule kwargs; None removes a built-in) — the code-side half of the PRESETS seam, for a module or an AppConfig.ready() that cannot go through settings.
- replace_participants — stapel_calendar.services.replace_participants
  Replace an event's guest list with PUT semantics: the owner is always retained as ACCEPTED, ids already on the event KEEP their existing RSVP, new ids arrive INVITED, dropped ids are deleted. Idempotent — reach for it instead of diffing participant rows yourself, where the classic bug is a re-sent list silently resetting an accepted invite back to invited.
- reset_presets — stapel_calendar.recurrence.reset_presets
  Tests only: drop the runtime preset overrides. The registry is process-global, so a test that calls register_preset must reset it in teardown or leak into every later test — do that here instead of reaching into the private registry dict.
- respond — stapel_calendar.services.respond
  Record a participant's RSVP — the backing call of POST /events/{id}/respond, for programmatic answers (a bot accepting on a user's behalf, a data migration). Instead of assigning Participant.rsvp, which skips the updated_at bump and the never-invited check.
- rrule_inputs — stapel_calendar.recurrence.rrule_inputs
  Recover the recurrence params (interval / weekdays / until / count) from a stored canonical RRULE — the inverse of build_rrule. The library persists only the rule string, so anything that has to show or partially edit "every 2 weeks until March" must call this instead of parsing the RRULE itself.
- run_reminders — stapel_calendar.reminders.run_reminders
  The reminder entry point a host cron must call on a schedule (once per REMINDER_SCAN_WINDOW_SECONDS): it scans concrete upcoming events, asks the configured policy what is due, emits calendar.event.reminder_due and returns how many. This module ships NO scheduler — nothing here fires on its own, and a deployment that never wires this gets no reminders and no error.
- subtract_intervals — stapel_calendar.services.subtract_intervals
  The parts of a window not covered by a list of busy intervals — the other half of the availability algebra, paired with merge_intervals. Call it instead of hand-rolling overlap subtraction.
- to_ics — stapel_calendar.ics.to_ics
  instead of: icalendar.Calendar
  Wrap one event or an iterable of them in a VCALENDAR — how to ship an .ics subscription feed or attach an invite to an outgoing letter without adding an iCalendar dependency or hand-writing CRLF-folded output.
- update_event — stapel_calendar.services.update_event
  The safe edit path for an event or a series master: partial field update plus, when a recurrence input or the DTSTART anchor moves, a rule rebuild with every materialized occurrence reconciled against the new rule (still-valid instants kept, orphaned tombstones deleted, orphaned real occurrences detached into standalone events). Assigning rrule/start on the model leaves children claiming instants their series no longer has, and silently drops a bounded series' UNTIL.

## Extension points — what a product replaces, fork-free
- PRESETS [merge_registry]
  Add or override recurrence presets (dateutil rrule kwargs) merged over the built-ins (none/daily/weekdays/weekly/biweekly/monthly/custom); None removes a built-in.
- REMINDER_POLICY [dotted_path]
  Swap the reminder policy (when and what to remind) — the default emits one reminder_due event per configured offset; subclass to change cadence or channel decisions without forking.
- SCOPE_PROVIDER [dotted_path]
  Swap how the opaque scope_key (workspace/org/tenant) is resolved from the request and enforced on querysets — the default is a single global scope; a host may return the active workspace_id.
- calendar.occurrence.materialized [comm_event]
  The resource hook: when a recurring occurrence is materialized the engine emits this event (plus a Django signal) — the app layer subscribes to attach its resource (e.g. a video room) without any coupling in the engine.
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — every endpoint requires an authenticated user (IsAuthenticated); stapel-auth is the shelf's session issuer — any stapel-core-compatible JWT issuer satisfies the check
- stapel-core (required) — comm bus (occurrence.materialized / reminder_due emits, calendar.free_busy Function), JWT authentication, AppSettings config layer
- stapel-notifications (optional) — delivers the calendar.event.reminder_due events the default reminder policy emits; reminders degrade to no-ops without a subscriber

## HTTP operations (10) — call by operationId, never by a typed path
Paths are relative to `/calendar/api/v1/`.
### Calendar
- GET /availability — calendar_api_v1_availability_retrieve
- GET /calendar — calendar_api_v1_calendar_retrieve
- POST /events — calendar_api_v1_events_create
- DELETE /events/{event_id} — calendar_api_v1_events_destroy
- GET /events/{event_id}/ics — calendar_api_v1_events_ics_retrieve
- GET /events — calendar_api_v1_events_list
- PATCH /events/{event_id} — calendar_api_v1_events_partial_update
- PUT /events/{event_id}/participants — calendar_api_v1_events_participants_update
- POST /events/{event_id}/respond — calendar_api_v1_events_respond_create
- GET /events/{event_id} — calendar_api_v1_events_retrieve

## Error codes (49) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.calendar_invalid_range [400] fix_input
- error.400.calendar_invalid_recurrence [400] fix_input
- error.400.calendar_invalid_rsvp [400] fix_input
- error.400.calendar_invalid_slot_minutes [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.calendar_not_event_owner [403] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.calendar_event_not_found [404] retry
- error.404.calendar_not_invited [404] fix_input
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
