fischer-agentkit/src/agentkit/server/routes/calendar.py

455 lines
15 KiB
Python

"""REST API routes for calendar operations (U2).
Thin wrapper over CalendarService. All business logic lives in the
service layer — routes handle HTTP concerns (auth, request validation).
Endpoints (all under /api/v1/calendar):
- POST /calendar/events — create event
- GET /calendar/events — list with filters (start, end, type_id, tag_id)
- GET /calendar/events/{event_id} — get single event
- PATCH /calendar/events/{event_id} — update event
- DELETE /calendar/events/{event_id} — delete event
- POST /calendar/events/{event_id}/invitations — invite user by email
- POST /calendar/invitations/{invitation_id}/respond — accept/decline/tentative
- GET /calendar/invitations — list invitations for current user
- GET /calendar/users/search?q=xxx — search users (G5/A3)
- GET /calendar/event-types — list event types
- POST /calendar/event-types — create event type
- PATCH /calendar/event-types/{type_id} — update event type
- GET /calendar/tags — list tags
- POST /calendar/tags — create tag
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from agentkit.server.auth.dependencies import require_authenticated
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/calendar", tags=["calendar"])
_VALID_INVITATION_STATUSES = {"accepted", "declined", "tentative"}
# ---------------------------------------------------------------------------
# Service accessor
# ---------------------------------------------------------------------------
def _get_calendar_service(request: Request):
"""Get CalendarService from app.state. Raises 503 if not initialized."""
service = getattr(request.app.state, "calendar_service", None)
if service is None:
raise HTTPException(
status_code=503,
detail="Calendar service not available. Server may not have initialized it.",
)
return service
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class CreateEventRequest(BaseModel):
title: str
start_time: str
end_time: str
description: str = ""
location: str = ""
is_all_day: bool = False
event_type_id: str | None = None
rrule: str | None = None
tag_ids: list[str] = Field(default_factory=list)
class UpdateEventRequest(BaseModel):
title: str | None = None
start_time: str | None = None
end_time: str | None = None
description: str | None = None
location: str | None = None
is_all_day: bool | None = None
event_type_id: str | None = None
rrule: str | None = None
class CreateEventTypeRequest(BaseModel):
name: str
color: str = "#4A90D9"
class UpdateEventTypeRequest(BaseModel):
name: str | None = None
color: str | None = None
is_default: bool | None = None
class CreateTagRequest(BaseModel):
name: str
class CreateInvitationRequest(BaseModel):
invitee_email: str
class RespondInvitationRequest(BaseModel):
status: str
# ---------------------------------------------------------------------------
# Event endpoints
# ---------------------------------------------------------------------------
@router.post("/events")
async def create_event(
body: CreateEventRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Create a new calendar event."""
service = _get_calendar_service(request)
event = await service.create_event(
user_id=user["user_id"],
title=body.title,
start_time=body.start_time,
end_time=body.end_time,
description=body.description,
location=body.location,
is_all_day=body.is_all_day,
event_type_id=body.event_type_id,
rrule=body.rrule,
source="manual",
tag_ids=body.tag_ids,
)
return {"success": True, "event": event.to_dict()}
@router.get("/events")
async def list_events(
request: Request,
start: str | None = Query(None),
end: str | None = Query(None),
type_id: str | None = Query(None),
tag_id: str | None = Query(None),
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""List events for the current user with optional filters."""
service = _get_calendar_service(request)
events = await service.list_events(
user_id=user["user_id"],
start=start,
end=end,
event_type_id=type_id,
tag_id=tag_id,
)
return {
"success": True,
"events": [e.to_dict() for e in events],
"count": len(events),
}
@router.get("/events/{event_id}")
async def get_event(
event_id: str,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Get a single event by id."""
service = _get_calendar_service(request)
event = await service.get_event(event_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if event.user_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return {"success": True, "event": event.to_dict()}
@router.patch("/events/{event_id}")
async def update_event(
event_id: str,
body: UpdateEventRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Update specific fields of an event."""
service = _get_calendar_service(request)
event = await service.get_event(event_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if event.user_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Build fields dict from non-None values
fields: dict[str, Any] = {
name: value
for name, value in body.model_dump(exclude_unset=True).items()
if value is not None
}
if not fields:
return {"success": True, "event": event.to_dict(), "updated": False}
updated = await service.update_event(event_id, fields)
refreshed = await service.get_event(event_id)
return {
"success": True,
"event": refreshed.to_dict() if refreshed else event.to_dict(),
"updated": updated,
}
@router.delete("/events/{event_id}")
async def delete_event(
event_id: str,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Delete an event."""
service = _get_calendar_service(request)
event = await service.get_event(event_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if event.user_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
deleted = await service.delete_event(event_id)
return {"success": True, "deleted": deleted}
# ---------------------------------------------------------------------------
# Invitation endpoints
# ---------------------------------------------------------------------------
@router.post("/events/{event_id}/invitations")
async def create_invitation(
event_id: str,
body: CreateInvitationRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Invite a user to an event by email."""
service = _get_calendar_service(request)
event = await service.get_event(event_id)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if event.user_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Only the event owner can invite")
invitation = await service.create_invitation(
event_id=event_id,
inviter_user_id=user["user_id"],
invitee_email=body.invitee_email,
)
return {"success": True, "invitation": invitation.to_dict()}
@router.post("/invitations/{invitation_id}/respond")
async def respond_to_invitation(
invitation_id: str,
body: RespondInvitationRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Respond to an invitation (accept/decline/tentative)."""
if body.status not in _VALID_INVITATION_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {sorted(_VALID_INVITATION_STATUSES)}",
)
service = _get_calendar_service(request)
invitation = await service.get_invitation(invitation_id)
if invitation is None:
raise HTTPException(status_code=404, detail="Invitation not found")
email = await service.get_user_email(user["user_id"])
if email is None or invitation.invitee_email != email:
raise HTTPException(status_code=403, detail="Only the invitee can respond")
updated = await service.respond_to_invitation(invitation_id, body.status)
if not updated:
raise HTTPException(status_code=404, detail="Invitation not found")
return {"success": True, "status": body.status}
@router.get("/invitations")
async def list_invitations(
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""List invitations for the current user (by email)."""
service = _get_calendar_service(request)
email = await service.get_user_email(user["user_id"])
if email is None:
return {"success": True, "invitations": [], "count": 0}
invitations = await service.list_invitations(email)
return {
"success": True,
"invitations": [inv.to_dict() for inv in invitations],
"count": len(invitations),
}
# ---------------------------------------------------------------------------
# User search endpoint (G5/A3)
# ---------------------------------------------------------------------------
@router.get("/users/search")
async def search_users(
request: Request,
q: str = Query(..., min_length=1),
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Search users by username or email. Returns top 10 matches.
Only username and email are returned — never user_id or password
fields (least-privilege, G5/A3).
"""
service = _get_calendar_service(request)
users = await service.search_users(q)
return {"success": True, "users": users, "count": len(users)}
# ---------------------------------------------------------------------------
# Event Type endpoints
# ---------------------------------------------------------------------------
@router.get("/event-types")
async def list_event_types(
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""List all event types for the current user."""
service = _get_calendar_service(request)
types = await service.list_event_types(user["user_id"])
return {
"success": True,
"event_types": [t.to_dict() for t in types],
"count": len(types),
}
@router.post("/event-types")
async def create_event_type(
body: CreateEventTypeRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Create a new event type."""
service = _get_calendar_service(request)
et = await service.create_event_type(
user_id=user["user_id"],
name=body.name,
color=body.color,
)
return {"success": True, "event_type": et.to_dict()}
@router.patch("/event-types/{type_id}")
async def update_event_type(
type_id: str,
body: UpdateEventTypeRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Update specific fields of an event type."""
service = _get_calendar_service(request)
et = await service.get_event_type(type_id)
if et is None:
raise HTTPException(status_code=404, detail="Event type not found")
if et.user_id != user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
fields: dict[str, Any] = {}
for name, value in body.model_dump(exclude_unset=True).items():
if value is not None:
fields[name] = value
if not fields:
return {"success": True, "updated": False}
updated = await service.update_event_type(type_id, fields)
return {"success": True, "updated": updated}
# ---------------------------------------------------------------------------
# Tag endpoints
# ---------------------------------------------------------------------------
@router.get("/tags")
async def list_tags(
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""List all tags for the current user."""
service = _get_calendar_service(request)
tags = await service.list_tags(user["user_id"])
return {"success": True, "tags": [t.to_dict() for t in tags], "count": len(tags)}
@router.post("/tags")
async def create_tag(
body: CreateTagRequest,
request: Request,
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Create a new tag."""
service = _get_calendar_service(request)
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
return {"success": True, "tag": tag.to_dict()}
# ---------------------------------------------------------------------------
# ICS import/export (U8)
# ---------------------------------------------------------------------------
@router.post("/import-ics")
async def import_ics(
request: Request,
file: UploadFile = File(...),
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Import events from an uploaded .ics file."""
service = _get_calendar_service(request)
content = await file.read()
from agentkit.calendar.sync.ics_provider import ICSProvider # lazy: avoid circular import
provider = ICSProvider(service)
try:
result = await provider.import_ics(content, user["user_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.get("/export-ics")
async def export_ics(
request: Request,
start: str | None = Query(None),
end: str | None = Query(None),
user: dict = Depends(require_authenticated),
) -> Response:
"""Export the current user's events to a downloadable .ics file."""
service = _get_calendar_service(request)
events = await service.list_events(user_id=user["user_id"], start=start, end=end)
from agentkit.calendar.sync.ics_provider import ICSProvider # lazy: avoid circular import
provider = ICSProvider(service)
ics_bytes = provider.export_ics(events)
return Response(
content=ics_bytes,
media_type="text/calendar",
headers={"Content-Disposition": 'attachment; filename="calendar.ics"'},
)