"""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(), }