# T05 — Calendar Plugin Backend Briefing ## Project Root /a0/usr/workdir/dev-projects/leocrm ## Context Files (read first) - `app/plugins/base.py` — BasePlugin abstract class - `app/plugins/manifest.py` — PluginManifest, PluginRouteDef - `app/plugins/builtins/tags/` — Reference plugin (subdirectory pattern) - `app/plugins/builtins/dms/` — Most recent plugin (complex reference) - `app/core/db.py` — Base, TenantMixin, TimestampMixin - `app/models/company.py` — Model pattern reference - `app/routes/companies.py` — Route pattern reference - `tests/test_dms.py` — Test pattern reference (uses authed_client from conftest) - `tests/conftest.py` — Shared fixtures (dms_app, dms_client, authed_client — adapt for calendar) - `architecture.md` — Calendar tables + endpoints (search for 'Calendar Plugin') ## Plugin Structure ``` app/plugins/builtins/calendar/ ├── __init__.py — Exports CalendarPlugin ├── plugin.py — CalendarPlugin(BasePlugin) with manifest ├── routes.py — 21 endpoints ├── models.py — 7 SQLAlchemy models ├── schemas.py — Pydantic schemas ├── recurrence.py — Recurrence engine (RRULE-style) ├── ics_utils.py — ICS export/import utilities └── migrations/ └── 0001_initial.sql — 7 tables ``` ## Models (7 tables) ### Calendar - id (UUID PK), tenant_id, name (str, not null), color (str, default '#3B82F6') - type (str: personal/team/project/company, default 'personal') - owner_id (UUID, not null), created_at, updated_at, deleted_at (soft delete) - Unique: (name, tenant_id) WHERE deleted_at IS NULL ### CalendarEntry - id (UUID PK), tenant_id, calendar_id (FK→calendars.id) - entry_type (str: appointment/task, not null) - subtype (str: normal/follow_up/private, default 'normal') - title (str, not null), description (TEXT, nullable) - start_at (TIMESTAMPTZ, nullable — for appointments) - end_at (TIMESTAMPTZ, nullable — for appointments) - all_day (bool, default false) - location (str, nullable) - due_date (DATE, nullable — for tasks) - priority (str: low/medium/high, default 'medium') - status (str: open/in_progress/done/cancelled, default 'open') - assigned_to (UUID, nullable — for tasks) - reminder (JSONB, nullable: {value: int, unit: str, channel: str}) - recurrence (JSONB, nullable: {pattern: str, custom_rule: str, end_date: date, exceptions: [date]}) - source_mail_id (UUID, nullable) - created_by (UUID, not null), created_at, updated_at, deleted_at - Index: (tenant_id, calendar_id), (tenant_id, start_at), (tenant_id, due_date), (tenant_id, assigned_to, status) ### CalendarEntryLink - id (UUID PK), tenant_id, entry_id (FK→calendar_entries.id) - entity_type (str: company/contact, not null), entity_id (UUID, not null) ### CalendarShare - id (UUID PK), tenant_id, calendar_id (FK→calendars.id) - user_id (UUID, nullable), group_id (UUID, nullable) - permission (str: read/write, not null) ### UserCalendarVisibility - user_id (FK→users.id), calendar_id (FK→calendars.id), tenant_id - visible (bool, default true) - PK: (user_id, calendar_id) ### Subtask - id (UUID PK), tenant_id, entry_id (FK→calendar_entries.id) - title (str, not null), completed (bool, default false) - created_at ### Resource - id (UUID PK), tenant_id, name (str, not null) - type (str: room/equipment, not null) ### ResourceBooking - id (UUID PK), tenant_id, resource_id (FK→resources.id) - entry_id (FK→calendar_entries.id), start_at (TIMESTAMPTZ, not null), end_at (TIMESTAMPTZ, not null) ## Endpoints (21 total) ### Calendars (6) 1. `GET /api/v1/calendars` → 200 + calendar list (filtered by tenant + visibility) 2. `POST /api/v1/calendars` → 201, calendar created (name, color, type) 3. `PATCH /api/v1/calendars/{id}` → 200, updated (name, color) 4. `DELETE /api/v1/calendars/{id}` → 204, cascade delete entries + shares + visibility 5. `POST /api/v1/calendars/{id}/share` → 200, calendar shared (user_id/group_id, permission) 6. `GET /api/v1/calendars/{id}/permissions` → 200 + permission list ### Entries (10) 7. `GET /api/v1/calendar/entries?start=2026-01-01&end=2026-12-31` → 200 + entries in range 8. `POST /api/v1/calendar/entries` (appointment) → 201, entry created with start_at/end_at 9. `POST /api/v1/calendar/entries` (task) → 201, entry created with due_date/priority/status 10. `GET /api/v1/calendar/entries/{id}` → 200 + entry detail with links+subtasks 11. `PATCH /api/v1/calendar/entries/{id}` → 200, updated (drag&drop: PATCH start_at+end_at, or status change) 12. `PATCH /api/v1/calendar/entries/{id}` status=done → 200, status updated 13. `DELETE /api/v1/calendar/entries/{id}` → 204 14. `POST /api/v1/calendar/entries/{id}/link` → 200, linked to company/contact (entity_type, entity_id) 15. `POST /api/v1/calendar/entries/{id}/subtasks` → 201, subtask created (title) 16. `PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id}` → 200, completed toggled ### Bulk + Kanban + Export (3) 17. `POST /api/v1/calendar/entries/bulk` → 200, bulk status change/delete (entry_ids, action) 18. `GET /api/v1/calendar/kanban` → 200 + tasks grouped by status columns (open/in_progress/done/cancelled) 19. `GET /api/v1/calendar/entries/export?format=csv` → 200 + CSV stream ### ICS (2) 20. `GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid` → 200 + text/calendar (NO auth, token-based) 21. `GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid` → 401 22. `POST /api/v1/calendar/import` (multipart .ics file) → 200 + import result (entries_created count) ### Resources (2) 23. `POST /api/v1/resources` → 201 (admin only, 403 for non-admin) 24. `POST /api/v1/calendar/entries/{id}/book-resource` → 200, resource booked (resource_id) 25. `POST /api/v1/calendar/entries/{id}/book-resource` (conflict) → 409 ## Recurrence Engine - Patterns: daily, weekly, monthly, yearly - Custom rules: e.g. 'every 2nd Tuesday' → store as JSONB {pattern: 'custom', custom_rule: 'BYDAY=TU;BYSETPOS=2'} - Exceptions: array of dates excluded from occurrence generation - Occurrence generation: given a date range query (start, end), generate all occurrence instances - Max 2 years forward for appointments - Tasks: generate next instance on completion (post-completion, not on-the-fly) ## ICS Export Format ``` BEGIN:VCALENDAR VERSION:2.0 PRODID:-//LeoCRM//Calendar//EN BEGIN:VEVENT UID:@leocrm DTSTART: DTEND: SUMMARY: DESCRIPTION:<description> LOCATION:<location> END:VEVENT END:VCALENDAR ``` - Token auth: each calendar has an `ics_token` (generate on first feed request, store in calendar_shares or a separate field) - Invalid token → 401 ## ICS Import - Parse .ics file (VCALENDAR → VEVENT blocks) - Create CalendarEntry per VEVENT - Map: DTSTART→start_at, DTEND→end_at, SUMMARY→title, DESCRIPTION→description, LOCATION→location - Return: {entries_created: N, errors: [...]} - Use Python `icalendar` library if available, else parse manually ## Reminder System - reminder JSONB: {value: 15, unit: 'minutes', channel: 'in_app'} - When reminder is set on entry creation/update, schedule an ARQ job - ARQ job fires at (start_at - reminder) for appointments, (due_date - reminder) for tasks - Job sends in-app notification via EventBus - For now: just store the reminder config and schedule the ARQ job (don't need to implement the actual notification delivery) ## Calendar Sharing - Owner can share with user_id or group_id, permission read/write - User with read permission: can view entries, cannot edit (403 on PATCH/POST/DELETE) - User with write permission: can create/edit entries - Private subtype entries: only owner + admin can see (filter in query) ## Migration SQL ```sql CREATE TABLE IF NOT EXISTS calendars ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, name VARCHAR(200) NOT NULL, color VARCHAR(20) DEFAULT '#3B82F6', type VARCHAR(20) DEFAULT 'personal', owner_id UUID NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_calendars_tenant ON calendars(tenant_id) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS calendar_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, calendar_id UUID REFERENCES calendars(id) ON DELETE CASCADE, entry_type VARCHAR(15) NOT NULL, subtype VARCHAR(20) DEFAULT 'normal', title VARCHAR(500) NOT NULL, description TEXT, start_at TIMESTAMPTZ, end_at TIMESTAMPTZ, all_day BOOLEAN DEFAULT false, location VARCHAR(500), due_date DATE, priority VARCHAR(10) DEFAULT 'medium', status VARCHAR(15) DEFAULT 'open', assigned_to UUID, reminder JSONB, recurrence JSONB, source_mail_id UUID, created_by UUID NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ ); CREATE INDEX idx_entries_tenant_cal ON calendar_entries(tenant_id, calendar_id) WHERE deleted_at IS NULL; CREATE INDEX idx_entries_tenant_start ON calendar_entries(tenant_id, start_at) WHERE deleted_at IS NULL; CREATE INDEX idx_entries_tenant_due ON calendar_entries(tenant_id, due_date) WHERE deleted_at IS NULL; CREATE INDEX idx_entries_assigned ON calendar_entries(tenant_id, assigned_to, status) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS calendar_entry_links ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE, entity_type VARCHAR(50) NOT NULL, entity_id UUID NOT NULL ); CREATE TABLE IF NOT EXISTS calendar_shares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, calendar_id UUID REFERENCES calendars(id) ON DELETE CASCADE, user_id UUID, group_id UUID, permission VARCHAR(10) NOT NULL ); CREATE TABLE IF NOT EXISTS user_calendar_visibility ( user_id UUID NOT NULL, calendar_id UUID NOT NULL, tenant_id UUID NOT NULL, visible BOOLEAN DEFAULT true, PRIMARY KEY (user_id, calendar_id) ); CREATE TABLE IF NOT EXISTS subtasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE, title VARCHAR(500) NOT NULL, completed BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS resources ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, name VARCHAR(200) NOT NULL, type VARCHAR(50) NOT NULL ); CREATE TABLE IF NOT EXISTS resource_bookings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, resource_id UUID REFERENCES resources(id) ON DELETE CASCADE, entry_id UUID REFERENCES calendar_entries(id) ON DELETE CASCADE, start_at TIMESTAMPTZ NOT NULL, end_at TIMESTAMPTZ NOT NULL ); ``` ## Registration Add to `app/plugins/builtins/__init__.py`: ```python from app.plugins.builtins.calendar import CalendarPlugin __all__ = [..., "CalendarPlugin"] ``` ## Test File: tests/test_calendar.py - Use shared fixtures from conftest.py (adapt: create calendar_app, calendar_client, calendar_authed_client) - OR add calendar fixtures to conftest.py (preferred — same pattern as DMS) - Test ALL 29 ACs - Coverage target: ≥80% - Add `concurrency = ["greenlet"]` already in pyproject.toml (done in T04) ## Test Patterns - httpx AsyncClient with ASGITransport - `ORIGIN_HEADER` from conftest - `authed_client` returns (client, seed) with admin_a, viewer_a, editor_a - Multipart upload for ICS import: `files={'file': ('test.ics', ics_content, 'text/calendar')}` - ICS feed: no auth header, just `?token=valid_or_invalid` - Recurrence test: create weekly entry, query range, verify occurrences - Resource conflict: create 2 bookings with overlapping time → 409 ## Verification Commands ```bash cd /a0/usr/workdir/dev-projects/leocrm python -m pytest tests/test_calendar.py -v --tb=short python -m pytest tests/test_calendar.py --cov=app/plugins/builtins/calendar --cov-report=term-missing ruff check app/plugins/builtins/calendar/ tests/test_calendar.py ruff format --check app/plugins/builtins/calendar/ tests/test_calendar.py ``` ## 29 Acceptance Criteria — ALL must pass 1. GET /api/v1/calendars → 200 + calendar list 2. POST /api/v1/calendars → 201, calendar created 3. PATCH /api/v1/calendars/{id} → 200 4. DELETE /api/v1/calendars/{id} → 204, cascade delete entries 5. POST /api/v1/calendars/{id}/share → 200, calendar shared 6. GET /api/v1/calendars/{id}/permissions → 200 + permission list 7. GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range 8. POST /api/v1/calendar/entries (appointment) → 201, with start_at/end_at 9. POST /api/v1/calendar/entries (task) → 201, with due_date/priority/status 10. GET /api/v1/calendar/entries/{id} → 200 + detail with links+subtasks 11. PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop) 12. PATCH /api/v1/calendar/entries/{id} status=done → 200 13. DELETE /api/v1/calendar/entries/{id} → 204 14. POST /api/v1/calendar/entries/{id}/link → 200, linked 15. POST /api/v1/calendar/entries/{id}/subtasks → 201 16. PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, toggled 17. POST /api/v1/calendar/entries/bulk → 200, bulk status/delete 18. GET /api/v1/calendar/kanban → 200 + tasks grouped by status 19. GET /api/v1/calendar/entries/export?format=csv → 200 + CSV 20. GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar 21. GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid → 401 22. POST /api/v1/calendar/import mit .ics file → 200 + import result 23. POST /api/v1/resources → 201 (admin only, 403 non-admin) 24. POST /api/v1/calendar/entries/{id}/book-resource → 200 25. POST /api/v1/calendar/entries/{id}/book-resource (conflict) → 409 26. Recurrence: weekly entry generates correct occurrences for date range query 27. Recurrence: exception date excluded from occurrences 28. Reminder: ARQ job scheduled when reminder JSONB set 29. Calendar share: user with read permission can view, cannot edit (403) 30. Private subtype: only owner+admin can see entry ## Rules - No `# noqa: F401` for re-exports in routes/models (only in __init__.py) - Use `from None` in except blocks (B904) - No blocking file I/O in async functions without `# noqa: ASYNC230` - Follow existing plugin patterns exactly (see tags/dms plugins) - Tenant scoping on ALL queries - Soft delete for calendars and entries (deleted_at) - ICS feed endpoint: NO auth header, token-based only