"""Calendar events REST API.""" from __future__ import annotations from datetime import datetime, timezone from quart import Blueprint, g, jsonify, request from fabledassistant.auth import login_required import fabledassistant.services.events as events_svc events_bp = Blueprint("events", __name__, url_prefix="/api/events") def _parse_dt(value: str) -> datetime: """Parse ISO 8601 datetime string, ensuring UTC-awareness.""" dt = datetime.fromisoformat(value.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def _get_current_user_id() -> int: return g.user.id @events_bp.get("") @login_required async def list_events(): date_from_str = request.args.get("from") date_to_str = request.args.get("to") if not date_from_str or not date_to_str: return jsonify({"error": "from and to query params are required"}), 400 try: date_from = _parse_dt(date_from_str) date_to = _parse_dt(date_to_str) except ValueError: return jsonify({"error": "Invalid datetime format"}), 400 events = await events_svc.list_events( user_id=_get_current_user_id(), date_from=date_from, date_to=date_to, ) return jsonify(events) @events_bp.post("") @login_required async def create_event(): data = await request.get_json() or {} if not data.get("title") or not data.get("start_dt"): return jsonify({"error": "title and start_dt are required"}), 400 try: start_dt = _parse_dt(data["start_dt"]) end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None except ValueError: return jsonify({"error": "Invalid datetime format"}), 400 try: event = await events_svc.create_event( user_id=_get_current_user_id(), title=data["title"], start_dt=start_dt, end_dt=end_dt, duration_minutes=data.get("duration_minutes"), all_day=data.get("all_day", False), description=data.get("description", ""), location=data.get("location", ""), color=data.get("color", ""), recurrence=data.get("recurrence"), project_id=data.get("project_id"), reminder_minutes=data.get("reminder_minutes"), ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 return jsonify(event.to_dict()), 201 @events_bp.get("/") @login_required async def get_event(event_id: int): event = await events_svc.get_event( user_id=_get_current_user_id(), event_id=event_id, ) if event is None: return jsonify({"error": "Event not found"}), 404 return jsonify(event.to_dict()) @events_bp.patch("/") @login_required async def update_event(event_id: int): data = await request.get_json() or {} fields: dict = {} for str_field in ("title", "description", "location", "color", "recurrence"): if str_field in data: fields[str_field] = data[str_field] for bool_field in ("all_day",): if bool_field in data: fields[bool_field] = data[bool_field] for int_field in ("project_id", "reminder_minutes", "duration_minutes"): if int_field in data: fields[int_field] = data[int_field] for dt_field in ("start_dt", "end_dt"): if dt_field in data: if data[dt_field] is None: # Explicit null clears the field (e.g. removing end_dt) fields[dt_field] = None elif data[dt_field]: try: fields[dt_field] = _parse_dt(data[dt_field]) except ValueError: return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 try: event = await events_svc.update_event( user_id=_get_current_user_id(), event_id=event_id, **fields, ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 if event is None: return jsonify({"error": "Event not found"}), 404 return jsonify(event.to_dict()) @events_bp.delete("/") @login_required async def delete_event(event_id: int): event = await events_svc.get_event( user_id=_get_current_user_id(), event_id=event_id, ) if event is None: return jsonify({"error": "Event not found"}), 404 await events_svc.delete_event( user_id=_get_current_user_id(), event_id=event_id, ) return "", 204 @events_bp.post("/sync") @login_required async def sync_caldav(): """Trigger a CalDAV pull sync for the current user.""" from fabledassistant.services.caldav_sync import sync_user_events result = await sync_user_events(user_id=_get_current_user_id()) return jsonify(result)