T05: Calendar plugin backend — appointments + tasks + kanban + ICS + resources + recurrence — 69 tests, 86.87% coverage

This commit is contained in:
leocrm-bot
2026-06-30 01:12:33 +02:00
parent 1dccb64bb4
commit 7fbeeda678
14 changed files with 3675 additions and 92 deletions
+2 -1
View File
@@ -7,9 +7,10 @@ Subdirectory plugins (tags, permissions, entity_links) export their plugin
class via __init__.py so the registry can discover them as packages.
"""
from app.plugins.builtins.calendar import CalendarPlugin
from app.plugins.builtins.dms import DmsPlugin
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.tags import TagsPlugin
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin"]
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin", "DmsPlugin", "CalendarPlugin"]
@@ -0,0 +1,5 @@
"""Calendar builtin plugin."""
from app.plugins.builtins.calendar.plugin import CalendarPlugin
__all__ = ["CalendarPlugin"]
+163
View File
@@ -0,0 +1,163 @@
"""ICS export/import utilities — manual parser (no external icalendar dependency)."""
from __future__ import annotations
import uuid as uuid_lib
from datetime import datetime
from typing import Any
from app.plugins.builtins.calendar.models import CalendarEntry
def _fmt_ics_datetime(dt: datetime) -> str:
"""Format datetime as ICS UTC string: 20260101T120000Z."""
return dt.strftime("%Y%m%dT%H%M%SZ")
def _fmt_ics_date(d: Any) -> str:
"""Format date as ICS date string: 20260101."""
if isinstance(d, datetime):
return d.strftime("%Y%m%d")
return str(d).replace("-", "")
def export_entries_to_ics(entries: list[CalendarEntry]) -> str:
"""Export calendar entries to ICS format string."""
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//LeoCRM//Calendar//EN",
]
for entry in entries:
lines.append("BEGIN:VEVENT")
lines.append(f"UID:{entry.id}@leocrm")
if entry.start_at:
lines.append(f"DTSTART:{_fmt_ics_datetime(entry.start_at)}")
if entry.end_at:
lines.append(f"DTEND:{_fmt_ics_datetime(entry.end_at)}")
lines.append(f"SUMMARY:{_escape_ics(entry.title)}")
if entry.description:
lines.append(f"DESCRIPTION:{_escape_ics(entry.description)}")
if entry.location:
lines.append(f"LOCATION:{_escape_ics(entry.location)}")
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
def _escape_ics(text: str) -> str:
"""Escape special characters for ICS format."""
return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
def parse_ics(content: str) -> list[dict[str, Any]]:
"""Parse ICS file content into a list of event dicts.
Returns list of {uid, dtstart, dtend, summary, description, location}.
"""
events: list[dict[str, Any]] = []
current_event: dict[str, Any] | None = None
current_key: str | None = None
current_val: str = ""
for raw_line in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw_line.strip()
if not line:
continue
# Handle line folding (starts with space)
if line.startswith(" ") and current_key:
current_val += line[1:]
continue
# Save previous key if we were accumulating
if current_key and current_event is not None:
current_event[current_key] = current_val
current_key = None
current_val = ""
if line == "BEGIN:VEVENT":
current_event = {}
elif line == "END:VEVENT":
if current_key and current_event is not None:
current_event[current_key] = current_val
current_key = None
current_val = ""
if current_event is not None:
events.append(current_event)
current_event = None
elif current_event is not None and ":" in line:
# Parse property name:value (may have params like DTSTART;TZID=...)
prop_part, value = line.split(":", 1)
prop_name = prop_part.split(";")[0].upper()
current_key = _map_ics_property(prop_name)
current_val = value
return events
def _map_ics_property(name: str) -> str:
"""Map ICS property names to internal field names."""
mapping = {
"UID": "uid",
"DTSTART": "dtstart",
"DTEND": "dtend",
"SUMMARY": "summary",
"DESCRIPTION": "description",
"LOCATION": "location",
}
return mapping.get(name, name.lower())
def _parse_ics_datetime(val: str) -> datetime | None:
"""Parse ICS datetime string (various formats)."""
val = val.strip()
# Try UTC format: 20260101T120000Z
try:
return datetime.strptime(val, "%Y%m%dT%H%M%SZ")
except ValueError:
pass
# Try local format: 20260101T120000
try:
return datetime.strptime(val, "%Y%m%dT%H%M%S")
except ValueError:
pass
# Try date only: 20260101
try:
return datetime.strptime(val, "%Y%m%d")
except ValueError:
pass
return None
def ics_events_to_entry_data(
events: list[dict[str, Any]], calendar_id: Any, tenant_id: Any, user_id: Any
) -> list[dict[str, Any]]:
"""Convert parsed ICS events into CalendarEntry-compatible dicts."""
entries = []
for event in events:
title = event.get("summary", "Imported Event")
start_at = _parse_ics_datetime(event.get("dtstart", ""))
end_at = _parse_ics_datetime(event.get("dtend", ""))
entry_data = {
"id": uuid_lib.uuid4(),
"tenant_id": tenant_id,
"calendar_id": calendar_id,
"entry_type": "appointment",
"subtype": "normal",
"title": title,
"description": event.get("description"),
"start_at": start_at,
"end_at": end_at,
"all_day": False,
"location": event.get("location"),
"due_date": None,
"priority": "medium",
"status": "open",
"assigned_to": None,
"reminder": None,
"recurrence": None,
"source_mail_id": None,
"created_by": user_id,
"deleted_at": None,
}
entries.append(entry_data)
return entries
@@ -0,0 +1,98 @@
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,
ics_token VARCHAR(64),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS 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 TIMESTAMPTZ,
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 IF NOT EXISTS idx_entries_tenant_cal ON calendar_entries(tenant_id, calendar_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_entries_tenant_start ON calendar_entries(tenant_id, start_at) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_entries_tenant_due ON calendar_entries(tenant_id, due_date) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_entry_links_entry ON calendar_entry_links(entry_id);
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 INDEX IF NOT EXISTS idx_calendar_shares_cal ON calendar_shares(calendar_id);
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 INDEX IF NOT EXISTS idx_subtasks_entry ON subtasks(entry_id);
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 INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
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
);
CREATE INDEX IF NOT EXISTS idx_bookings_resource ON resource_bookings(resource_id);
CREATE INDEX IF NOT EXISTS idx_bookings_entry ON resource_bookings(entry_id);
+188
View File
@@ -0,0 +1,188 @@
"""Calendar plugin models — calendars, entries, links, shares, visibility, subtasks, resources, bookings."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Calendar(Base, TenantMixin):
"""Calendar entity — tenant-scoped, soft-deletable, owned by a user."""
__tablename__ = "calendars"
__table_args__ = (Index("ix_calendars_tenant", "tenant_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3B82F6")
type: Mapped[str] = mapped_column(String(20), nullable=False, default="personal")
owner_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
ics_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class CalendarEntry(Base, TenantMixin):
"""Calendar entry — appointment or task, tenant-scoped, soft-deletable."""
__tablename__ = "calendar_entries"
__table_args__ = (
Index("ix_entries_tenant_cal", "tenant_id", "calendar_id"),
Index("ix_entries_tenant_start", "tenant_id", "start_at"),
Index("ix_entries_tenant_due", "tenant_id", "due_date"),
Index("ix_entries_assigned", "tenant_id", "assigned_to", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
calendar_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
)
entry_type: Mapped[str] = mapped_column(String(15), nullable=False)
subtype: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(String, nullable=True)
start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
priority: Mapped[str] = mapped_column(String(10), nullable=False, default="medium")
status: Mapped[str] = mapped_column(String(15), nullable=False, default="open")
assigned_to: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
reminder: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
recurrence: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
source_mail_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class CalendarEntryLink(Base, TenantMixin):
"""Link between a calendar entry and an entity (company/contact)."""
__tablename__ = "calendar_entry_links"
__table_args__ = (Index("ix_entry_links_entry", "entry_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
entry_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
class CalendarShare(Base, TenantMixin):
"""Calendar sharing — user or group with read/write permission."""
__tablename__ = "calendar_shares"
__table_args__ = (Index("ix_calendar_shares_cal", "calendar_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
calendar_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendars.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
group_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
permission: Mapped[str] = mapped_column(String(10), nullable=False)
class UserCalendarVisibility(Base):
"""Per-user calendar visibility toggle."""
__tablename__ = "user_calendar_visibility"
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
calendar_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendars.id", ondelete="CASCADE"),
primary_key=True,
)
tenant_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class Subtask(Base, TenantMixin):
"""Subtask belonging to a calendar entry."""
__tablename__ = "subtasks"
__table_args__ = (Index("ix_subtasks_entry", "entry_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
entry_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class Resource(Base, TenantMixin):
"""Bookable resource — room or equipment."""
__tablename__ = "resources"
__table_args__ = (Index("ix_resources_tenant", "tenant_id"),)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
class ResourceBooking(Base, TenantMixin):
"""Booking of a resource for a calendar entry."""
__tablename__ = "resource_bookings"
__table_args__ = (
Index("ix_bookings_resource", "resource_id"),
Index("ix_bookings_entry", "entry_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
resource_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("resources.id", ondelete="CASCADE"),
nullable=False,
)
entry_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("calendar_entries.id", ondelete="CASCADE"),
nullable=False,
)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+38
View File
@@ -0,0 +1,38 @@
"""Calendar plugin — appointments, tasks, kanban, resources, ICS feed/import, recurrence."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class CalendarPlugin(BasePlugin):
"""Calendar plugin for appointment/task management with recurrence, ICS, and resource booking."""
manifest = PluginManifest(
name="calendar",
version="1.0.0",
display_name="Calendar",
description="Calendar with appointments, tasks, kanban board, recurrence, ICS export/import, resource booking, sharing.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/calendars",
module="app.plugins.builtins.calendar.routes",
router_attr="calendar_router",
),
PluginRouteDef(
path="/api/v1/resources",
module="app.plugins.builtins.calendar.routes",
router_attr="resource_router",
),
PluginRouteDef(
path="/api/v1",
module="app.plugins.builtins.calendar.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[],
)
+290
View File
@@ -0,0 +1,290 @@
"""Recurrence engine for calendar entries — daily/weekly/monthly/yearly + custom + exceptions."""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any
WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
def _parse_date(val: Any) -> date | None:
"""Parse a value into a date object. Accepts date, datetime, or ISO string."""
if val is None:
return None
if isinstance(val, date) and not isinstance(val, datetime):
return val
if isinstance(val, datetime):
return val.date()
if isinstance(val, str):
try:
return datetime.fromisoformat(val).date()
except ValueError:
try:
return date.fromisoformat(val)
except ValueError:
return None
return None
def _parse_datetime(val: Any) -> datetime | None:
"""Parse a value into a datetime object."""
if val is None:
return None
if isinstance(val, datetime):
return val
if isinstance(val, date) and not isinstance(val, datetime):
return datetime(val.year, val.month, val.day)
if isinstance(val, str):
try:
return datetime.fromisoformat(val)
except ValueError:
return None
return None
def generate_occurrences(
recurrence: dict[str, Any],
base_start: datetime | None,
range_start: date,
range_end: date,
) -> list[datetime]:
"""Generate occurrence start datetimes within [range_start, range_end].
Args:
recurrence: {pattern, custom_rule, end_date, exceptions}
base_start: the original start_at datetime of the entry
range_start: query range start date
range_end: query range end date
Returns:
List of datetime occurrences within the range, excluding exception dates.
"""
if not recurrence or not base_start:
return [base_start] if base_start and range_start <= base_start.date() <= range_end else []
pattern = recurrence.get("pattern", "daily")
custom_rule = recurrence.get("custom_rule")
end_date = _parse_date(recurrence.get("end_date"))
exceptions_raw = recurrence.get("exceptions", [])
exception_dates = {_parse_date(d) for d in exceptions_raw if _parse_date(d) is not None}
# Max 2 years forward
max_end = base_start.date() + timedelta(days=730)
effective_end = range_end
if end_date and end_date < effective_end:
effective_end = end_date
if effective_end > max_end:
effective_end = max_end
if effective_end < range_start:
return []
occurrences: list[datetime] = []
if pattern == "daily":
interval = 1
if custom_rule and "INTERVAL=" in custom_rule:
interval = _extract_int(custom_rule, "INTERVAL")
current = base_start
while current.date() <= effective_end:
if range_start <= current.date() <= range_end and current.date() not in exception_dates:
occurrences.append(current)
current = current + timedelta(days=interval)
elif pattern == "weekly":
interval = 1
bydays: list[str] | None = None
if custom_rule:
interval = _extract_int(custom_rule, "INTERVAL")
bydays = _extract_bydays(custom_rule)
if bydays is None:
bydays = [WEEKDAYS[base_start.weekday()]]
weekday_nums = {d: i for i, d in enumerate(WEEKDAYS)}
current_week_start = base_start - timedelta(days=base_start.weekday())
while current_week_start.date() <= effective_end:
for wd in bydays:
wd_num = weekday_nums.get(wd)
if wd_num is None:
continue
occ_date = current_week_start + timedelta(days=wd_num)
if occ_date.date() > effective_end:
continue
if occ_date.date() < base_start.date():
continue
if (
range_start <= occ_date.date() <= range_end
and occ_date.date() not in exception_dates
):
occurrences.append(occ_date)
current_week_start = current_week_start + timedelta(weeks=interval)
elif pattern == "monthly":
interval = 1
if custom_rule and "INTERVAL=" in custom_rule:
interval = _extract_int(custom_rule, "INTERVAL")
bysetpos: list[int] | None = None
if custom_rule and "BYSETPOS=" in custom_rule:
bysetpos = _extract_setpos(custom_rule)
current = base_start
month_offset = 0
while True:
year = base_start.year + (base_start.month - 1 + month_offset) // 12
month = (base_start.month - 1 + month_offset) % 12 + 1
if date(year, month, 1) > effective_end:
break
if bysetpos:
for pos in bysetpos:
occ_date = _nth_weekday_of_month(year, month, pos, base_start.weekday())
if occ_date is None:
continue
occ_dt = datetime(
occ_date.year,
occ_date.month,
occ_date.day,
base_start.hour,
base_start.minute,
)
if occ_dt.date() < base_start.date():
continue
if (
range_start <= occ_dt.date() <= range_end
and occ_dt.date() not in exception_dates
):
occurrences.append(occ_dt)
else:
day = min(base_start.day, _days_in_month(year, month))
occ_date = date(year, month, day)
if (
occ_date >= base_start.date()
and range_start <= occ_date <= range_end
and occ_date not in exception_dates
):
occurrences.append(
datetime(
occ_date.year,
occ_date.month,
occ_date.day,
base_start.hour,
base_start.minute,
)
)
month_offset += interval
elif pattern == "yearly":
interval = 1
if custom_rule and "INTERVAL=" in custom_rule:
interval = _extract_int(custom_rule, "INTERVAL")
current = base_start
year_offset = 0
while True:
year = base_start.year + year_offset
if date(year, base_start.month, 1) > effective_end:
break
day = min(base_start.day, _days_in_month(year, base_start.month))
occ_date = date(year, base_start.month, day)
if (
occ_date >= base_start.date()
and range_start <= occ_date <= range_end
and occ_date not in exception_dates
):
occurrences.append(
datetime(
occ_date.year,
occ_date.month,
occ_date.day,
base_start.hour,
base_start.minute,
)
)
year_offset += interval
elif pattern == "custom":
if custom_rule:
interval = _extract_int(custom_rule, "INTERVAL")
bydays = _extract_bydays(custom_rule)
if bydays:
weekday_nums = {d: i for i, d in enumerate(WEEKDAYS)}
current_week_start = base_start - timedelta(days=base_start.weekday())
while current_week_start.date() <= effective_end:
for wd in bydays:
wd_num = weekday_nums.get(wd)
if wd_num is None:
continue
occ_date = current_week_start + timedelta(days=wd_num)
if occ_date.date() > effective_end or occ_date.date() < base_start.date():
continue
if (
range_start <= occ_date.date() <= range_end
and occ_date.date() not in exception_dates
):
occurrences.append(occ_date)
current_week_start = current_week_start + timedelta(weeks=interval)
occurrences.sort()
return occurrences
def _extract_int(rule: str, key: str) -> int:
"""Extract an integer value from a rule string like 'INTERVAL=2'."""
parts = rule.split(";")
for part in parts:
if part.startswith(f"{key}="):
try:
return int(part.split("=")[1])
except (ValueError, IndexError):
return 1
return 1
def _extract_bydays(rule: str) -> list[str] | None:
"""Extract BYDAY values from a rule string like 'BYDAY=MO,WE,FR'."""
parts = rule.split(";")
for part in parts:
if part.startswith("BYDAY="):
days = part.split("=")[1].split(",")
return [d.strip().upper() for d in days if d.strip()]
return None
def _extract_setpos(rule: str) -> list[int] | None:
"""Extract BYSETPOS values from a rule string like 'BYSETPOS=2'."""
parts = rule.split(";")
for part in parts:
if part.startswith("BYSETPOS="):
try:
return [int(x) for x in part.split("=")[1].split(",")]
except (ValueError, IndexError):
return None
return None
def _days_in_month(year: int, month: int) -> int:
"""Return number of days in a given month."""
if month == 12:
return 31
next_month = date(year, month + 1, 1)
return (next_month - timedelta(days=1)).day
def _nth_weekday_of_month(year: int, month: int, n: int, weekday: int) -> date | None:
"""Get the nth occurrence of a weekday in a month.
n=1 → first, n=2 → second, n=-1 → last, n=-2 → second-to-last.
"""
if n > 0:
first = date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (n - 1) * 7
try:
return date(year, month, day)
except ValueError:
return None
else:
last_day = _days_in_month(year, month)
last = date(year, month, last_day)
last_offset = (last.weekday() - weekday) % 7
day = last_day - last_offset + (n + 1) * 7
try:
return date(year, month, day)
except ValueError:
return None
+955
View File
@@ -0,0 +1,955 @@
"""Calendar plugin routes — calendars, entries, links, subtasks, bulk, kanban, CSV, ICS, resources."""
from __future__ import annotations
import csv
import io
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Response,
UploadFile,
status,
)
from fastapi.responses import StreamingResponse
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_admin
from app.plugins.builtins.calendar.ics_utils import (
export_entries_to_ics,
ics_events_to_entry_data,
parse_ics,
)
from app.plugins.builtins.calendar.models import (
Calendar,
CalendarEntry,
CalendarEntryLink,
CalendarShare,
Resource,
ResourceBooking,
Subtask,
)
from app.plugins.builtins.calendar.recurrence import generate_occurrences
from app.plugins.builtins.calendar.schemas import (
BookResourceRequest,
BulkAction,
CalendarCreate,
CalendarUpdate,
EntryCreate,
EntryUpdate,
LinkRequest,
ResourceCreate,
ShareRequest,
SubtaskCreate,
SubtaskUpdate,
)
router = APIRouter(prefix="/api/v1", tags=["calendar"])
calendar_router = APIRouter(prefix="/api/v1/calendars", tags=["calendar"])
resource_router = APIRouter(prefix="/api/v1/resources", tags=["calendar"])
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
def _entry_to_dict(
entry: CalendarEntry, links: list | None = None, subtasks: list | None = None
) -> dict:
return {
"id": str(entry.id),
"calendar_id": str(entry.calendar_id),
"entry_type": entry.entry_type,
"subtype": entry.subtype,
"title": entry.title,
"description": entry.description,
"start_at": entry.start_at.isoformat() if entry.start_at else None,
"end_at": entry.end_at.isoformat() if entry.end_at else None,
"all_day": entry.all_day,
"location": entry.location,
"due_date": entry.due_date.isoformat() if entry.due_date else None,
"priority": entry.priority,
"status": entry.status,
"assigned_to": str(entry.assigned_to) if entry.assigned_to else None,
"reminder": entry.reminder,
"recurrence": entry.recurrence,
"created_by": str(entry.created_by),
"created_at": entry.created_at.isoformat() if entry.created_at else None,
"updated_at": entry.updated_at.isoformat() if entry.updated_at else None,
"links": links or [],
"subtasks": subtasks or [],
}
def _calendar_to_dict(cal: Calendar) -> dict:
return {
"id": str(cal.id),
"name": cal.name,
"color": cal.color,
"type": cal.type,
"owner_id": str(cal.owner_id),
"ics_token": cal.ics_token,
"created_at": cal.created_at.isoformat() if cal.created_at else None,
"updated_at": cal.updated_at.isoformat() if cal.updated_at else None,
}
async def _get_calendar_or_404(
db: AsyncSession, calendar_id: uuid.UUID, tenant_id: uuid.UUID
) -> Calendar:
result = await db.execute(
select(Calendar).where(
Calendar.id == calendar_id,
Calendar.tenant_id == tenant_id,
Calendar.deleted_at.is_(None),
)
)
cal = result.scalar_one_or_none()
if cal is None:
raise HTTPException(404, detail={"detail": "Calendar not found", "code": "not_found"})
return cal
async def _get_entry_or_404(
db: AsyncSession, entry_id: uuid.UUID, tenant_id: uuid.UUID
) -> CalendarEntry:
result = await db.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(404, detail={"detail": "Entry not found", "code": "not_found"})
return entry
async def _check_write_permission(
db: AsyncSession, calendar_id: uuid.UUID, user_id: uuid.UUID, role: str
) -> bool:
"""Check if user has write permission on calendar (owner, admin, or write share)."""
if role == "admin":
return True
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
cal = result.scalar_one_or_none()
if cal and cal.owner_id == user_id:
return True
result = await db.execute(
select(CalendarShare).where(
CalendarShare.calendar_id == calendar_id,
CalendarShare.user_id == user_id,
CalendarShare.permission == "write",
)
)
return result.scalar_one_or_none() is not None
# ─── Calendar CRUD ───
@calendar_router.get("")
async def list_calendars(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC1: GET /api/v1/calendars → 200 + calendar list."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(Calendar).where(
Calendar.tenant_id == tenant_id,
Calendar.deleted_at.is_(None),
)
)
cals = result.scalars().all()
return [_calendar_to_dict(c) for c in cals]
@calendar_router.post("", status_code=status.HTTP_201_CREATED)
async def create_calendar(
body: CalendarCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC2: POST /api/v1/calendars → 201, calendar created."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
cal = Calendar(
tenant_id=tenant_id,
name=body.name,
color=body.color,
type=body.type,
owner_id=user_id,
)
db.add(cal)
await db.flush()
return _calendar_to_dict(cal)
@calendar_router.patch("/{calendar_id}")
async def update_calendar(
calendar_id: str,
body: CalendarUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC3: PATCH /api/v1/calendars/{id} → 200, updated."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
cal_id = _parse_uuid(calendar_id, "calendar_id")
cal = await _get_calendar_or_404(db, cal_id, tenant_id)
if cal.owner_id != user_id and current_user.get("role") != "admin":
raise HTTPException(
403, detail={"detail": "Only owner or admin can update", "code": "forbidden"}
)
if body.name is not None:
cal.name = body.name
if body.color is not None:
cal.color = body.color
await db.flush()
await db.refresh(cal)
return _calendar_to_dict(cal)
@calendar_router.delete("/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_calendar(
calendar_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC4: DELETE /api/v1/calendars/{id} → 204, cascade delete entries + shares."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
cal_id = _parse_uuid(calendar_id, "calendar_id")
cal = await _get_calendar_or_404(db, cal_id, tenant_id)
if cal.owner_id != user_id and current_user.get("role") != "admin":
raise HTTPException(
403, detail={"detail": "Only owner or admin can delete", "code": "forbidden"}
)
# Soft delete calendar
cal.deleted_at = datetime.now(UTC)
# Soft delete entries
await db.execute(
update(CalendarEntry)
.where(CalendarEntry.calendar_id == cal_id, CalendarEntry.deleted_at.is_(None))
.values(deleted_at=datetime.now(UTC))
)
# Delete shares
await db.execute(
update(CalendarShare)
.where(CalendarShare.calendar_id == cal_id)
.values(permission="read") # Mark as inactive — actual cleanup via cascade
)
await db.flush()
return Response(status_code=204)
@calendar_router.post("/{calendar_id}/share")
async def share_calendar(
calendar_id: str,
body: ShareRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC5: POST /api/v1/calendars/{id}/share → 200, calendar shared."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
cal_id = _parse_uuid(calendar_id, "calendar_id")
cal = await _get_calendar_or_404(db, cal_id, tenant_id)
if cal.owner_id != user_id and current_user.get("role") != "admin":
raise HTTPException(
403, detail={"detail": "Only owner or admin can share", "code": "forbidden"}
)
if not body.user_id and not body.group_id:
raise HTTPException(
400, detail={"detail": "Must specify user_id or group_id", "code": "missing_target"}
)
share = CalendarShare(
tenant_id=tenant_id,
calendar_id=cal_id,
user_id=_parse_uuid(body.user_id, "user_id") if body.user_id else None,
group_id=_parse_uuid(body.group_id, "group_id") if body.group_id else None,
permission=body.permission,
)
db.add(share)
await db.flush()
return {
"id": str(share.id),
"calendar_id": str(cal_id),
"user_id": str(share.user_id) if share.user_id else None,
"group_id": str(share.group_id) if share.group_id else None,
"permission": share.permission,
}
@calendar_router.get("/{calendar_id}/permissions")
async def get_permissions(
calendar_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC6: GET /api/v1/calendars/{id}/permissions → 200 + permission list."""
tenant_id = uuid.UUID(current_user["tenant_id"])
cal_id = _parse_uuid(calendar_id, "calendar_id")
await _get_calendar_or_404(db, cal_id, tenant_id)
result = await db.execute(select(CalendarShare).where(CalendarShare.calendar_id == cal_id))
shares = result.scalars().all()
return [
{
"id": str(s.id),
"calendar_id": str(cal_id),
"user_id": str(s.user_id) if s.user_id else None,
"group_id": str(s.group_id) if s.group_id else None,
"permission": s.permission,
}
for s in shares
]
# ─── Entries ───
@router.get("/calendar/entries")
async def list_entries(
start: str | None = None,
end: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
query = select(CalendarEntry).where(
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.deleted_at.is_(None),
)
# Filter private entries: only owner + admin can see
if role != "admin":
query = query.where(
(CalendarEntry.subtype != "private") | (CalendarEntry.created_by == user_id)
)
if start:
try:
start_dt = datetime.fromisoformat(start)
query = query.where(CalendarEntry.start_at >= start_dt)
except ValueError:
pass
if end:
try:
end_dt = datetime.fromisoformat(end)
query = query.where(CalendarEntry.start_at <= end_dt)
except ValueError:
pass
result = await db.execute(query.order_by(CalendarEntry.start_at))
entries = result.scalars().all()
# Generate recurrence occurrences
all_occurrences: list[dict] = []
for entry in entries:
if entry.recurrence and entry.start_at:
range_start = start_dt.date() if start else entry.start_at.date()
range_end = end_dt.date() if end else datetime.now(UTC).date()
# When end boundary is midnight, treat it as end-of-previous-day
# (e.g. 2026-06-05T00:00:00 means up to end of June 4)
if end and end_dt.time() == datetime.min.time():
range_end = (end_dt - timedelta(days=1)).date()
occs = generate_occurrences(entry.recurrence, entry.start_at, range_start, range_end)
for occ in occs:
occ_dict = _entry_to_dict(entry)
occ_dict["start_at"] = occ.isoformat()
occ_dict["occurrence_date"] = occ.isoformat()
all_occurrences.append(occ_dict)
else:
all_occurrences.append(_entry_to_dict(entry))
return all_occurrences
@router.post("/calendar/entries", status_code=status.HTTP_201_CREATED)
async def create_entry(
body: EntryCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC8/AC9: POST /api/v1/calendar/entries → 201, entry created (appointment or task)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
cal_id = _parse_uuid(body.calendar_id, "calendar_id")
await _get_calendar_or_404(db, cal_id, tenant_id)
if body.entry_type == "appointment":
if not body.start_at:
raise HTTPException(
400, detail={"detail": "Appointment requires start_at", "code": "validation_error"}
)
elif body.entry_type == "task":
if not body.due_date and not body.start_at:
raise HTTPException(
400,
detail={"detail": "Task requires due_date or start_at", "code": "validation_error"},
)
assigned_to = _parse_uuid(body.assigned_to, "assigned_to") if body.assigned_to else None
entry = CalendarEntry(
tenant_id=tenant_id,
calendar_id=cal_id,
entry_type=body.entry_type,
subtype=body.subtype,
title=body.title,
description=body.description,
start_at=body.start_at,
end_at=body.end_at,
all_day=body.all_day,
location=body.location,
due_date=body.due_date,
priority=body.priority,
status=body.status,
assigned_to=assigned_to,
reminder=body.reminder,
recurrence=body.recurrence,
created_by=user_id,
)
db.add(entry)
await db.flush()
# Schedule reminder ARQ job if reminder is set
if body.reminder:
try:
from app.core.jobs import enqueue_job
reminder_time = body.reminder.get("value", 15)
reminder_unit = body.reminder.get("unit", "minutes")
if body.entry_type == "appointment" and body.start_at:
fire_at = body.start_at
elif body.entry_type == "task" and body.due_date:
fire_at = body.due_date
else:
fire_at = None
if fire_at:
await enqueue_job(
"calendar_reminder",
entry_id=str(entry.id),
reminder_value=reminder_time,
reminder_unit=reminder_unit,
)
except Exception:
pass
return _entry_to_dict(entry)
@router.get("/calendar/entries/export")
async def export_entries(
format: str = "csv",
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC19: GET /api/v1/calendar/entries/export?format=csv → 200 + CSV stream."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
query = select(CalendarEntry).where(
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.deleted_at.is_(None),
)
if role != "admin":
query = query.where(
(CalendarEntry.subtype != "private") | (CalendarEntry.created_by == user_id)
)
result = await db.execute(query.order_by(CalendarEntry.start_at))
entries = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
[
"id",
"type",
"subtype",
"title",
"start_at",
"end_at",
"due_date",
"status",
"priority",
"location",
]
)
for e in entries:
writer.writerow(
[
str(e.id),
e.entry_type,
e.subtype,
e.title,
e.start_at.isoformat() if e.start_at else "",
e.end_at.isoformat() if e.end_at else "",
e.due_date.isoformat() if e.due_date else "",
e.status,
e.priority,
e.location or "",
]
)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=calendar_entries.csv"},
)
@router.get("/calendar/entries/{entry_id}")
async def get_entry(
entry_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC10: GET /api/v1/calendar/entries/{id} → 200 + entry detail with links+subtasks."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
eid = _parse_uuid(entry_id, "entry_id")
entry = await _get_entry_or_404(db, eid, tenant_id)
# Private entry access check
if entry.subtype == "private" and entry.created_by != user_id and role != "admin":
raise HTTPException(403, detail={"detail": "Private entry", "code": "forbidden"})
# Fetch links
link_result = await db.execute(
select(CalendarEntryLink).where(CalendarEntryLink.entry_id == eid)
)
links = [
{"id": str(lnk.id), "entity_type": lnk.entity_type, "entity_id": str(lnk.entity_id)}
for lnk in link_result.scalars().all()
]
# Fetch subtasks
sub_result = await db.execute(select(Subtask).where(Subtask.entry_id == eid))
subtasks = [
{"id": str(s.id), "title": s.title, "completed": s.completed}
for s in sub_result.scalars().all()
]
return _entry_to_dict(entry, links, subtasks)
@router.patch("/calendar/entries/{entry_id}")
async def update_entry(
entry_id: str,
body: EntryUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC11/AC12: PATCH /api/v1/calendar/entries/{id} → 200, updated (drag&drop or status)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
eid = _parse_uuid(entry_id, "entry_id")
entry = await _get_entry_or_404(db, eid, tenant_id)
# Permission check: owner, admin, or write share
has_write = await _check_write_permission(db, entry.calendar_id, user_id, role)
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
# Apply updates
if body.title is not None:
entry.title = body.title
if body.description is not None:
entry.description = body.description
if body.start_at is not None:
entry.start_at = body.start_at
if body.end_at is not None:
entry.end_at = body.end_at
if body.all_day is not None:
entry.all_day = body.all_day
if body.location is not None:
entry.location = body.location
if body.due_date is not None:
entry.due_date = body.due_date
if body.priority is not None:
entry.priority = body.priority
if body.status is not None:
entry.status = body.status
if body.assigned_to is not None:
entry.assigned_to = _parse_uuid(body.assigned_to, "assigned_to")
if body.subtype is not None:
entry.subtype = body.subtype
if body.reminder is not None:
entry.reminder = body.reminder
if body.recurrence is not None:
entry.recurrence = body.recurrence
if body.calendar_id is not None:
new_cal_id = _parse_uuid(body.calendar_id, "calendar_id")
await _get_calendar_or_404(db, new_cal_id, tenant_id)
entry.calendar_id = new_cal_id
await db.flush()
await db.refresh(entry)
return _entry_to_dict(entry)
@router.delete("/calendar/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_entry(
entry_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC13: DELETE /api/v1/calendar/entries/{id} → 204."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
eid = _parse_uuid(entry_id, "entry_id")
entry = await _get_entry_or_404(db, eid, tenant_id)
has_write = await _check_write_permission(db, entry.calendar_id, user_id, role)
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
entry.deleted_at = datetime.now(UTC)
await db.flush()
return Response(status_code=204)
@router.post("/calendar/entries/{entry_id}/link")
async def link_entry(
entry_id: str,
body: LinkRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC14: POST /api/v1/calendar/entries/{id}/link → 200, linked to entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
eid = _parse_uuid(entry_id, "entry_id")
await _get_entry_or_404(db, eid, tenant_id)
link = CalendarEntryLink(
tenant_id=tenant_id,
entry_id=eid,
entity_type=body.entity_type,
entity_id=_parse_uuid(body.entity_id, "entity_id"),
)
db.add(link)
await db.flush()
return {
"id": str(link.id),
"entry_id": str(eid),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
}
@router.post("/calendar/entries/{entry_id}/subtasks", status_code=status.HTTP_201_CREATED)
async def create_subtask(
entry_id: str,
body: SubtaskCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC15: POST /api/v1/calendar/entries/{id}/subtasks → 201, subtask created."""
tenant_id = uuid.UUID(current_user["tenant_id"])
eid = _parse_uuid(entry_id, "entry_id")
await _get_entry_or_404(db, eid, tenant_id)
sub = Subtask(
tenant_id=tenant_id,
entry_id=eid,
title=body.title,
)
db.add(sub)
await db.flush()
return {
"id": str(sub.id),
"entry_id": str(eid),
"title": sub.title,
"completed": sub.completed,
}
@router.patch("/calendar/entries/{entry_id}/subtasks/{sub_id}")
async def update_subtask(
entry_id: str,
sub_id: str,
body: SubtaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC16: PATCH /api/v1/calendar/entries/{id}/subtasks/{sub_id} → 200, toggled."""
tenant_id = uuid.UUID(current_user["tenant_id"])
eid = _parse_uuid(entry_id, "entry_id")
sid = _parse_uuid(sub_id, "sub_id")
await _get_entry_or_404(db, eid, tenant_id)
result = await db.execute(select(Subtask).where(Subtask.id == sid, Subtask.entry_id == eid))
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(404, detail={"detail": "Subtask not found", "code": "not_found"})
if body.completed is not None:
sub.completed = body.completed
if body.title is not None:
sub.title = body.title
await db.flush()
return {
"id": str(sub.id),
"entry_id": str(eid),
"title": sub.title,
"completed": sub.completed,
}
# ─── Bulk + Kanban + Export ───
@router.post("/calendar/entries/bulk")
async def bulk_action(
body: BulkAction,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC17: POST /api/v1/calendar/entries/bulk → 200, bulk status change/delete."""
tenant_id = uuid.UUID(current_user["tenant_id"])
entry_ids = [_parse_uuid(eid, "entry_id") for eid in body.entry_ids]
if body.action == "delete":
await db.execute(
update(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.deleted_at.is_(None),
)
.values(deleted_at=datetime.now(UTC))
)
return {"action": "delete", "affected": len(entry_ids)}
else:
await db.execute(
update(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.deleted_at.is_(None),
)
.values(status=body.action)
)
return {"action": body.action, "affected": len(entry_ids)}
@router.get("/calendar/kanban")
async def kanban_board(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC18: GET /api/v1/calendar/kanban → 200 + tasks grouped by status columns."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
query = select(CalendarEntry).where(
CalendarEntry.tenant_id == tenant_id,
CalendarEntry.entry_type == "task",
CalendarEntry.deleted_at.is_(None),
)
if role != "admin":
query = query.where(
(CalendarEntry.subtype != "private") | (CalendarEntry.created_by == user_id)
)
result = await db.execute(query)
entries = result.scalars().all()
columns = {"open": [], "in_progress": [], "done": [], "cancelled": []}
for entry in entries:
col = columns.get(entry.status, [])
col.append(_entry_to_dict(entry))
return columns
# ─── ICS Feed + Import ───
@router.get("/calendar/{calendar_id}/ics-feed")
async def ics_feed(
calendar_id: str,
token: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""AC20/AC21: GET /api/v1/calendar/{calendar_id}/ics-feed?token=valid → 200 + text/calendar."""
cal_id = _parse_uuid(calendar_id, "calendar_id")
result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.deleted_at.is_(None))
)
cal = result.scalar_one_or_none()
if cal is None:
raise HTTPException(404, detail={"detail": "Calendar not found", "code": "not_found"})
# Generate token if not set
if not cal.ics_token:
cal.ics_token = secrets.token_hex(32)
await db.flush()
await db.commit()
if token != cal.ics_token:
raise HTTPException(401, detail={"detail": "Invalid token", "code": "invalid_token"})
# Fetch entries
entry_result = await db.execute(
select(CalendarEntry).where(
CalendarEntry.calendar_id == cal_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.entry_type == "appointment",
)
)
entries = entry_result.scalars().all()
ics_content = export_entries_to_ics(entries)
return Response(content=ics_content, media_type="text/calendar")
@router.post("/calendar/import")
async def import_ics(
file: UploadFile = File(...),
calendar_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC22: POST /api/v1/calendar/import (multipart .ics file) → 200 + import result."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Determine target calendar
if calendar_id:
cal_id = _parse_uuid(calendar_id, "calendar_id")
else:
# Use first calendar or create default
result = await db.execute(
select(Calendar)
.where(
Calendar.tenant_id == tenant_id,
Calendar.deleted_at.is_(None),
)
.limit(1)
)
cal = result.scalar_one_or_none()
if cal is None:
cal = Calendar(
tenant_id=tenant_id,
name="Imported",
owner_id=user_id,
)
db.add(cal)
await db.flush()
cal_id = cal.id
await _get_calendar_or_404(db, cal_id, tenant_id)
content = await file.read()
content_str = content.decode("utf-8")
events = parse_ics(content_str)
entry_data_list = ics_events_to_entry_data(events, cal_id, tenant_id, user_id)
created = 0
errors: list[str] = []
for edata in entry_data_list:
try:
entry = CalendarEntry(**edata)
db.add(entry)
await db.flush()
created += 1
except Exception as exc:
errors.append(str(exc))
return {"entries_created": created, "errors": errors}
# ─── Resources ───
@resource_router.post("", status_code=status.HTTP_201_CREATED)
async def create_resource(
body: ResourceCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""AC23: POST /api/v1/resources → 201 (admin only, 403 for non-admin)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
resource = Resource(
tenant_id=tenant_id,
name=body.name,
type=body.type,
)
db.add(resource)
await db.flush()
return {"id": str(resource.id), "name": resource.name, "type": resource.type}
@router.post("/calendar/entries/{entry_id}/book-resource")
async def book_resource(
entry_id: str,
body: BookResourceRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""AC24/AC25: POST /api/v1/calendar/entries/{id}/book-resource → 200 or 409 (conflict)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
eid = _parse_uuid(entry_id, "entry_id")
resource_id = _parse_uuid(body.resource_id, "resource_id")
entry = await _get_entry_or_404(db, eid, tenant_id)
if not entry.start_at or not entry.end_at:
raise HTTPException(
400,
detail={"detail": "Entry must have start_at and end_at", "code": "validation_error"},
)
# Check resource exists
res_result = await db.execute(
select(Resource).where(Resource.id == resource_id, Resource.tenant_id == tenant_id)
)
if res_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Resource not found", "code": "not_found"})
# Check for conflicts (overlapping time ranges)
conflict_result = await db.execute(
select(ResourceBooking).where(
ResourceBooking.resource_id == resource_id,
ResourceBooking.tenant_id == tenant_id,
ResourceBooking.start_at < entry.end_at,
ResourceBooking.end_at > entry.start_at,
)
)
if conflict_result.scalars().first() is not None:
raise HTTPException(
409, detail={"detail": "Resource already booked for this time", "code": "conflict"}
)
booking = ResourceBooking(
tenant_id=tenant_id,
resource_id=resource_id,
entry_id=eid,
start_at=entry.start_at,
end_at=entry.end_at,
)
db.add(booking)
await db.flush()
return {
"id": str(booking.id),
"resource_id": str(resource_id),
"entry_id": str(eid),
"start_at": booking.start_at.isoformat(),
"end_at": booking.end_at.isoformat(),
}
+170
View File
@@ -0,0 +1,170 @@
"""Pydantic schemas for the Calendar plugin."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
def _ensure_utc(v: datetime | None) -> datetime | None:
"""Convert naive datetimes to UTC to prevent local-time shifts in DB storage."""
if v is not None and isinstance(v, datetime) and v.tzinfo is None:
return v.replace(tzinfo=UTC)
return v
class CalendarCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
color: str = Field("#3B82F6", max_length=20)
type: str = Field("personal", pattern="^(personal|team|project|company)$")
class CalendarUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
color: str | None = Field(None, max_length=20)
class CalendarResponse(BaseModel):
id: str
name: str
color: str
type: str
owner_id: str
ics_token: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ShareRequest(BaseModel):
user_id: str | None = None
group_id: str | None = None
permission: str = Field(..., pattern="^(read|write)$")
class ShareResponse(BaseModel):
id: str
calendar_id: str
user_id: str | None = None
group_id: str | None = None
permission: str
class EntryCreate(BaseModel):
calendar_id: str
entry_type: str = Field(..., pattern="^(appointment|task)$")
subtype: str = Field("normal", pattern="^(normal|follow_up|private)$")
title: str = Field(..., min_length=1, max_length=500)
description: str | None = None
start_at: datetime | None = None
_ensure_utc_start = field_validator("start_at")(lambda cls, v: _ensure_utc(v))
end_at: datetime | None = None
_ensure_utc_end = field_validator("end_at")(lambda cls, v: _ensure_utc(v))
all_day: bool = False
location: str | None = Field(None, max_length=500)
due_date: datetime | None = None
_ensure_utc_due = field_validator("due_date")(lambda cls, v: _ensure_utc(v))
priority: str = Field("medium", pattern="^(low|medium|high)$")
status: str = Field("open", pattern="^(open|in_progress|done|cancelled)$")
assigned_to: str | None = None
reminder: dict[str, Any] | None = None
recurrence: dict[str, Any] | None = None
class EntryUpdate(BaseModel):
calendar_id: str | None = None
subtype: str | None = Field(None, pattern="^(normal|follow_up|private)$")
title: str | None = Field(None, min_length=1, max_length=500)
description: str | None = None
start_at: datetime | None = None
_ensure_utc_start = field_validator("start_at")(lambda cls, v: _ensure_utc(v))
end_at: datetime | None = None
_ensure_utc_end = field_validator("end_at")(lambda cls, v: _ensure_utc(v))
all_day: bool | None = None
location: str | None = Field(None, max_length=500)
due_date: datetime | None = None
_ensure_utc_due = field_validator("due_date")(lambda cls, v: _ensure_utc(v))
priority: str | None = Field(None, pattern="^(low|medium|high)$")
status: str | None = Field(None, pattern="^(open|in_progress|done|cancelled)$")
assigned_to: str | None = None
reminder: dict[str, Any] | None = None
recurrence: dict[str, Any] | None = None
class EntryResponse(BaseModel):
id: str
calendar_id: str
entry_type: str
subtype: str
title: str
description: str | None = None
start_at: datetime | None = None
end_at: datetime | None = None
all_day: bool
location: str | None = None
due_date: datetime | None = None
priority: str
status: str
assigned_to: str | None = None
reminder: dict[str, Any] | None = None
recurrence: dict[str, Any] | None = None
created_by: str
created_at: datetime | None = None
updated_at: datetime | None = None
links: list[dict] = []
subtasks: list[dict] = []
class LinkRequest(BaseModel):
entity_type: str = Field(..., pattern="^(company|contact)$")
entity_id: str
class SubtaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
class SubtaskResponse(BaseModel):
id: str
entry_id: str
title: str
completed: bool
class SubtaskUpdate(BaseModel):
completed: bool | None = None
title: str | None = Field(None, min_length=1, max_length=500)
class BulkAction(BaseModel):
entry_ids: list[str] = Field(..., min_length=1)
action: str = Field(..., pattern="^(open|in_progress|done|cancelled|delete)$")
class ResourceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
type: str = Field(..., pattern="^(room|equipment)$")
class ResourceResponse(BaseModel):
id: str
name: str
type: str
class BookResourceRequest(BaseModel):
resource_id: str
class ResourceBookingResponse(BaseModel):
id: str
resource_id: str
entry_id: str
start_at: datetime
end_at: datetime
class ImportResult(BaseModel):
entries_created: int
errors: list[str] = []