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