14 KiB
14 KiB
T05 — Calendar Plugin Backend Briefing
Project Root
/a0/usr/workdir/dev-projects/leocrm
Context Files (read first)
app/plugins/base.py— BasePlugin abstract classapp/plugins/manifest.py— PluginManifest, PluginRouteDefapp/plugins/builtins/tags/— Reference plugin (subdirectory pattern)app/plugins/builtins/dms/— Most recent plugin (complex reference)app/core/db.py— Base, TenantMixin, TimestampMixinapp/models/company.py— Model pattern referenceapp/routes/companies.py— Route pattern referencetests/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)
GET /api/v1/calendars→ 200 + calendar list (filtered by tenant + visibility)POST /api/v1/calendars→ 201, calendar created (name, color, type)PATCH /api/v1/calendars/{id}→ 200, updated (name, color)DELETE /api/v1/calendars/{id}→ 204, cascade delete entries + shares + visibilityPOST /api/v1/calendars/{id}/share→ 200, calendar shared (user_id/group_id, permission)GET /api/v1/calendars/{id}/permissions→ 200 + permission list
Entries (10)
GET /api/v1/calendar/entries?start=2026-01-01&end=2026-12-31→ 200 + entries in rangePOST /api/v1/calendar/entries(appointment) → 201, entry created with start_at/end_atPOST /api/v1/calendar/entries(task) → 201, entry created with due_date/priority/statusGET /api/v1/calendar/entries/{id}→ 200 + entry detail with links+subtasksPATCH /api/v1/calendar/entries/{id}→ 200, updated (drag&drop: PATCH start_at+end_at, or status change)PATCH /api/v1/calendar/entries/{id}status=done → 200, status updatedDELETE /api/v1/calendar/entries/{id}→ 204POST /api/v1/calendar/entries/{id}/link→ 200, linked to company/contact (entity_type, entity_id)POST /api/v1/calendar/entries/{id}/subtasks→ 201, subtask created (title)PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id}→ 200, completed toggled
Bulk + Kanban + Export (3)
POST /api/v1/calendar/entries/bulk→ 200, bulk status change/delete (entry_ids, action)GET /api/v1/calendar/kanban→ 200 + tasks grouped by status columns (open/in_progress/done/cancelled)GET /api/v1/calendar/entries/export?format=csv→ 200 + CSV stream
ICS (2)
GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid→ 200 + text/calendar (NO auth, token-based)GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid→ 401POST /api/v1/calendar/import(multipart .ics file) → 200 + import result (entries_created count)
Resources (2)
POST /api/v1/resources→ 201 (admin only, 403 for non-admin)POST /api/v1/calendar/entries/{id}/book-resource→ 200, resource booked (resource_id)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:<entry_uuid>@leocrm
DTSTART:<start_at>
DTEND:<end_at>
SUMMARY:<title>
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
icalendarlibrary 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
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:
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_HEADERfrom conftestauthed_clientreturns (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
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
- GET /api/v1/calendars → 200 + calendar list
- POST /api/v1/calendars → 201, calendar created
- PATCH /api/v1/calendars/{id} → 200
- DELETE /api/v1/calendars/{id} → 204, cascade delete entries
- POST /api/v1/calendars/{id}/share → 200, calendar shared
- GET /api/v1/calendars/{id}/permissions → 200 + permission list
- GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range
- POST /api/v1/calendar/entries (appointment) → 201, with start_at/end_at
- POST /api/v1/calendar/entries (task) → 201, with due_date/priority/status
- GET /api/v1/calendar/entries/{id} → 200 + detail with links+subtasks
- PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop)
- PATCH /api/v1/calendar/entries/{id} status=done → 200
- DELETE /api/v1/calendar/entries/{id} → 204
- POST /api/v1/calendar/entries/{id}/link → 200, linked
- POST /api/v1/calendar/entries/{id}/subtasks → 201
- PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, toggled
- POST /api/v1/calendar/entries/bulk → 200, bulk status/delete
- GET /api/v1/calendar/kanban → 200 + tasks grouped by status
- GET /api/v1/calendar/entries/export?format=csv → 200 + CSV
- GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar
- GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid → 401
- POST /api/v1/calendar/import mit .ics file → 200 + import result
- POST /api/v1/resources → 201 (admin only, 403 non-admin)
- POST /api/v1/calendar/entries/{id}/book-resource → 200
- POST /api/v1/calendar/entries/{id}/book-resource (conflict) → 409
- Recurrence: weekly entry generates correct occurrences for date range query
- Recurrence: exception date excluded from occurrences
- Reminder: ARQ job scheduled when reminder JSONB set
- Calendar share: user with read permission can view, cannot edit (403)
- Private subtype: only owner+admin can see entry
Rules
- No
# noqa: F401for re-exports in routes/models (only in __init__.py) - Use
from Nonein 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