Files
leocrm/.a0/briefings/T05_briefing.md
T

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 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)
  • 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)

  1. GET /api/v1/calendar/entries?start=2026-01-01&end=2026-12-31 → 200 + entries in range
  2. POST /api/v1/calendar/entries (appointment) → 201, entry created with start_at/end_at
  3. POST /api/v1/calendar/entries (task) → 201, entry created with due_date/priority/status
  4. GET /api/v1/calendar/entries/{id} → 200 + entry detail with links+subtasks
  5. PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop: PATCH start_at+end_at, or status change)
  6. PATCH /api/v1/calendar/entries/{id} status=done → 200, status updated
  7. DELETE /api/v1/calendar/entries/{id} → 204
  8. POST /api/v1/calendar/entries/{id}/link → 200, linked to company/contact (entity_type, entity_id)
  9. POST /api/v1/calendar/entries/{id}/subtasks → 201, subtask created (title)
  10. PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, completed toggled

Bulk + Kanban + Export (3)

  1. POST /api/v1/calendar/entries/bulk → 200, bulk status change/delete (entry_ids, action)
  2. GET /api/v1/calendar/kanban → 200 + tasks grouped by status columns (open/in_progress/done/cancelled)
  3. GET /api/v1/calendar/entries/export?format=csv → 200 + CSV stream

ICS (2)

  1. GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar (NO auth, token-based)
  2. GET /api/v1/calendar/{calendar_id}/ics-feed?token=invalid → 401
  3. POST /api/v1/calendar/import (multipart .ics file) → 200 + import result (entries_created count)

Resources (2)

  1. POST /api/v1/resources → 201 (admin only, 403 for non-admin)
  2. POST /api/v1/calendar/entries/{id}/book-resource → 200, resource booked (resource_id)
  3. 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

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_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

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