1003 lines
48 KiB
Markdown
1003 lines
48 KiB
Markdown
|
|
# Calendar Resources
|
||
|
|
|
||
|
|
This document describes the design and implementation plan for **calendar resources** in La Suite Calendars: meeting rooms, vehicles, projectors, and any other bookable assets that people can reserve alongside events.
|
||
|
|
|
||
|
|
## Table of Contents
|
||
|
|
|
||
|
|
- [Overview](#overview)
|
||
|
|
- [What Is a Calendar Resource?](#what-is-a-calendar-resource)
|
||
|
|
- [Resource Scheduling Addresses](#resource-scheduling-addresses-not-real-emails)
|
||
|
|
- [Email Safety](#email-safety)
|
||
|
|
- [CalDAV Standards and Interoperability](#caldav-standards-and-interoperability)
|
||
|
|
- [Data Model](#data-model)
|
||
|
|
- [Resource Lifecycle](#resource-lifecycle)
|
||
|
|
- [Booking Flow](#booking-flow)
|
||
|
|
- [Auto-Scheduling and Conflict Detection](#auto-scheduling-and-conflict-detection)
|
||
|
|
- [Free/Busy and Availability](#freebusy-and-availability)
|
||
|
|
- [Access Rights and Administration](#access-rights-and-administration)
|
||
|
|
- [Sharing and Delegation](#sharing-and-delegation)
|
||
|
|
- [Resource Discovery](#resource-discovery)
|
||
|
|
- [Interoperability with CalDAV Clients](#interoperability-with-caldav-clients)
|
||
|
|
- [What SabreDAV Provides (and What It Doesn't)](#what-sabredav-provides-and-what-it-doesnt)
|
||
|
|
- [Implementation Plan](#implementation-plan)
|
||
|
|
- [Phase 1: Resource Principals in SabreDAV](#phase-1-resource-principals-in-sabredav)
|
||
|
|
- [Phase 2: Django Resource Management](#phase-2-django-resource-management)
|
||
|
|
- [Phase 3: Auto-Scheduling Plugin](#phase-3-auto-scheduling-plugin)
|
||
|
|
- [Phase 4: Frontend Resource UI](#phase-4-frontend-resource-ui)
|
||
|
|
- [Phase 5: Advanced Features](#phase-5-advanced-features)
|
||
|
|
- [Database Schema Changes](#database-schema-changes)
|
||
|
|
- [API Design](#api-design)
|
||
|
|
- [SabreDAV Plugin Design](#sabredav-plugin-design)
|
||
|
|
- [Frontend Components](#frontend-components)
|
||
|
|
- [Migration and Deployment](#migration-and-deployment)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
Calendar resources allow organizations to manage shared physical assets (rooms, vehicles, equipment) through the calendar. Users book resources by adding them as attendees to events, just like inviting a person. The system handles availability checking, conflict prevention, and automatic accept/decline responses.
|
||
|
|
|
||
|
|
**Key principles:**
|
||
|
|
|
||
|
|
- Resources are modeled as CalDAV principals, following RFC 6638
|
||
|
|
- Any CalDAV client can book a resource by inviting its email address
|
||
|
|
- The server handles auto-scheduling (accept if free, decline if busy)
|
||
|
|
- Resource provisioning (create/delete) happens through Django; all other management through CalDAV
|
||
|
|
- Double-booking prevention is enforced server-side
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## What Is a Calendar Resource?
|
||
|
|
|
||
|
|
A calendar resource is a bookable entity that is not a person. Resources fall into two categories defined by the iCalendar standard (RFC 5545):
|
||
|
|
|
||
|
|
| Type | CUTYPE | Examples |
|
||
|
|
|------|--------|----------|
|
||
|
|
| **Room** | `ROOM` | Conference rooms, meeting rooms, auditoriums, phone booths |
|
||
|
|
| **Resource** | `RESOURCE` | Projectors, vehicles, cameras, whiteboards, parking spots |
|
||
|
|
|
||
|
|
Each resource has:
|
||
|
|
- A **display name** (e.g., "Room 101 - Large Conference")
|
||
|
|
- A **scheduling address** (a `mailto:` URI used as a CalDAV identifier -- see below)
|
||
|
|
- A **calendar** that shows its bookings
|
||
|
|
- **Metadata** (capacity, location, description, equipment list)
|
||
|
|
- **Availability hours** (e.g., 8:00-20:00 on weekdays)
|
||
|
|
- An **auto-schedule policy** (auto-accept, require approval, etc.)
|
||
|
|
- One or more **administrators** who manage the resource
|
||
|
|
|
||
|
|
### Resource Scheduling Addresses (Not Real Emails)
|
||
|
|
|
||
|
|
In CalDAV, every principal (user or resource) is identified by a `mailto:` URI in the `calendar-user-address-set` property. This is how the scheduling protocol matches an `ATTENDEE` on an event to a principal on the server. **These addresses do not need to be real, routable email addresses.**
|
||
|
|
|
||
|
|
When SabreDAV receives a scheduling request for `mailto:c_a1b2c3d4@resource.calendar.example.com`, it looks up the `email` column in the `principals` table. If a matching principal is found locally, the iTIP message is delivered **internally** (inbox-to-inbox on the same server). See [Email Safety](#email-safety) for how outbound emails to resource addresses are prevented.
|
||
|
|
|
||
|
|
**Recommended convention**: `{opaque-id}@resource.calendar.{APP_DOMAIN}`
|
||
|
|
|
||
|
|
The address uses:
|
||
|
|
- An **opaque identifier** (UUID or short hash, prefixed with `c_`) rather than a human-readable slug, so renaming a resource doesn't change its address
|
||
|
|
- A **subdomain you control** (`resource.calendar.{APP_DOMAIN}`), which avoids collisions with real user emails and leaves the door open for future inbound email (e.g., resources acting as meeting organizers)
|
||
|
|
|
||
|
|
Examples (with `APP_DOMAIN=example.com`):
|
||
|
|
- `c_a1b2c3d4@resource.calendar.example.com`
|
||
|
|
- `c_f6g7h8i9@resource.calendar.example.com`
|
||
|
|
|
||
|
|
By default, no MX record is configured for this subdomain, so inbound email silently fails -- same practical effect as a non-routable address, but reversible if resources need to send/receive email in the future (e.g., room-initiated meetings with external attendees).
|
||
|
|
|
||
|
|
Org scoping is handled at the CalDAV/application level, not encoded in the email address.
|
||
|
|
|
||
|
|
The system identifies resource principals via the `calendar_user_type` column in the SabreDAV principals table, not by email pattern.
|
||
|
|
|
||
|
|
### Email Safety
|
||
|
|
|
||
|
|
Resource scheduling addresses are **not real mailboxes**. No email should ever be sent to or from them. However, the current invitation system (`HttpCallbackIMipPlugin` -> Django -> email) will attempt to email any attendee unless explicitly prevented. This must be handled at two levels:
|
||
|
|
|
||
|
|
#### Internal: Preventing Outbound Emails to Resources
|
||
|
|
|
||
|
|
In the current flow, when a user adds attendees to an event, `HttpCallbackIMipPlugin` POSTs to Django for each attendee, and Django sends an invitation email. Without intervention, Django would attempt to email the resource address.
|
||
|
|
|
||
|
|
**Fix 1 (SabreDAV)**: The `ResourceAutoSchedulePlugin` (priority 120) sets `$message->scheduleStatus` on the iTIP message before `HttpCallbackIMipPlugin` runs. The base `IMipPlugin` class skips messages that already have a status set, preventing the callback entirely.
|
||
|
|
|
||
|
|
**Fix 2 (Django, safety net)**: `CalendarInvitationService.send_invitation()` checks if the recipient is a resource address (by checking `calendar_user_type` of the principal or matching the `@resource.calendar.example.com` domain) and skips email sending.
|
||
|
|
|
||
|
|
Both should be implemented. The plugin is the primary gate; Django is the fallback.
|
||
|
|
|
||
|
|
#### External: What Happens When Outside Systems See the Address
|
||
|
|
|
||
|
|
When an event includes both a resource and external attendees, the ICS attachment in invitation emails lists all `ATTENDEE` properties, including the resource's `mailto:c_abc123@resource.calendar.example.com`. This is visible to external recipients. Here's why this is safe:
|
||
|
|
|
||
|
|
| Scenario | What happens | Risk |
|
||
|
|
|----------|-------------|------|
|
||
|
|
| External email client tries to send iTIP REPLY | REPLY goes to ORGANIZER only (the human), not to other attendees | None |
|
||
|
|
| External email client tries to contact all attendees | Email to `@resource.calendar.example.com` bounces (no MX record by default) | None |
|
||
|
|
| External CalDAV server tries to book the resource | iTIP REQUEST email to `@resource.calendar.example.com` bounces | None -- resources are only bookable through this server |
|
||
|
|
| CalDAV client connected to THIS server | Uses `SCHEDULE-AGENT=SERVER`; all scheduling goes through SabreDAV, no email | None |
|
||
|
|
|
||
|
|
By default no MX record exists for `resource.calendar.{APP_DOMAIN}`, so external email attempts bounce harmlessly. All legitimate resource interactions go through the CalDAV server. If resources need to send/receive email in the future (e.g., room-initiated meetings), an MX record can be added and inbound mail routed to the application.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## CalDAV Standards and Interoperability
|
||
|
|
|
||
|
|
### Relevant Standards
|
||
|
|
|
||
|
|
| Standard | Role |
|
||
|
|
|----------|------|
|
||
|
|
| **RFC 5545** (iCalendar) | Defines `CUTYPE` parameter (`ROOM`, `RESOURCE`) on `ATTENDEE` properties |
|
||
|
|
| **RFC 6638** (CalDAV Scheduling) | Defines `calendar-user-type` DAV property on principals; scheduling transport (inbox/outbox); `SCHEDULE-AGENT` parameter |
|
||
|
|
| **RFC 4791** (CalDAV) | Calendar collections, `free-busy-query` REPORT |
|
||
|
|
| **RFC 7953** (Calendar Availability) | `VAVAILABILITY` component for defining operating hours (future) |
|
||
|
|
| **draft-cal-resource-schema** | Resource metadata schema (capacity, booking window, manager, etc.) |
|
||
|
|
| **draft-pot-caldav-sharing** | Calendar sharing between principals |
|
||
|
|
|
||
|
|
### How Resources Work in CalDAV
|
||
|
|
|
||
|
|
In CalDAV, a resource is a **regular principal** with a special `calendar-user-type` property set to `ROOM` or `RESOURCE`. It has its own calendar home, schedule inbox, and schedule outbox, exactly like a user principal.
|
||
|
|
|
||
|
|
When a user creates an event with a resource as an attendee:
|
||
|
|
|
||
|
|
```
|
||
|
|
ATTENDEE;CUTYPE=ROOM;ROLE=NON-PARTICIPANT;PARTSTAT=NEEDS-ACTION;
|
||
|
|
RSVP=TRUE:mailto:c_a1b2c3d4@resource.calendar.example.com
|
||
|
|
```
|
||
|
|
|
||
|
|
The CalDAV scheduling server (RFC 6638):
|
||
|
|
1. Detects the `ATTENDEE` on the event
|
||
|
|
2. Delivers an iTIP `REQUEST` to the resource's schedule inbox
|
||
|
|
3. A server-side agent checks the resource's calendar for conflicts
|
||
|
|
4. Sends an iTIP `REPLY` back with `PARTSTAT=ACCEPTED` or `PARTSTAT=DECLINED`
|
||
|
|
|
||
|
|
**Auto-scheduling is not standardized** -- it is a server implementation feature. RFC 6638 only defines the transport mechanism. This project implements auto-scheduling as a custom SabreDAV plugin (see [What SabreDAV Provides](#what-sabredav-provides-and-what-it-doesnt) for rationale).
|
||
|
|
|
||
|
|
### What This Means for Interoperability
|
||
|
|
|
||
|
|
The critical insight: **resource booking does not require client-side support**. Any CalDAV client that can add an attendee by email address can book a resource. The server handles everything else. This means:
|
||
|
|
|
||
|
|
- Apple Calendar, Thunderbird, GNOME Calendar, and all CalDAV clients work out of the box
|
||
|
|
- Clients that support `CUTYPE` can display resources differently from people
|
||
|
|
- Clients that support `principal-property-search` can discover available resources
|
||
|
|
- The web frontend provides the richest experience (resource browser, availability view)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Data Model
|
||
|
|
|
||
|
|
### Design Principle: CalDAV as Single Source of Truth
|
||
|
|
|
||
|
|
Resources are **entirely managed in CalDAV** -- metadata, calendar data, and access control. No Django model is needed.
|
||
|
|
|
||
|
|
- **Metadata** (name, capacity, location, equipment, etc.): DAV properties via PROPFIND/PROPPATCH
|
||
|
|
- **Calendar data** (bookings, free/busy): CalDAV calendar collections
|
||
|
|
- **Access control**: CalDAV sharing (`CS:share`) with privilege levels, the same mechanism already used for user calendar sharing
|
||
|
|
- **Scheduling config** (auto-schedule mode, booking policies): Custom DAV properties read by the SabreDAV plugin
|
||
|
|
|
||
|
|
Django's role is limited to:
|
||
|
|
- **Provisioning**: A REST endpoint to create/delete resource principals. CalDAV has no standard operation to create a principal, so this is the one justified exception to the "Django is a pass-through" rule. Django makes CalDAV requests to SabreDAV to set up the principal + calendar + initial properties.
|
||
|
|
- **Proxying**: The existing CalDAV proxy (`CalDAVProxyView`) forwards all CalDAV requests, including those for resource principals
|
||
|
|
|
||
|
|
This means **zero new Django models** for resources. The frontend manages resource permissions via `CalDavService.shareCalendar()` / `getCalendarSharees()`, exactly like it already does for user calendars.
|
||
|
|
|
||
|
|
### Resource as a CalDAV Principal
|
||
|
|
|
||
|
|
Each resource exists as a principal in SabreDAV with a single
|
||
|
|
dedicated calendar:
|
||
|
|
|
||
|
|
```
|
||
|
|
principals/resources/{resource-id}
|
||
|
|
-> calendar-home-set: /calendars/resources/{resource-id}/
|
||
|
|
-> schedule-inbox-URL: /calendars/resources/{resource-id}/inbox/
|
||
|
|
-> schedule-outbox-URL: /calendars/resources/{resource-id}/outbox/
|
||
|
|
-> calendar-user-type: ROOM | RESOURCE
|
||
|
|
-> calendar-user-address-set: mailto:{opaque-id}@resource.calendar.example.com
|
||
|
|
```
|
||
|
|
|
||
|
|
The `mailto:` address is a CalDAV internal identifier, not a real
|
||
|
|
mailbox (see [Resource Scheduling Addresses](#resource-scheduling-addresses-not-real-emails)).
|
||
|
|
|
||
|
|
**A resource principal has exactly one calendar.** Although CalDAV
|
||
|
|
allows any principal to own multiple calendar collections, this
|
||
|
|
doesn't make sense for resources (a room has one schedule).
|
||
|
|
`MKCALENDAR` requests targeting a resource principal's calendar
|
||
|
|
home are rejected by a SabreDAV plugin (hooking
|
||
|
|
`beforeMethod:MKCALENDAR`). The single calendar is created during
|
||
|
|
provisioning and cannot be added to or removed independently.
|
||
|
|
|
||
|
|
### Resource Properties (CalDAV)
|
||
|
|
|
||
|
|
All resource metadata is stored as DAV properties on the resource's principal or default calendar, using standard properties where they exist and a project namespace (`{urn:lasuite:calendars}`) for the rest.
|
||
|
|
|
||
|
|
SabreDAV's `PropertyStorage` plugin persists custom properties in a `propertystorage` table automatically -- any property set via PROPPATCH is stored and returned via PROPFIND.
|
||
|
|
|
||
|
|
#### Standard Properties (on the principal or calendar collection)
|
||
|
|
|
||
|
|
| Property | Namespace | Where | Description |
|
||
|
|
|----------|-----------|-------|-------------|
|
||
|
|
| `displayname` | `{DAV:}` | Principal | Resource name |
|
||
|
|
| `calendar-user-type` | `{urn:ietf:params:xml:ns:caldav}` | Principal | `ROOM` or `RESOURCE` |
|
||
|
|
| `calendar-description` | `{urn:ietf:params:xml:ns:caldav}` | Calendar | Free-form description |
|
||
|
|
| `calendar-color` | `{http://apple.com/ns/ical/}` | Calendar | Hex color (e.g., `#4CAF50`) |
|
||
|
|
| `calendar-timezone` | `{urn:ietf:params:xml:ns:caldav}` | Calendar | VTIMEZONE component |
|
||
|
|
|
||
|
|
#### Semi-Standard Properties (Apple CalendarServer)
|
||
|
|
|
||
|
|
| Property | Namespace | Where | Description |
|
||
|
|
|----------|-----------|-------|-------------|
|
||
|
|
| `capacity` | `{http://calendarserver.org/ns/}` | Principal | Integer, number of seats/units |
|
||
|
|
|
||
|
|
#### Custom Properties (Project Namespace)
|
||
|
|
|
||
|
|
| Property | Namespace | Where | Type | Description |
|
||
|
|
|----------|-----------|-------|------|-------------|
|
||
|
|
| `location` | `{urn:lasuite:calendars}` | Principal | String | Building, floor, address |
|
||
|
|
| `equipment` | `{urn:lasuite:calendars}` | Principal | JSON array | `["Projector", "Whiteboard"]` |
|
||
|
|
| `tags` | `{urn:lasuite:calendars}` | Principal | JSON array | `["building-a", "video"]` |
|
||
|
|
| `auto-schedule-mode` | `{urn:lasuite:calendars}` | Principal | String | See auto-schedule modes below |
|
||
|
|
| `is-active` | `{urn:lasuite:calendars}` | Principal | Boolean | `true` / `false` |
|
||
|
|
| `restricted-access` | `{urn:lasuite:calendars}` | Principal | Boolean | Restrict booking to explicit access |
|
||
|
|
| `multiple-bookings` | `{urn:lasuite:calendars}` | Principal | Integer | Max concurrent bookings (1 = no overlap) |
|
||
|
|
| `max-booking-duration` | `{urn:lasuite:calendars}` | Principal | Duration | ISO 8601 (e.g., `PT4H`) |
|
||
|
|
| `booking-window-start` | `{urn:lasuite:calendars}` | Principal | Duration | How far ahead (e.g., `P90D`) |
|
||
|
|
| `booking-window-end` | `{urn:lasuite:calendars}` | Principal | Duration | Minimum notice (e.g., `PT1H`) |
|
||
|
|
|
||
|
|
#### Example: Reading Resource Properties
|
||
|
|
|
||
|
|
```xml
|
||
|
|
PROPFIND /principals/resources/room-101/
|
||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<D:propfind xmlns:D="DAV:"
|
||
|
|
xmlns:C="urn:ietf:params:xml:ns:caldav"
|
||
|
|
xmlns:A="http://apple.com/ns/ical/"
|
||
|
|
xmlns:CS="http://calendarserver.org/ns/"
|
||
|
|
xmlns:LS="urn:lasuite:calendars">
|
||
|
|
<D:prop>
|
||
|
|
<D:displayname/>
|
||
|
|
<C:calendar-user-type/>
|
||
|
|
<CS:capacity/>
|
||
|
|
<LS:location/>
|
||
|
|
<LS:equipment/>
|
||
|
|
<LS:tags/>
|
||
|
|
<LS:auto-schedule-mode/>
|
||
|
|
<LS:is-active/>
|
||
|
|
</D:prop>
|
||
|
|
</D:propfind>
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Example: Setting Resource Properties
|
||
|
|
|
||
|
|
```xml
|
||
|
|
PROPPATCH /principals/resources/room-101/
|
||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<D:propertyupdate xmlns:D="DAV:"
|
||
|
|
xmlns:CS="http://calendarserver.org/ns/"
|
||
|
|
xmlns:LS="urn:lasuite:calendars">
|
||
|
|
<D:set>
|
||
|
|
<D:prop>
|
||
|
|
<CS:capacity>20</CS:capacity>
|
||
|
|
<LS:location>Building A, Floor 2</LS:location>
|
||
|
|
<LS:equipment>["Projector", "Whiteboard", "Video conferencing"]</LS:equipment>
|
||
|
|
<LS:tags>["video-conference", "whiteboard", "building-a"]</LS:tags>
|
||
|
|
<LS:auto-schedule-mode>automatic</LS:auto-schedule-mode>
|
||
|
|
</D:prop>
|
||
|
|
</D:set>
|
||
|
|
</D:propertyupdate>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Auto-Schedule Modes
|
||
|
|
|
||
|
|
Stored as the `{urn:lasuite:calendars}auto-schedule-mode` property on the resource principal. Inspired by Apple Calendar Server's proven model:
|
||
|
|
|
||
|
|
| Mode | Behavior |
|
||
|
|
|------|----------|
|
||
|
|
| `automatic` | Accept if free, decline if busy (default) |
|
||
|
|
| `accept-always` | Accept all invitations regardless of conflicts |
|
||
|
|
| `decline-always` | Decline all invitations (resource offline) |
|
||
|
|
| `manual` | Require a resource manager to accept/decline |
|
||
|
|
|
||
|
|
The `ResourceAutoSchedulePlugin` reads this property from SabreDAV's `propertystorage` table (same PostgreSQL instance) during scheduling -- no Django roundtrip needed.
|
||
|
|
|
||
|
|
### Resource Access (CalDAV Sharing)
|
||
|
|
|
||
|
|
Resource access uses the **same CalDAV sharing mechanism** (`CS:share`) already used for user calendar sharing. No Django model is needed.
|
||
|
|
|
||
|
|
The resource's calendar is shared with administrators/managers using the existing privilege levels:
|
||
|
|
|
||
|
|
| CalDAV Privilege | Role | Can Do |
|
||
|
|
|-----------------|------|--------|
|
||
|
|
| `read` | Viewer | See bookings, free/busy |
|
||
|
|
| `read-write` | Manager | See bookings, modify/cancel any booking |
|
||
|
|
| `admin` | Admin | Full control: edit properties, manage sharing, override bookings |
|
||
|
|
|
||
|
|
The frontend manages this via `CalDavService.shareCalendar()` and `getCalendarSharees()` -- the same code already used for sharing user calendars.
|
||
|
|
|
||
|
|
The resource creator is automatically the calendar owner (the principal itself owns the calendar collection). Additional admins are added via sharing.
|
||
|
|
|
||
|
|
### What About Search and Filtering?
|
||
|
|
|
||
|
|
Storing metadata in CalDAV raises the question: how do you filter resources by capacity, tags, or location?
|
||
|
|
|
||
|
|
For most deployments, the number of resources is small (tens to low hundreds). The frontend can:
|
||
|
|
|
||
|
|
1. **PROPFIND** on `principals/resources/` to fetch all resource principals with their properties in a single request
|
||
|
|
2. **Filter and sort client-side** in JavaScript (capacity >= 10, tags include "projector", etc.)
|
||
|
|
|
||
|
|
This is simple, avoids data duplication, and works well up to ~1000 resources. If a deployment needs SQL-level search across thousands of resources, a Django read-only index synced from CalDAV can be added later as an optimization -- but this is not needed for v1.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Resource Lifecycle
|
||
|
|
|
||
|
|
### Creating a Resource
|
||
|
|
|
||
|
|
1. An **org admin** (`can_admin` entitlement) calls the Django
|
||
|
|
REST API to create a resource (name, type)
|
||
|
|
2. Django calls SabreDAV's `/internal-api/resources/` endpoint
|
||
|
|
(POST with JSON body). The `InternalApiPlugin` handles both
|
||
|
|
principal creation and default calendar creation atomically.
|
||
|
|
The admin's `org_id` is passed in the request body.
|
||
|
|
3. The frontend sets additional metadata via PROPPATCH (capacity,
|
||
|
|
location, equipment, etc.)
|
||
|
|
4. The resource is immediately available for booking
|
||
|
|
|
||
|
|
No Django model is created -- the CalDAV principal **is** the
|
||
|
|
resource.
|
||
|
|
|
||
|
|
> **Architecture note:** All resource CRUD goes through the
|
||
|
|
> `/internal-api/` namespace in SabreDAV, which is completely
|
||
|
|
> separate from the CalDAV protocol namespace. This avoids direct
|
||
|
|
> database access from Django to SabreDAV tables. The internal API
|
||
|
|
> is gated by the `X-Internal-Api-Key` header (different from the
|
||
|
|
> `X-Api-Key` used by the CalDAV proxy) and is explicitly blocked
|
||
|
|
> by the Django proxy's path validation.
|
||
|
|
|
||
|
|
### Updating a Resource
|
||
|
|
|
||
|
|
All metadata changes go through CalDAV `PROPPATCH` directly (from the frontend or via a Django proxy endpoint). There is no Django model to keep in sync.
|
||
|
|
|
||
|
|
- Display properties (name, color, description): PROPPATCH on the calendar collection
|
||
|
|
- Resource properties (capacity, equipment, location, tags): PROPPATCH on the principal
|
||
|
|
- Scheduling config (auto-schedule-mode, booking policies): PROPPATCH on the principal
|
||
|
|
- Deactivating a resource: set `{urn:lasuite:calendars}is-active` to `false`
|
||
|
|
|
||
|
|
### Deleting a Resource
|
||
|
|
|
||
|
|
1. Django calls `DELETE /internal-api/resources/{resource-id}` on SabreDAV
|
||
|
|
2. The `InternalApiPlugin` deletes all calendars, calendar objects,
|
||
|
|
scheduling objects, and the principal row atomically
|
||
|
|
3. Sharing entries are automatically cleaned up with the calendar
|
||
|
|
|
||
|
|
Existing events in user calendars that reference the resource as
|
||
|
|
an attendee are **left as-is**. The resource's `mailto:` address
|
||
|
|
becomes an unresolvable address -- same as if an external attendee
|
||
|
|
disappeared. The resource will simply stop responding to scheduling
|
||
|
|
requests. This avoids the complexity of modifying events across
|
||
|
|
all user calendars.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Booking Flow
|
||
|
|
|
||
|
|
### Standard Booking (Any CalDAV Client)
|
||
|
|
|
||
|
|
```
|
||
|
|
User CalDAV Server Auto-Scheduler
|
||
|
|
| | |
|
||
|
|
|-- PUT event ------------>| |
|
||
|
|
| (ATTENDEE=c_...@resource.calendar.example.com)
|
||
|
|
| |-- iTIP REQUEST --------->|
|
||
|
|
| | |-- check free/busy
|
||
|
|
| | |-- no conflict?
|
||
|
|
| |<-- iTIP REPLY -----------|
|
||
|
|
| | (PARTSTAT=ACCEPTED) |
|
||
|
|
|<-- schedule-status ------| |
|
||
|
|
| (1.2 = delivered) | |
|
||
|
|
```
|
||
|
|
|
||
|
|
### Web Frontend Booking
|
||
|
|
|
||
|
|
The web UI provides a richer experience:
|
||
|
|
|
||
|
|
1. User opens event creation modal
|
||
|
|
2. User clicks "Add Room" or "Add Resource"
|
||
|
|
3. A resource picker shows available resources with metadata
|
||
|
|
4. User selects a resource; frontend checks free/busy in real-time
|
||
|
|
5. Frontend adds the resource as an `ATTENDEE` with `CUTYPE=ROOM`
|
||
|
|
6. On save, the CalDAV flow triggers auto-scheduling
|
||
|
|
7. The event updates with the resource's `PARTSTAT` response
|
||
|
|
|
||
|
|
### Booking with Conflicts
|
||
|
|
|
||
|
|
When a resource is already booked:
|
||
|
|
|
||
|
|
1. User creates event with resource as attendee
|
||
|
|
2. Auto-scheduler detects conflict
|
||
|
|
3. Resource declines (`PARTSTAT=DECLINED`)
|
||
|
|
4. Organizer sees the declined status
|
||
|
|
5. Frontend shows a warning: "Room 101 is unavailable at this time"
|
||
|
|
|
||
|
|
### Recurring Event Booking
|
||
|
|
|
||
|
|
For recurring events, the auto-scheduler must check **every instance** within a reasonable window (e.g., 1 year) for conflicts. If any instance conflicts:
|
||
|
|
|
||
|
|
- **Option A (strict)**: Decline the entire series
|
||
|
|
- **Option B (lenient)**: Accept the series but decline specific instances via `EXDATE`
|
||
|
|
|
||
|
|
The recommended approach is **Option A** for simplicity, with the UI helping users find conflict-free times.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## What SabreDAV Provides (and What It Doesn't)
|
||
|
|
|
||
|
|
SabreDAV is the most mature open-source CalDAV server, but its support for resource principals (ROOM/RESOURCE) has gaps. Understanding exactly what's built in vs. what we extend is important.
|
||
|
|
|
||
|
|
### CUTYPE: Extensible but Not Enabled by Default
|
||
|
|
|
||
|
|
RFC 6638 §2.4.2 defines the `{urn:ietf:params:xml:ns:caldav}calendar-user-type` property on principals, with values from RFC 5545's CUTYPE parameter (INDIVIDUAL, ROOM, RESOURCE, GROUP, etc.). SabreDAV **acknowledges** this property but doesn't fully implement it:
|
||
|
|
|
||
|
|
- The [`Schedule\Plugin`](https://github.com/sabre-io/dav/blob/master/lib/CalDAV/Schedule/Plugin.php) hardcodes `calendar-user-type` to `'INDIVIDUAL'` for all principals, with an inline comment: *"The server currently reports every principal to be of type INDIVIDUAL."*
|
||
|
|
- The [`PrincipalBackend\PDO`](https://github.com/sabre-io/dav/blob/master/lib/DAVACL/PrincipalBackend/PDO.php) base class only maps two columns in its `$fieldMap`: `displayname` and `email`. There is no `calendar_user_type` column in the default schema.
|
||
|
|
|
||
|
|
**However**, SabreDAV was designed for this to be extended:
|
||
|
|
|
||
|
|
1. The `$fieldMap` in `PrincipalBackend\PDO` is a `protected` property that subclasses can override to add custom DB columns and map them to WebDAV properties.
|
||
|
|
2. When a property is in the `$fieldMap`, the `Principal` node exposes it via `getProperties()`, which runs **before** the Schedule Plugin's `handle()` callback.
|
||
|
|
3. The Schedule Plugin uses `$propFind->handle()` which is a **no-op when the property is already set**. So the hardcoded `'INDIVIDUAL'` only serves as a fallback for principals that lack the column.
|
||
|
|
|
||
|
|
This means adding CUTYPE support is a **one-line extension** of the fieldMap, not a hack:
|
||
|
|
|
||
|
|
```php
|
||
|
|
// In our AutoCreatePrincipalBackend (extends PrincipalBackend\PDO)
|
||
|
|
protected $fieldMap = [
|
||
|
|
'{DAV:}displayname' => ['dbField' => 'displayname'],
|
||
|
|
'{http://sabredav.org/ns}email-address' => ['dbField' => 'email'],
|
||
|
|
// This is all it takes -- SabreDAV handles the rest
|
||
|
|
'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => ['dbField' => 'calendar_user_type'],
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
With this in place, `getPrincipalByPath()`, `getPrincipalsByPrefix()`, and `searchPrincipals()` all automatically include the real CUTYPE value. CalDAV clients that support `principal-property-search` (Apple Calendar) can discover rooms and resources. PROPFIND on resource principals returns the correct type. No custom plugin is needed for CUTYPE -- just the standard extension point.
|
||
|
|
|
||
|
|
### Auto-Scheduling: Not Built In Anywhere
|
||
|
|
|
||
|
|
While CUTYPE is a matter of data exposure (and SabreDAV provides the extension points), **auto-scheduling is a different story**. No existing CalDAV server component provides automatic accept/decline for resource principals:
|
||
|
|
|
||
|
|
**SabreDAV Core**: The `Schedule\Plugin` handles iTIP delivery (inbox/outbox) but has no logic to auto-accept or auto-decline based on calendar-user-type or availability. After delivering a scheduling message, it's done.
|
||
|
|
|
||
|
|
**Nextcloud**: Built resource scheduling on top of SabreDAV, but deeply coupled to the Nextcloud framework (`\OCP\` interfaces, DI container, app framework). None of the code (`ResourcePrincipalBackend.php`, `RoomPrincipalBackend.php`, `CalDavBackend.php`) is extractable as a standalone plugin.
|
||
|
|
|
||
|
|
**SOGo**: Has resource auto-accept, but written in Objective-C with its own CalDAV stack -- not SabreDAV-based.
|
||
|
|
|
||
|
|
**Bedework**: The one enterprise CalDAV server with built-in room/resource scheduling, but it's a Java application, not a SabreDAV plugin.
|
||
|
|
|
||
|
|
**Packagist / Open Source**: No third-party SabreDAV resource scheduling package exists.
|
||
|
|
|
||
|
|
### What This Means
|
||
|
|
|
||
|
|
- **CUTYPE support**: Uses SabreDAV's built-in `$fieldMap` extension. One line of code, fully idiomatic.
|
||
|
|
- **Auto-scheduling**: Requires a custom `ResourceAutoSchedulePlugin` (~350 lines of PHP). Follows the same pattern as our existing `HttpCallbackIMipPlugin`: hook into the `schedule` event, inspect the iTIP message, act on it. The plugin reads resource configuration from the shared PostgreSQL database directly (same instance SabreDAV already uses).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Auto-Scheduling and Conflict Detection
|
||
|
|
|
||
|
|
### How Auto-Scheduling Works
|
||
|
|
|
||
|
|
Auto-scheduling is implemented as a **custom SabreDAV plugin** that intercepts scheduling deliveries to resource principals. It runs after `Sabre\CalDAV\Schedule\Plugin` delivers the iTIP message. No existing SabreDAV plugin provides this functionality (see [What SabreDAV Provides](#what-sabredav-provides-and-what-it-doesnt)).
|
||
|
|
|
||
|
|
```php
|
||
|
|
class ResourceAutoSchedulePlugin extends ServerPlugin
|
||
|
|
{
|
||
|
|
// Hook into the 'schedule' event
|
||
|
|
function schedule(ITip\Message $message)
|
||
|
|
{
|
||
|
|
// 1. Is the recipient a resource principal?
|
||
|
|
// 2. What is the resource's auto_schedule_mode?
|
||
|
|
// 3. For 'automatic' mode: check free/busy
|
||
|
|
// 4. Set $message->scheduleStatus accordingly
|
||
|
|
// 5. Update PARTSTAT in the delivered calendar object
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Conflict Detection Algorithm
|
||
|
|
|
||
|
|
```
|
||
|
|
function hasConflict(resource, newEvent):
|
||
|
|
// Get the resource's calendar
|
||
|
|
calendar = resource.getDefaultCalendar()
|
||
|
|
|
||
|
|
// For each instance of the new event (expand recurrence)
|
||
|
|
for instance in expandInstances(newEvent, maxWindow=1year):
|
||
|
|
if instance.transp == TRANSPARENT:
|
||
|
|
continue // transparent events don't block
|
||
|
|
|
||
|
|
// Query existing events in this time range
|
||
|
|
existing = calendar.getEvents(instance.start, instance.end)
|
||
|
|
|
||
|
|
// Check against max concurrent bookings
|
||
|
|
overlapping = countOverlapping(existing, instance)
|
||
|
|
if overlapping >= resource.multiple_bookings:
|
||
|
|
return true // conflict
|
||
|
|
|
||
|
|
return false // no conflict
|
||
|
|
```
|
||
|
|
|
||
|
|
### Edge Cases
|
||
|
|
|
||
|
|
- **All-day events**: Treated as blocking the entire day
|
||
|
|
- **Tentative events**: Count as busy (configurable per resource)
|
||
|
|
- **Cancelled events**: Do not count as busy
|
||
|
|
- **Transparent events** (`TRANSP=TRANSPARENT`): Do not count as busy
|
||
|
|
- **Recurring with exceptions**: Must check each expanded instance
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Free/Busy and Availability
|
||
|
|
|
||
|
|
### Free/Busy Queries
|
||
|
|
|
||
|
|
Resources support standard CalDAV free/busy queries. Two methods:
|
||
|
|
|
||
|
|
**Method 1: `CALDAV:free-busy-query` REPORT** (RFC 4791)
|
||
|
|
|
||
|
|
```xml
|
||
|
|
REPORT /calendars/resources/room-101/default/
|
||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<C:free-busy-query xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||
|
|
<C:time-range start="20260305T000000Z" end="20260306T000000Z"/>
|
||
|
|
</C:free-busy-query>
|
||
|
|
```
|
||
|
|
|
||
|
|
Returns a `VFREEBUSY` component with busy intervals.
|
||
|
|
|
||
|
|
**Method 2: Schedule outbox POST** (RFC 6638)
|
||
|
|
|
||
|
|
The organizer POSTs a `VFREEBUSY` request to their outbox, specifying the resource as an attendee. The server returns the resource's free/busy data.
|
||
|
|
|
||
|
|
### Availability Hours (VAVAILABILITY) — Future
|
||
|
|
|
||
|
|
Resources could define operating hours using RFC 7953
|
||
|
|
`VAVAILABILITY` (e.g., Monday-Friday 8:00-20:00, booking outside
|
||
|
|
these hours auto-declined). However, SabreDAV does not support
|
||
|
|
RFC 7953 out of the box — the auto-schedule plugin would need to
|
||
|
|
implement `VAVAILABILITY` parsing. This is deferred to Phase 5.
|
||
|
|
|
||
|
|
### Frontend Availability View
|
||
|
|
|
||
|
|
The web frontend shows:
|
||
|
|
- A **day/week timeline** of the resource's bookings
|
||
|
|
- **Color-coded slots**: free (green), busy (red), tentative (yellow)
|
||
|
|
- A **multi-resource view** to compare several rooms side by side
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Access Rights and Administration
|
||
|
|
|
||
|
|
Resource access control uses **CalDAV sharing** (`CS:share`), the same mechanism already used for sharing user calendars. No Django model is needed.
|
||
|
|
|
||
|
|
### Privilege Levels
|
||
|
|
|
||
|
|
| CalDAV Privilege | Role | Capabilities |
|
||
|
|
|-----------------|------|--------------|
|
||
|
|
| *(no share)* | Any authenticated user | Discover resource, view free/busy, book by adding as attendee |
|
||
|
|
| `read` | Shared viewer | All of the above + see full booking details on the calendar |
|
||
|
|
| `read-write` | Manager | All of the above + modify/cancel any booking on the resource |
|
||
|
|
| `admin` | Administrator | All of the above + edit resource properties (PROPPATCH), manage sharing, override auto-schedule decisions, delete resource |
|
||
|
|
|
||
|
|
### How It Works
|
||
|
|
|
||
|
|
- The **resource principal** owns its calendar collection. This is the "owner" in CalDAV terms.
|
||
|
|
- **Admins** are added by sharing the resource's calendar with `admin` privilege via `CS:share` (same as `CalDavService.shareCalendar()`)
|
||
|
|
- **Managers** get `read-write` privilege
|
||
|
|
- **Viewers** get `read` privilege
|
||
|
|
- **Any authenticated user** can book the resource (add it as an attendee to their event) and query free/busy -- this does not require sharing. The CalDAV scheduling protocol handles this via the resource's schedule inbox.
|
||
|
|
|
||
|
|
This maps directly to how user calendar sharing already works in the frontend (`CalendarShareModal`, `CalDavService.shareCalendar()`, `getCalendarSharees()`).
|
||
|
|
|
||
|
|
### Restricted Resources
|
||
|
|
|
||
|
|
For resources that should not be bookable by everyone (executive rooms, specialized equipment), the `{urn:lasuite:calendars}restricted-access` property can be set to `true`. When set, the auto-schedule plugin only accepts invitations from users who have been explicitly shared on the resource's calendar.
|
||
|
|
|
||
|
|
### Organization Scoping and Permissions
|
||
|
|
|
||
|
|
See [docs/organizations.md](organizations.md) for the full org design. Key points for resources:
|
||
|
|
|
||
|
|
- **Resource discovery** is org-scoped: SabreDAV filters resource principals by the `org_id` column on the `principals` table, using the `X-CalDAV-Organization` header set by Django.
|
||
|
|
- **Cross-org resource booking is not allowed**: the auto-schedule plugin rejects invitations from users outside the resource's org.
|
||
|
|
- **Resource creation** requires the `can_admin` entitlement (returned by the entitlements system alongside `can_access`).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Sharing and Delegation
|
||
|
|
|
||
|
|
### Delegation to Resource Managers
|
||
|
|
|
||
|
|
When `auto-schedule-mode=manual`, incoming booking requests require approval:
|
||
|
|
|
||
|
|
1. User creates event with resource as attendee
|
||
|
|
2. Auto-scheduler detects `manual` mode
|
||
|
|
3. Resource stays in `PARTSTAT=NEEDS-ACTION`
|
||
|
|
4. Resource managers receive a notification
|
||
|
|
5. Manager approves or declines via:
|
||
|
|
- The web UI (resource management panel)
|
||
|
|
- Direct calendar interaction (change PARTSTAT on the resource's calendar)
|
||
|
|
6. iTIP REPLY sent back to the organizer
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Resource Discovery
|
||
|
|
|
||
|
|
### How Users Find Resources
|
||
|
|
|
||
|
|
In major calendar apps, users pick rooms from a list scoped to their organization. This works because those apps are tied to an organization directory (Workspace domain, Exchange GAL, etc.). In CalDAV, there are two discovery mechanisms:
|
||
|
|
|
||
|
|
### Discovery via CalDAV (`principal-property-search`)
|
||
|
|
|
||
|
|
RFC 3744 defines a `DAV:principal-property-search` REPORT that lets clients search for principals by property. For example, to find all rooms:
|
||
|
|
|
||
|
|
```xml
|
||
|
|
REPORT /principals/
|
||
|
|
<?xml version="1.0" encoding="utf-8"?>
|
||
|
|
<D:principal-property-search xmlns:D="DAV:"
|
||
|
|
xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||
|
|
<D:property-search>
|
||
|
|
<D:prop>
|
||
|
|
<C:calendar-user-type/>
|
||
|
|
</D:prop>
|
||
|
|
<D:match>ROOM</D:match>
|
||
|
|
</D:property-search>
|
||
|
|
<D:prop>
|
||
|
|
<D:displayname/>
|
||
|
|
<C:calendar-user-address-set/>
|
||
|
|
</D:prop>
|
||
|
|
</D:principal-property-search>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Limitations**:
|
||
|
|
- Returns **all** matching principals on the server -- there is no built-in scoping by organization or tenant
|
||
|
|
- Only Apple Calendar uses this for resource discovery in practice
|
||
|
|
- Thunderbird and GNOME Calendar do not support resource discovery via CalDAV
|
||
|
|
- CalDAV properties are limited to basic fields (name, email, CUTYPE) -- no capacity, equipment, location metadata
|
||
|
|
|
||
|
|
**In a single-tenant deployment**, this works fine: all resources belong to the same organization, so returning all of them is correct.
|
||
|
|
|
||
|
|
**In a multi-tenant deployment**, SabreDAV filters results by the `org_id` on the `principals` table (see [Organization Scoping](#organization-scoping-and-permissions)).
|
||
|
|
|
||
|
|
### Discovery via CalDAV + Client-Side Filtering (Web Frontend)
|
||
|
|
|
||
|
|
The web frontend fetches all resource principals via a single
|
||
|
|
`PROPFIND` on `principals/resources/` (through the CalDAV proxy)
|
||
|
|
and filters/sorts **client-side** in JavaScript. This is the
|
||
|
|
**primary and richest** discovery method:
|
||
|
|
|
||
|
|
- Full metadata: capacity, equipment, location, tags
|
||
|
|
- Client-side filtering: by type, capacity, location, tags
|
||
|
|
- Real-time availability: via `free-busy-query` REPORT
|
||
|
|
- Org-scoped: SabreDAV filters by `org_id` before returning
|
||
|
|
|
||
|
|
No Django endpoint is needed for resource discovery. The frontend
|
||
|
|
does the same kind of PROPFIND it already does for calendars.
|
||
|
|
|
||
|
|
### Discovery via Email (All Clients)
|
||
|
|
|
||
|
|
Any CalDAV client can book a resource by typing its scheduling address in the attendee field, even without a discovery UI. This is the universal fallback that works everywhere.
|
||
|
|
|
||
|
|
## Interoperability with CalDAV Clients
|
||
|
|
|
||
|
|
| Client | Book by Email | See CUTYPE | Discover via CalDAV | Free/Busy |
|
||
|
|
|--------|---------------|------------|---------------------|-----------|
|
||
|
|
| Apple Calendar | Yes | Yes | Yes (`principal-property-search`) | Yes |
|
||
|
|
| Thunderbird | Yes | No (shows as person) | No | Yes |
|
||
|
|
| GNOME Calendar | Yes | Partial | No | Partial |
|
||
|
|
| Web Frontend | Yes | Yes | Yes (PROPFIND + client-side) | Yes |
|
||
|
|
| Any CalDAV client | Yes | Varies | Varies | Yes |
|
||
|
|
|
||
|
|
The key takeaway: **booking works universally** (any client can invite a resource by email). **Discovery** (browsing available rooms) is richest in the web frontend and limited in native CalDAV clients.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementation Plan
|
||
|
|
|
||
|
|
### Phase 1: Resource Principals in SabreDAV
|
||
|
|
|
||
|
|
**Goal**: Resources exist as CalDAV principals and can receive scheduling messages.
|
||
|
|
|
||
|
|
**Changes**:
|
||
|
|
|
||
|
|
1. **Extend SabreDAV principals table**: Add `calendar_user_type` column (and `org_id` for multi-tenancy)
|
||
|
|
2. **Extend `AutoCreatePrincipalBackend.$fieldMap`**: Add `{urn:ietf:params:xml:ns:caldav}calendar-user-type` → `calendar_user_type`. This is SabreDAV's idiomatic extension point for principal properties -- the base `PrincipalBackend\PDO` automatically includes mapped fields in all queries, and the `Schedule\Plugin`'s hardcoded `INDIVIDUAL` becomes a fallback (see [What SabreDAV Provides](#what-sabredav-provides-and-what-it-doesnt))
|
||
|
|
3. **Add nested principal prefixes**: `principals/users/` for user principals and `principals/resources/` for resource principals, with a custom `CalendarsRoot` node to handle the nested structure (SabreDAV's default `CalendarRoot` only supports flat principal prefixes)
|
||
|
|
4. **Verify scheduling delivery**: Ensure `Schedule\Plugin` delivers iTIP messages to resource inboxes
|
||
|
|
|
||
|
|
**Files to modify**:
|
||
|
|
- `src/caldav/sql/pgsql.principals.sql` -- add columns
|
||
|
|
- `src/caldav/src/AutoCreatePrincipalBackend.php` -- extend `$fieldMap`
|
||
|
|
- `src/caldav/src/CalendarsRoot.php` -- custom DAV Collection for nested prefixes
|
||
|
|
- `src/caldav/server.php` -- use CalendarsRoot and nested principal collections
|
||
|
|
|
||
|
|
### Phase 2: Django Provisioning API
|
||
|
|
|
||
|
|
**Goal**: REST endpoint to create/delete resource principals, gated by org-level permissions.
|
||
|
|
|
||
|
|
**Changes**:
|
||
|
|
|
||
|
|
1. **Resource provisioning endpoint**: Creates the SabreDAV principal + calendar via CalDAV requests (no direct DB writes). Checks the user's `can_admin` entitlement.
|
||
|
|
2. **Resource deletion endpoint**: Cleans up CalDAV principal + calendar. Same permission check.
|
||
|
|
|
||
|
|
No Django model is needed. Metadata is managed via CalDAV PROPPATCH. Access control uses CalDAV sharing (`CS:share`), the same way user calendars are shared. Org-level permission to create/delete resources comes from the `can_admin` entitlement.
|
||
|
|
|
||
|
|
**Files to create/modify**:
|
||
|
|
- `src/backend/core/api/viewsets.py` -- add resource provisioning viewset
|
||
|
|
- `src/backend/core/services/resource_service.py` -- provisioning logic (CalDAV calls)
|
||
|
|
|
||
|
|
### Phase 3: Auto-Scheduling Plugin
|
||
|
|
|
||
|
|
**Goal**: Resources automatically accept/decline based on availability.
|
||
|
|
|
||
|
|
No existing SabreDAV plugin provides this -- see [What SabreDAV Provides](#what-sabredav-provides-and-what-it-doesnt). The plugin is ~350 lines of PHP, following the same pattern as the existing `HttpCallbackIMipPlugin`.
|
||
|
|
|
||
|
|
**Changes**:
|
||
|
|
|
||
|
|
1. **`ResourceAutoSchedulePlugin.php`**: SabreDAV plugin that hooks into the `schedule` event (same hook `HttpCallbackIMipPlugin` already uses)
|
||
|
|
2. **Conflict detection**: Query the resource's calendar for overlapping events
|
||
|
|
3. **Auto-schedule modes**: Read the `{urn:lasuite:calendars}auto-schedule-mode` property from SabreDAV's `propertystorage` table (same PostgreSQL instance, no Django roundtrip)
|
||
|
|
4. **iTIP REPLY generation**: Send `ACCEPTED` or `DECLINED` back to organizer
|
||
|
|
5. **Availability hours**: Not in v1 (see Phase 5)
|
||
|
|
|
||
|
|
**Files to create/modify**:
|
||
|
|
- `src/caldav/src/ResourceAutoSchedulePlugin.php` -- new plugin
|
||
|
|
- `src/caldav/server.php` -- register plugin
|
||
|
|
|
||
|
|
**Design choice**: The plugin reads the `auto_schedule_mode` from the database directly (same PostgreSQL instance SabreDAV already uses) rather than calling Django's API, to avoid circular HTTP dependencies during scheduling.
|
||
|
|
|
||
|
|
### Phase 4: Frontend Resource UI
|
||
|
|
|
||
|
|
**Goal**: Users can discover, browse, and book resources from the web interface.
|
||
|
|
|
||
|
|
**Components**:
|
||
|
|
|
||
|
|
1. **Resource directory**: Searchable/filterable list of all resources
|
||
|
|
2. **Resource detail panel**: Shows metadata, availability timeline, current bookings
|
||
|
|
3. **Resource picker in event modal**: Add room/resource when creating an event
|
||
|
|
4. **Availability checker**: Real-time free/busy display when selecting a resource
|
||
|
|
5. **Multi-resource timeline**: Side-by-side availability comparison
|
||
|
|
6. **Resource management panel**: For admins to create/edit/configure resources
|
||
|
|
|
||
|
|
**Files to create**:
|
||
|
|
- `src/frontend/apps/calendars/src/features/resources/` -- new feature module
|
||
|
|
- `types.ts` -- TypeScript types for resource properties
|
||
|
|
- `components/ResourceDirectory.tsx`
|
||
|
|
- `components/ResourcePicker.tsx`
|
||
|
|
- `components/ResourceDetail.tsx`
|
||
|
|
- `components/ResourceTimeline.tsx`
|
||
|
|
- `components/ResourceAdmin.tsx`
|
||
|
|
- `src/frontend/apps/calendars/src/services/dav/CalDavService.ts` -- extend with resource PROPFIND/PROPPATCH methods
|
||
|
|
- Updates to event modal for resource attendee support
|
||
|
|
|
||
|
|
The frontend reads/writes resource metadata via CalDAV (PROPFIND/PROPPATCH), the same way it already manages calendar properties. The Django REST API is only used for provisioning (create/delete).
|
||
|
|
|
||
|
|
### Phase 5: Advanced Features
|
||
|
|
|
||
|
|
**Goal**: Polish and power-user features.
|
||
|
|
|
||
|
|
1. **Approval workflow**: Notification system for `manual` mode resources
|
||
|
|
2. **Booking policies**: Max duration, booking window, recurring limits
|
||
|
|
3. **Resource groups**: Group rooms by building/floor for easier browsing
|
||
|
|
4. **Capacity warnings**: Warn when event attendee count exceeds room capacity
|
||
|
|
5. **Resource calendar overlay**: Show resource bookings in the main calendar view
|
||
|
|
6. **Reporting**: Usage statistics, popular times, underutilized resources
|
||
|
|
7. **VAVAILABILITY editor**: UI for configuring resource operating hours
|
||
|
|
8. **Bulk resource import**: CSV/JSON import for provisioning many resources
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Database Schema Changes
|
||
|
|
|
||
|
|
### SabreDAV: Principals Table
|
||
|
|
|
||
|
|
The principals table includes two extra columns beyond the SabreDAV defaults (defined in `src/caldav/sql/pgsql.principals.sql`):
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE principals (
|
||
|
|
id SERIAL NOT NULL,
|
||
|
|
uri VARCHAR(200) NOT NULL,
|
||
|
|
email VARCHAR(80),
|
||
|
|
displayname VARCHAR(80),
|
||
|
|
calendar_user_type VARCHAR(20) DEFAULT 'INDIVIDUAL', -- INDIVIDUAL, ROOM, RESOURCE
|
||
|
|
org_id VARCHAR(200), -- organization scoping
|
||
|
|
PRIMARY KEY (id),
|
||
|
|
UNIQUE (uri)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_principals_org_id ON principals (org_id) WHERE org_id IS NOT NULL;
|
||
|
|
CREATE INDEX idx_principals_cutype ON principals (calendar_user_type)
|
||
|
|
WHERE calendar_user_type IN ('ROOM', 'RESOURCE');
|
||
|
|
```
|
||
|
|
|
||
|
|
The `calendar_user_type` column is exposed as the standard `{urn:ietf:params:xml:ns:caldav}calendar-user-type` DAV property via `AutoCreatePrincipalBackend.$fieldMap`. The `org_id` column is used internally for org-scoped filtering and is not exposed as a DAV property.
|
||
|
|
|
||
|
|
Resource metadata (capacity, location, equipment, etc.) is stored in SabreDAV's existing `propertystorage` table via PROPPATCH -- no additional SabreDAV schema changes needed.
|
||
|
|
|
||
|
|
### Django: No New Tables
|
||
|
|
|
||
|
|
No Django models are needed for resources. Access control uses CalDAV sharing (stored in SabreDAV's `calendarinstances` table). Resource metadata uses SabreDAV's `propertystorage` table. Both are managed through CalDAV protocol, not direct database access.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API Design
|
||
|
|
|
||
|
|
Resource metadata is read/written via **CalDAV** (PROPFIND/PROPPATCH). The Django REST API only handles provisioning and deletion.
|
||
|
|
|
||
|
|
### Django REST API (Provisioning)
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/v1.0/resources/ # Create resource (provision principal + calendar)
|
||
|
|
DELETE /api/v1.0/resources/{resource-id}/ # Delete resource (cleanup principal + calendar)
|
||
|
|
```
|
||
|
|
|
||
|
|
Both `POST` and `DELETE` require the `can_admin` entitlement. Access control (sharing) is managed via CalDAV `CS:share` on the resource's calendar -- no Django access endpoints needed.
|
||
|
|
|
||
|
|
### Create Resource Request
|
||
|
|
|
||
|
|
```json
|
||
|
|
POST /api/v1.0/resources/
|
||
|
|
{
|
||
|
|
"name": "Room 101 - Large Conference",
|
||
|
|
"resource_type": "ROOM"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Django checks the user's `can_admin` entitlement, provisions the CalDAV principal and calendar, then the frontend sets additional metadata via PROPPATCH.
|
||
|
|
|
||
|
|
### CalDAV API (Metadata and Discovery)
|
||
|
|
|
||
|
|
All resource metadata is managed via standard CalDAV protocol:
|
||
|
|
|
||
|
|
| Operation | Method | URL |
|
||
|
|
|-----------|--------|-----|
|
||
|
|
| List all resources | `PROPFIND` | `/api/v1.0/caldav/principals/resources/` |
|
||
|
|
| Get resource properties | `PROPFIND` | `/api/v1.0/caldav/principals/resources/{resource-id}/` |
|
||
|
|
| Update resource properties | `PROPPATCH` | `/api/v1.0/caldav/principals/resources/{resource-id}/` |
|
||
|
|
| Get resource calendar | `PROPFIND` | `/api/v1.0/caldav/calendars/resources/{resource-id}/default/` |
|
||
|
|
| Query free/busy | `REPORT` | `/api/v1.0/caldav/calendars/resources/{resource-id}/default/` |
|
||
|
|
|
||
|
|
The frontend fetches all resource principals with their properties in a single PROPFIND request and filters/sorts client-side. This is the same pattern used for fetching calendars today.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## SabreDAV Plugin Design
|
||
|
|
|
||
|
|
CUTYPE support uses SabreDAV's built-in `$fieldMap` extension point (no plugin needed). Auto-scheduling requires a custom plugin because SabreDAV has no built-in resource auto-scheduling, and no reusable third-party implementation exists (see [What SabreDAV Provides](#what-sabredav-provides-and-what-it-doesnt)).
|
||
|
|
|
||
|
|
### ResourceAutoSchedulePlugin
|
||
|
|
|
||
|
|
```php
|
||
|
|
class ResourceAutoSchedulePlugin extends DAV\ServerPlugin
|
||
|
|
{
|
||
|
|
function getPluginName() { return 'resource-auto-schedule'; }
|
||
|
|
|
||
|
|
function initialize(DAV\Server $server)
|
||
|
|
{
|
||
|
|
$server->on('schedule', [$this, 'autoSchedule'], 120);
|
||
|
|
// Priority 120: runs after Schedule\Plugin (110)
|
||
|
|
}
|
||
|
|
|
||
|
|
function autoSchedule(ITip\Message $message)
|
||
|
|
{
|
||
|
|
// Only handle messages TO resource principals
|
||
|
|
if (!$this->isResourcePrincipal($message->recipient)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Read auto-schedule-mode from propertystorage table
|
||
|
|
$mode = $this->getAutoScheduleMode($message->recipient);
|
||
|
|
|
||
|
|
switch ($mode) {
|
||
|
|
case 'accept_always':
|
||
|
|
$this->acceptInvitation($message);
|
||
|
|
break;
|
||
|
|
case 'decline_always':
|
||
|
|
$this->declineInvitation($message);
|
||
|
|
break;
|
||
|
|
case 'automatic':
|
||
|
|
if ($this->hasConflict($message)) {
|
||
|
|
$this->declineInvitation($message);
|
||
|
|
} else {
|
||
|
|
$this->acceptInvitation($message);
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case 'manual':
|
||
|
|
// Do nothing; leave PARTSTAT=NEEDS-ACTION
|
||
|
|
// Managers see pending requests on the resource calendar
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Integration with Existing Plugins
|
||
|
|
|
||
|
|
The plugin runs in this order:
|
||
|
|
1. `CalendarSanitizerPlugin` (priority 85) -- strips binaries, truncates
|
||
|
|
2. `AttendeeNormalizerPlugin` (priority 90) -- normalizes emails
|
||
|
|
3. `CalDAV\Schedule\Plugin` (priority 110) -- delivers iTIP messages
|
||
|
|
4. **`ResourceAutoSchedulePlugin`** (priority 120) -- auto-accept/decline
|
||
|
|
5. `HttpCallbackIMipPlugin` -- notifies Django (for email delivery)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Frontend Components
|
||
|
|
|
||
|
|
### Data Flow
|
||
|
|
|
||
|
|
The frontend reads/writes resource metadata via CalDAV, just like it does for calendars:
|
||
|
|
|
||
|
|
```
|
||
|
|
CalDavService.fetchResourcePrincipals() → PROPFIND /principals/resources/
|
||
|
|
CalDavService.getResourceProperties(id) → PROPFIND /principals/resources/{resource-id}/
|
||
|
|
CalDavService.updateResourceProperties() → PROPPATCH /principals/resources/{resource-id}/
|
||
|
|
CalDavService.fetchResourceEvents() → REPORT on resource calendar
|
||
|
|
CalDavService.queryResourceFreeBusy() → free-busy-query REPORT
|
||
|
|
```
|
||
|
|
|
||
|
|
The Django REST API is only called for provisioning
|
||
|
|
(create/delete). Everything else goes through CalDAV.
|
||
|
|
|
||
|
|
### Resource Directory (`/resources`)
|
||
|
|
|
||
|
|
A browsable directory of all resources with:
|
||
|
|
- **Filter sidebar**: Type (room/resource), capacity range, tags, location, availability -- all filtering is client-side after a single PROPFIND
|
||
|
|
- **List/grid view**: Cards showing name, location, capacity, availability status
|
||
|
|
- **Search**: Real-time search across name, description, location
|
||
|
|
- **Quick book**: Click to start creating an event with the resource
|
||
|
|
|
||
|
|
### Resource Picker (Event Modal)
|
||
|
|
|
||
|
|
When creating/editing an event:
|
||
|
|
- "Add Room" / "Add Resource" button in the attendees section
|
||
|
|
- Opens a filtered dropdown/modal showing available resources
|
||
|
|
- Shows real-time availability for the selected event time
|
||
|
|
- Displays capacity and key metadata inline
|
||
|
|
- Selected resources appear in the attendees list with a room/resource icon
|
||
|
|
|
||
|
|
### Resource Timeline
|
||
|
|
|
||
|
|
A horizontal timeline showing:
|
||
|
|
- One row per resource
|
||
|
|
- Colored blocks for existing bookings
|
||
|
|
- Grey blocks for unavailable hours
|
||
|
|
- Ability to click an empty slot to book
|
||
|
|
|
||
|
|
### Resource Admin Panel
|
||
|
|
|
||
|
|
For resource administrators:
|
||
|
|
- Create/edit resource metadata (PROPPATCH to CalDAV)
|
||
|
|
- Configure auto-schedule mode and booking policies (PROPPATCH)
|
||
|
|
- Manage access via CalDAV sharing (`CS:share`) -- same UI as calendar sharing
|
||
|
|
- View booking history and usage stats
|
||
|
|
- Override booking decisions (accept/decline pending requests)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Migration and Deployment
|
||
|
|
|
||
|
|
### Rolling Deployment Steps
|
||
|
|
|
||
|
|
1. **Database migration**: Add `calendar_user_type` to SabreDAV principals (no Django migration needed)
|
||
|
|
2. **Deploy SabreDAV**: Updated principal backend + auto-schedule plugin (no user impact -- new code paths only activate for resource principals)
|
||
|
|
3. **Deploy Django backend**: Resource provisioning + access endpoints (additive, no breaking changes)
|
||
|
|
4. **Deploy frontend**: Resource UI (feature-flagged if needed)
|
||
|
|
|
||
|
|
### Feature Flag
|
||
|
|
|
||
|
|
Consider a `RESOURCES_ENABLED` feature flag in settings to:
|
||
|
|
- Show/hide resource UI in the frontend
|
||
|
|
- Enable/disable resource API endpoints
|
||
|
|
- Allow gradual rollout
|
||
|
|
|
||
|
|
### Data Migration
|
||
|
|
|
||
|
|
If importing resources from an external system:
|
||
|
|
1. Call the Django provisioning endpoint for each resource (creates CalDAV principals)
|
||
|
|
2. PROPPATCH to set metadata properties on each resource principal
|
||
|
|
3. Import historical bookings as calendar events via ICS import
|
||
|
|
4. Verify free/busy accuracy after import
|