336 lines
14 KiB
Markdown
336 lines
14 KiB
Markdown
|
|
# 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
|