455 lines
15 KiB
Python
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"'},
|
|
)
|