T05: Calendar plugin backend — appointments + tasks + kanban + ICS + resources + recurrence — 69 tests, 86.87% coverage
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# 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:<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 `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
|
||||
Reference in New Issue
Block a user