Upload 91 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- server/.DS_Store +0 -0
- server/Dockerfile +40 -0
- server/__init__.py +7 -0
- server/apis/__init__.py +0 -0
- server/apis/acl/router.py +236 -0
- server/apis/calendarList/__init__.py +0 -0
- server/apis/calendarList/router.py +802 -0
- server/apis/calendars/__init__.py +0 -0
- server/apis/calendars/router.py +271 -0
- server/apis/colors/__init__.py +1 -0
- server/apis/colors/data.py +219 -0
- server/apis/colors/router.py +46 -0
- server/apis/core_apis.py +19 -0
- server/apis/database_router.py +625 -0
- server/apis/events/router.py +893 -0
- server/apis/freebusy/__init__.py +7 -0
- server/apis/freebusy/router.py +80 -0
- server/apis/mcp/__init__.py +0 -0
- server/apis/mcp/router.py +31 -0
- server/apis/settings/router.py +116 -0
- server/apis/users/router.py +95 -0
- server/app.py +56 -0
- server/calendar_environment.py +23 -0
- server/calendar_mcp/__init__.py +3 -0
- server/calendar_mcp/tools/__init__.py +40 -0
- server/calendar_mcp/tools/acl.py +351 -0
- server/calendar_mcp/tools/calendar_list.py +704 -0
- server/calendar_mcp/tools/calendars.py +353 -0
- server/calendar_mcp/tools/colors.py +57 -0
- server/calendar_mcp/tools/events.py +0 -0
- server/calendar_mcp/tools/freebusy.py +158 -0
- server/calendar_mcp/tools/settings.py +161 -0
- server/calendar_mcp/tools/users.py +37 -0
- server/data/__init__.py +1 -0
- server/data/enhanced_event_seed_data.py +893 -0
- server/data/google_colors.py +46 -0
- server/data/multi_user_sample.py +532 -0
- server/data/watch_channel_seed_data.py +245 -0
- server/database/__init__.py +0 -0
- server/database/base_manager.py +112 -0
- server/database/managers/__init__.py +0 -0
- server/database/managers/acl_manager.py +595 -0
- server/database/managers/calendar_list_manager.py +626 -0
- server/database/managers/calendar_manager.py +445 -0
- server/database/managers/color_manager.py +224 -0
- server/database/managers/event_manager.py +0 -0
- server/database/managers/freebusy_manager.py +249 -0
- server/database/managers/settings_manager.py +134 -0
- server/database/managers/user_manager.py +281 -0
- server/database/models/__init__.py +27 -0
server/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
server/Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image as base
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
ENV API_PORT=8004
|
| 5 |
+
|
| 6 |
+
# Set working directory
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Set environment variables
|
| 10 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
PYTHONPATH=/app
|
| 13 |
+
|
| 14 |
+
# Install system dependencies
|
| 15 |
+
RUN apt-get update && apt-get install -y \
|
| 16 |
+
gcc \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Copy requirements first for better caching
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
|
| 22 |
+
# Install Python dependencies
|
| 23 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 24 |
+
pip install --no-cache-dir -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# Copy application code
|
| 27 |
+
COPY . .
|
| 28 |
+
|
| 29 |
+
# Create directory for database files
|
| 30 |
+
RUN mkdir -p /app/mcp_databases
|
| 31 |
+
|
| 32 |
+
# Expose port 8010
|
| 33 |
+
EXPOSE 8004
|
| 34 |
+
|
| 35 |
+
# Health check
|
| 36 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
| 37 |
+
CMD python -c "import requests; requests.get('http://localhost:8004/health')" || exit 1
|
| 38 |
+
|
| 39 |
+
# Run the application
|
| 40 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8004"]
|
server/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""Calendar server package."""
|
server/apis/__init__.py
ADDED
|
File without changes
|
server/apis/acl/router.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ACL (Access Control List) API endpoints following Google Calendar API v3 structure.
|
| 3 |
+
Handles CRUD operations for calendar access rules.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 9 |
+
from schemas.acl import ACLRule, ACLRuleInput, Channel, ACLWatchRequest, ACLListResponse, InsertACLRule, PatchACLRuleInput
|
| 10 |
+
from database.managers.acl_manager import ACLManager, get_acl_manager
|
| 11 |
+
from database.session_manager import CalendarSessionManager
|
| 12 |
+
from middleware.auth import get_user_context
|
| 13 |
+
from pydantic import ValidationError
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/calendars", tags=["acl"])
|
| 18 |
+
session_manager = CalendarSessionManager()
|
| 19 |
+
|
| 20 |
+
def get_acl_manager_instance(database_id: str, user_id: str) -> ACLManager:
|
| 21 |
+
session = session_manager.get_session(database_id)
|
| 22 |
+
return get_acl_manager(session, user_id)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/{calendarId}/acl", response_model=ACLListResponse, operation_id="list_acl_rules")
|
| 26 |
+
def list_acl_rules(
|
| 27 |
+
calendarId: str,
|
| 28 |
+
maxResults: Optional[int] = Query(100, ge=1, le=250, description="Maximum number of entries returned on one result page"),
|
| 29 |
+
pageToken: Optional[str] = Query(None, description="Token specifying which result page to return"),
|
| 30 |
+
showDeleted: bool = Query(False, description="Whether to include deleted ACLs in the result"),
|
| 31 |
+
syncToken: Optional[str] = Query(None, description="Token for incremental synchronization"),
|
| 32 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 33 |
+
):
|
| 34 |
+
try:
|
| 35 |
+
database_id, user_id = user_context
|
| 36 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 37 |
+
|
| 38 |
+
if not manager.validate_calendar_id(calendarId, user_id):
|
| 39 |
+
raise ValueError(f"Calendar {calendarId} not found for user {user_id}")
|
| 40 |
+
|
| 41 |
+
# If syncToken is provided, showDeleted must be True (Google Calendar API behavior)
|
| 42 |
+
if syncToken and not showDeleted:
|
| 43 |
+
showDeleted = True
|
| 44 |
+
|
| 45 |
+
result = manager.list_rules(
|
| 46 |
+
calendarId,
|
| 47 |
+
max_results=maxResults,
|
| 48 |
+
page_token=pageToken,
|
| 49 |
+
show_deleted=showDeleted,
|
| 50 |
+
sync_token=syncToken
|
| 51 |
+
)
|
| 52 |
+
return result
|
| 53 |
+
|
| 54 |
+
except ValueError as verr:
|
| 55 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{str(verr)}")
|
| 56 |
+
except HTTPException as he:
|
| 57 |
+
if he.status_code == 410: # Handle sync token expiration
|
| 58 |
+
raise he
|
| 59 |
+
raise
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"Error listing ACL rules: {e}")
|
| 62 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/{calendarId}/acl/{ruleId}", response_model=ACLRule, operation_id="get_acl_rule")
|
| 66 |
+
def get_acl_rule(
|
| 67 |
+
calendarId: str,
|
| 68 |
+
ruleId: str,
|
| 69 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 70 |
+
):
|
| 71 |
+
try:
|
| 72 |
+
database_id, user_id = user_context
|
| 73 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 74 |
+
rule = manager.get_rule(calendarId, ruleId)
|
| 75 |
+
if not rule:
|
| 76 |
+
raise HTTPException(status_code=404, detail="ACL rule not found")
|
| 77 |
+
return rule
|
| 78 |
+
except HTTPException:
|
| 79 |
+
raise
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error retrieving ACL rule {ruleId}: {e}")
|
| 82 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.post("/{calendarId}/acl", status_code=201, operation_id="insert_acl_rule")
|
| 86 |
+
def insert_acl_rule(
|
| 87 |
+
calendarId: str,
|
| 88 |
+
rule: ACLRuleInput,
|
| 89 |
+
sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"),
|
| 90 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 91 |
+
):
|
| 92 |
+
try:
|
| 93 |
+
database_id, user_id = user_context
|
| 94 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 95 |
+
|
| 96 |
+
if not manager.validate_calendar_id(calendarId, user_id):
|
| 97 |
+
raise ValueError(f"Calendar {calendarId} not found for user {user_id}")
|
| 98 |
+
|
| 99 |
+
return manager.insert_rule(calendarId, rule, send_notifications=sendNotifications)
|
| 100 |
+
except ValueError as ve:
|
| 101 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error inserting ACL rule: {e}")
|
| 104 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.put("/{calendarId}/acl/{ruleId}", operation_id="update_acl_rule")
|
| 108 |
+
def update_acl_rule(
|
| 109 |
+
calendarId: str,
|
| 110 |
+
ruleId: str,
|
| 111 |
+
rule: ACLRuleInput,
|
| 112 |
+
sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"),
|
| 113 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 114 |
+
):
|
| 115 |
+
try:
|
| 116 |
+
database_id, user_id = user_context
|
| 117 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 118 |
+
|
| 119 |
+
if not manager.validate_calendar_id(calendarId, user_id):
|
| 120 |
+
raise ValueError(f"Calendar {calendarId} not found for user {user_id}")
|
| 121 |
+
|
| 122 |
+
updated = manager.update_rule(calendarId, ruleId, rule, send_notifications=sendNotifications)
|
| 123 |
+
if not updated:
|
| 124 |
+
raise ValueError(f"ACL rule not found")
|
| 125 |
+
return updated
|
| 126 |
+
except ValueError as ve:
|
| 127 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error updating ACL rule {ruleId}: {e}")
|
| 130 |
+
raise HTTPException(status_code=500, detail=f"An error occurred {e}")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.patch("/{calendarId}/acl/{ruleId}", operation_id="patch_acl_rule")
|
| 134 |
+
def patch_acl_rule(
|
| 135 |
+
calendarId: str,
|
| 136 |
+
ruleId: str,
|
| 137 |
+
rule: PatchACLRuleInput,
|
| 138 |
+
sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"),
|
| 139 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 140 |
+
):
|
| 141 |
+
try:
|
| 142 |
+
database_id, user_id = user_context
|
| 143 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 144 |
+
updated = manager.patch_rule(calendarId, ruleId, rule, sendNotifications)
|
| 145 |
+
if not updated:
|
| 146 |
+
raise ValueError(f"ACL rule not found")
|
| 147 |
+
return updated
|
| 148 |
+
except ValueError as ve:
|
| 149 |
+
raise HTTPException(status_code=400, detail=str(ve))
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"Error patching ACL rule {ruleId}: {e}")
|
| 152 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.delete("/{calendarId}/acl/{ruleId}", status_code=status.HTTP_204_NO_CONTENT)
|
| 156 |
+
def delete_acl_rule(
|
| 157 |
+
calendarId: str,
|
| 158 |
+
ruleId: str,
|
| 159 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 160 |
+
):
|
| 161 |
+
"""
|
| 162 |
+
Deletes an ACL rule from the specified calendar.
|
| 163 |
+
|
| 164 |
+
DELETE /calendars/{calendarId}/acl/{ruleId}
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
database_id, user_id = user_context
|
| 168 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 169 |
+
|
| 170 |
+
success = manager.delete_rule(calendarId, ruleId)
|
| 171 |
+
if not success:
|
| 172 |
+
raise HTTPException(
|
| 173 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 174 |
+
detail=f"ACL rule '{ruleId}' not found for calendar '{calendarId}'"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
return None # 204 No Content
|
| 178 |
+
|
| 179 |
+
except HTTPException:
|
| 180 |
+
raise
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"Error deleting ACL rule {ruleId} from calendar {calendarId}: {e}")
|
| 183 |
+
raise HTTPException(status_code=500, detail="Internal server error")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.post("/{calendarId}/acl/watch", response_model=Channel, operation_id="watch_acl")
|
| 187 |
+
def watch_acl(
|
| 188 |
+
calendarId: str,
|
| 189 |
+
watch_request: ACLWatchRequest,
|
| 190 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 191 |
+
):
|
| 192 |
+
"""
|
| 193 |
+
Set up watch notifications for ACL changes on the specified calendar.
|
| 194 |
+
|
| 195 |
+
POST /calendars/{calendarId}/acl/watch
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
database_id, user_id = user_context
|
| 199 |
+
manager = get_acl_manager_instance(database_id, user_id)
|
| 200 |
+
|
| 201 |
+
# Log the received request for debugging
|
| 202 |
+
logger.info(f"Received watch request for calendar {calendarId}")
|
| 203 |
+
logger.debug(f"Watch request data: {watch_request}")
|
| 204 |
+
|
| 205 |
+
# Validate user exists in this database (ensures ownership context)
|
| 206 |
+
from database.session_utils import get_session
|
| 207 |
+
from database.models.user import User
|
| 208 |
+
session = get_session(database_id)
|
| 209 |
+
try:
|
| 210 |
+
user_row = session.query(User).filter(User.user_id == user_id).first()
|
| 211 |
+
if not user_row:
|
| 212 |
+
raise HTTPException(status_code=404, detail=f"User not found: {user_id}")
|
| 213 |
+
finally:
|
| 214 |
+
session.close()
|
| 215 |
+
|
| 216 |
+
# Set up watch channel
|
| 217 |
+
channel = manager.watch_acl(user_id, calendarId, watch_request)
|
| 218 |
+
|
| 219 |
+
return channel
|
| 220 |
+
|
| 221 |
+
except ValidationError as e:
|
| 222 |
+
logger.error(f"Validation error for calendar {calendarId}: {e.errors()}")
|
| 223 |
+
validation_errors = []
|
| 224 |
+
for error in e.errors():
|
| 225 |
+
field = error.get('loc', ['unknown'])[0] if error.get('loc') else 'unknown'
|
| 226 |
+
message = error.get('msg', 'validation failed')
|
| 227 |
+
validation_errors.append(f"{field}: {message}")
|
| 228 |
+
raise HTTPException(
|
| 229 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 230 |
+
detail=f"Validation errors: - {' - '.join(validation_errors)}"
|
| 231 |
+
)
|
| 232 |
+
except HTTPException:
|
| 233 |
+
raise
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.error(f"Error setting up ACL watch for calendar {calendarId}: {e}")
|
| 236 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
server/apis/calendarList/__init__.py
ADDED
|
File without changes
|
server/apis/calendarList/router.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CalendarList API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Handles user's calendar list operations with exact Google API compatibility
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
import uuid
|
| 10 |
+
from urllib.parse import urlencode, urlparse
|
| 11 |
+
from datetime import datetime, timezone, timedelta
|
| 12 |
+
from fastapi import APIRouter, HTTPException, Header, Query, status, Depends
|
| 13 |
+
from schemas.calendar_list import (
|
| 14 |
+
CalendarListEntry,
|
| 15 |
+
CalendarListInsertRequest,
|
| 16 |
+
CalendarListUpdateRequest,
|
| 17 |
+
CalendarListResponse,
|
| 18 |
+
Channel,
|
| 19 |
+
WatchRequest,
|
| 20 |
+
CalendarListPatchRequest
|
| 21 |
+
)
|
| 22 |
+
from schemas.common import SuccessResponse
|
| 23 |
+
from database.managers.calendar_list_manager import CalendarListManager
|
| 24 |
+
from database.session_manager import CalendarSessionManager
|
| 25 |
+
from utils.validation import validate_request_colors, validate_and_set_color_id, set_colors_from_color_id
|
| 26 |
+
from middleware.auth import get_user_context
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
router = APIRouter(prefix="/users/me/calendarList", tags=["calendarList"])
|
| 31 |
+
|
| 32 |
+
# Initialize managers
|
| 33 |
+
session_manager = CalendarSessionManager()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_calendar_list_manager(database_id: str) -> CalendarListManager:
|
| 37 |
+
"""Get calendar list manager for the specified database"""
|
| 38 |
+
return CalendarListManager(database_id)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("", response_model=CalendarListResponse)
|
| 42 |
+
async def get_calendar_list(
|
| 43 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 44 |
+
maxResults: Optional[int] = Query(
|
| 45 |
+
None,
|
| 46 |
+
gt=0,
|
| 47 |
+
le=250,
|
| 48 |
+
description="Maximum number of entries returned (0-250). If 0, returns no items."
|
| 49 |
+
),
|
| 50 |
+
minAccessRole: Optional[str] = Query(
|
| 51 |
+
None,
|
| 52 |
+
description="Minimum access role filter",
|
| 53 |
+
regex="^(freeBusyReader|reader|writer|owner)$"
|
| 54 |
+
),
|
| 55 |
+
pageToken: Optional[str] = Query(None, description="Token for pagination"),
|
| 56 |
+
showDeleted: Optional[bool] = Query(False, description="Include deleted calendars"),
|
| 57 |
+
showHidden: Optional[bool] = Query(False, description="Include hidden calendars"),
|
| 58 |
+
syncToken: Optional[str] = Query(None, description="Token for incremental sync")
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
Returns the calendars on the user's calendar list
|
| 62 |
+
|
| 63 |
+
GET /users/me/calendarList
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
database_id, user_id = user_context
|
| 67 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 68 |
+
|
| 69 |
+
# Validate minAccessRole parameter
|
| 70 |
+
if minAccessRole and minAccessRole not in ["freeBusyReader", "reader", "writer", "owner"]:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 73 |
+
detail=f"Invalid minAccessRole: {minAccessRole}. Must be one of: freeBusyReader, reader, writer, owner"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Handle syncToken constraints
|
| 77 |
+
if syncToken:
|
| 78 |
+
# syncToken cannot be used with minAccessRole
|
| 79 |
+
if minAccessRole:
|
| 80 |
+
raise ValueError(f"minAccessRole query parameter cannot be specified together with syncToken")
|
| 81 |
+
|
| 82 |
+
# When using syncToken, deleted and hidden entries must be included
|
| 83 |
+
showDeleted = True
|
| 84 |
+
showHidden = True
|
| 85 |
+
|
| 86 |
+
# Get calendar entries with pagination and/or sync
|
| 87 |
+
try:
|
| 88 |
+
entries, next_page_token, next_sync_token = calendar_list_manager.list_calendar_entries(
|
| 89 |
+
user_id=user_id,
|
| 90 |
+
max_results=maxResults,
|
| 91 |
+
min_access_role=minAccessRole,
|
| 92 |
+
show_deleted=showDeleted,
|
| 93 |
+
show_hidden=showHidden,
|
| 94 |
+
page_token=pageToken,
|
| 95 |
+
sync_token=syncToken
|
| 96 |
+
)
|
| 97 |
+
except ValueError as e:
|
| 98 |
+
# Handle expired syncToken
|
| 99 |
+
if "expired" in str(e).lower() or "invalid sync token" in str(e).lower():
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_410_GONE,
|
| 102 |
+
detail="Sync token has expired. Client should clear storage and perform full synchronization."
|
| 103 |
+
)
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 106 |
+
detail=str(e)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
return CalendarListResponse(
|
| 110 |
+
kind="calendar#calendarList",
|
| 111 |
+
etag=f"etag-list-{database_id}",
|
| 112 |
+
items=entries,
|
| 113 |
+
nextPageToken=next_page_token,
|
| 114 |
+
nextSyncToken=next_sync_token
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
except ValueError as verr:
|
| 118 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}")
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Error listing calendar list: {e}")
|
| 121 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.get("/{calendarId}", response_model=CalendarListEntry)
|
| 125 |
+
async def get_calendar_from_list(
|
| 126 |
+
calendarId: str,
|
| 127 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 128 |
+
):
|
| 129 |
+
"""
|
| 130 |
+
Returns a calendar from the user's calendar list
|
| 131 |
+
|
| 132 |
+
GET /users/me/calendarList/{calendarId}
|
| 133 |
+
"""
|
| 134 |
+
try:
|
| 135 |
+
database_id, user_id = user_context
|
| 136 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 137 |
+
|
| 138 |
+
# Support keyword 'primary' to fetch the user's primary calendar list entry
|
| 139 |
+
if isinstance(calendarId, str) and calendarId.lower() == "primary":
|
| 140 |
+
entries, _, _ = calendar_list_manager.list_calendar_entries(
|
| 141 |
+
user_id=user_id,
|
| 142 |
+
show_hidden=True
|
| 143 |
+
)
|
| 144 |
+
entry = next((e for e in entries if e.get("primary") is True), None)
|
| 145 |
+
if entry:
|
| 146 |
+
# Check ACL permissions for primary calendar
|
| 147 |
+
try:
|
| 148 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, entry["id"], ["reader", "writer", "owner"])
|
| 149 |
+
except PermissionError:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 152 |
+
detail=f"User '{user_id}' lacks permission on calendar '{entry['id']}'"
|
| 153 |
+
)
|
| 154 |
+
else:
|
| 155 |
+
# Check ACL permissions before getting calendar entry
|
| 156 |
+
try:
|
| 157 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["reader", "writer", "owner"])
|
| 158 |
+
except PermissionError:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 161 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'"
|
| 162 |
+
)
|
| 163 |
+
entry = calendar_list_manager.get_calendar_entry(user_id, calendarId)
|
| 164 |
+
|
| 165 |
+
if not entry:
|
| 166 |
+
raise HTTPException(
|
| 167 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 168 |
+
detail=f"Calendar not found: {calendarId}"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
return entry
|
| 172 |
+
|
| 173 |
+
except HTTPException:
|
| 174 |
+
raise
|
| 175 |
+
except PermissionError as e:
|
| 176 |
+
raise HTTPException(
|
| 177 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 178 |
+
detail=f"Access denied: {str(e)}"
|
| 179 |
+
)
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"Error getting calendar list entry {calendarId}: {e}")
|
| 182 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@router.post("", response_model=CalendarListEntry, status_code=status.HTTP_201_CREATED)
|
| 186 |
+
async def add_calendar_to_list(
|
| 187 |
+
calendar_request: CalendarListInsertRequest,
|
| 188 |
+
colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"),
|
| 189 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 190 |
+
):
|
| 191 |
+
"""
|
| 192 |
+
Inserts an existing calendar into the user's calendar list
|
| 193 |
+
|
| 194 |
+
POST /users/me/calendarList
|
| 195 |
+
"""
|
| 196 |
+
try:
|
| 197 |
+
database_id, user_id = user_context
|
| 198 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 199 |
+
|
| 200 |
+
# Convert request to dict
|
| 201 |
+
entry_data = calendar_request.model_dump(exclude_none=True)
|
| 202 |
+
|
| 203 |
+
# Validate colorId if provided (calendar list uses calendar colors)
|
| 204 |
+
color_error = validate_request_colors(entry_data, "calendar", database_id)
|
| 205 |
+
if color_error:
|
| 206 |
+
raise ValueError(color_error)
|
| 207 |
+
|
| 208 |
+
# Set backgroundColor and foregroundColor from colorId if they are not provided
|
| 209 |
+
color_set_error = set_colors_from_color_id(entry_data, "calendar")
|
| 210 |
+
if color_set_error:
|
| 211 |
+
raise ValueError(color_set_error)
|
| 212 |
+
|
| 213 |
+
# Normalize empty strings: drop to avoid triggering validation
|
| 214 |
+
for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]:
|
| 215 |
+
if key in entry_data and isinstance(entry_data[key], str) and entry_data[key].strip() == "":
|
| 216 |
+
entry_data.pop(key)
|
| 217 |
+
|
| 218 |
+
# Enforce colorRgbFormat rules for RGB color fields
|
| 219 |
+
has_rgb_value = any(
|
| 220 |
+
(k in entry_data and isinstance(entry_data[k], str) and entry_data[k].strip() != "")
|
| 221 |
+
for k in ["backgroundColor", "foregroundColor"]
|
| 222 |
+
)
|
| 223 |
+
if has_rgb_value:
|
| 224 |
+
if not colorRgbFormat:
|
| 225 |
+
raise HTTPException(
|
| 226 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 227 |
+
detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true"
|
| 228 |
+
)
|
| 229 |
+
# Validate hex color pattern #RRGGBB
|
| 230 |
+
for key in ["backgroundColor", "foregroundColor"]:
|
| 231 |
+
if key in entry_data and isinstance(entry_data[key], str) and entry_data[key].strip() != "":
|
| 232 |
+
val = entry_data[key]
|
| 233 |
+
if not re.match(r"^#[0-9A-Fa-f]{6}$", val):
|
| 234 |
+
raise HTTPException(
|
| 235 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 236 |
+
detail=f"Invalid {key}: must be a hex color like #AABBCC"
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Validate color combination and set colorId if both colors are provided
|
| 240 |
+
if ("backgroundColor" in entry_data and entry_data["backgroundColor"] and
|
| 241 |
+
"foregroundColor" in entry_data and entry_data["foregroundColor"]):
|
| 242 |
+
combination_error = validate_and_set_color_id(entry_data, "calendar")
|
| 243 |
+
if combination_error:
|
| 244 |
+
raise HTTPException(
|
| 245 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 246 |
+
detail=combination_error
|
| 247 |
+
)
|
| 248 |
+
# When RGB colors are provided but no colorId was found/set, remove any existing colorId
|
| 249 |
+
elif "colorId" in entry_data:
|
| 250 |
+
entry_data.pop("colorId")
|
| 251 |
+
calendar_id = entry_data.pop("id")
|
| 252 |
+
|
| 253 |
+
# Check ACL permissions before adding calendar to list
|
| 254 |
+
try:
|
| 255 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, calendar_id, ["reader", "writer", "owner"])
|
| 256 |
+
except PermissionError:
|
| 257 |
+
raise HTTPException(
|
| 258 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 259 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendar_id}'"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# Enforce Google spec: required fields when ADDING reminders/notifications
|
| 264 |
+
allowed_reminder_methods = {"email", "popup"}
|
| 265 |
+
allowed_notification_types = {
|
| 266 |
+
"eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda"
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
# defaultReminders: each item must include method (enum) and minutes (>=0)
|
| 270 |
+
if "defaultReminders" in entry_data and entry_data["defaultReminders"] is not None:
|
| 271 |
+
dr = entry_data["defaultReminders"]
|
| 272 |
+
if not isinstance(dr, list):
|
| 273 |
+
raise HTTPException(status_code=400, detail="defaultReminders must be a list")
|
| 274 |
+
for idx, item in enumerate(dr):
|
| 275 |
+
if not isinstance(item, dict):
|
| 276 |
+
raise HTTPException(status_code=400, detail=f"defaultReminders[{idx}] must be an object")
|
| 277 |
+
method = (item.get("method") or "").strip()
|
| 278 |
+
minutes = item.get("minutes")
|
| 279 |
+
if method == "" or method not in allowed_reminder_methods:
|
| 280 |
+
raise HTTPException(status_code=400, detail="defaultReminders[].method is required when adding and must be 'email' or 'popup'")
|
| 281 |
+
if not isinstance(minutes, int) or minutes < 0:
|
| 282 |
+
raise HTTPException(status_code=400, detail="defaultReminders[].minutes is required when adding and must be >= 0")
|
| 283 |
+
|
| 284 |
+
# notificationSettings.notifications: each item must include method ('email') and type (enum)
|
| 285 |
+
if "notificationSettings" in entry_data and entry_data["notificationSettings"] is not None:
|
| 286 |
+
ns = entry_data["notificationSettings"]
|
| 287 |
+
if not isinstance(ns, dict):
|
| 288 |
+
raise HTTPException(status_code=400, detail="notificationSettings must be an object")
|
| 289 |
+
notifs = ns.get("notifications")
|
| 290 |
+
if notifs is not None:
|
| 291 |
+
if not isinstance(notifs, list):
|
| 292 |
+
raise HTTPException(status_code=400, detail="notificationSettings.notifications must be a list")
|
| 293 |
+
for idx, n in enumerate(notifs):
|
| 294 |
+
if not isinstance(n, dict):
|
| 295 |
+
raise HTTPException(status_code=400, detail=f"notificationSettings.notifications[{idx}] must be an object")
|
| 296 |
+
method = (n.get("method") or "").strip()
|
| 297 |
+
ntype = (n.get("type") or "").strip()
|
| 298 |
+
if method == "" or method != "email":
|
| 299 |
+
raise HTTPException(status_code=400, detail="notificationSettings.notifications[].method is required when adding and must be 'email'")
|
| 300 |
+
if ntype == "" or ntype not in allowed_notification_types:
|
| 301 |
+
raise HTTPException(status_code=400, detail="notificationSettings.notifications[].type is required when adding and must be a valid type")
|
| 302 |
+
|
| 303 |
+
# Insert calendar into list
|
| 304 |
+
entry = calendar_list_manager.insert_calendar_entry(user_id, calendar_id, entry_data)
|
| 305 |
+
|
| 306 |
+
if not entry:
|
| 307 |
+
raise HTTPException(
|
| 308 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 309 |
+
detail=f"Calendar not found: {calendar_id}"
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
return entry
|
| 313 |
+
|
| 314 |
+
except ValueError as verr:
|
| 315 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}")
|
| 316 |
+
except HTTPException:
|
| 317 |
+
raise
|
| 318 |
+
except PermissionError as e:
|
| 319 |
+
raise HTTPException(
|
| 320 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 321 |
+
detail=f"Access denied: {str(e)}"
|
| 322 |
+
)
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.error(f"Error inserting calendar list entry: {e}")
|
| 325 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@router.patch("/{calendarId}", response_model=CalendarListEntry)
|
| 329 |
+
async def update_calendar_in_list(
|
| 330 |
+
calendarId: str,
|
| 331 |
+
calendar_request: CalendarListUpdateRequest,
|
| 332 |
+
colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"),
|
| 333 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 334 |
+
):
|
| 335 |
+
"""
|
| 336 |
+
Updates an entry on the user's calendar list (partial update)
|
| 337 |
+
|
| 338 |
+
PATCH /users/me/calendarList/{calendarId}
|
| 339 |
+
"""
|
| 340 |
+
try:
|
| 341 |
+
database_id, user_id = user_context
|
| 342 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 343 |
+
|
| 344 |
+
# Support keyword 'primary' for calendarId (resolve to actual primary calendar ID)
|
| 345 |
+
if isinstance(calendarId, str) and calendarId.lower() == "primary":
|
| 346 |
+
entries, _, _ = calendar_list_manager.list_calendar_entries(
|
| 347 |
+
user_id=user_id,
|
| 348 |
+
show_hidden=True
|
| 349 |
+
)
|
| 350 |
+
primary_entry = next((e for e in entries if e.get("primary") is True), None)
|
| 351 |
+
if not primary_entry:
|
| 352 |
+
raise HTTPException(
|
| 353 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 354 |
+
detail="Primary calendar not found"
|
| 355 |
+
)
|
| 356 |
+
calendarId = primary_entry["id"]
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# Check ACL permissions before updating calendar list entry
|
| 360 |
+
try:
|
| 361 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["writer", "owner"])
|
| 362 |
+
except PermissionError:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 365 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Convert request to dict for PATCH
|
| 369 |
+
# Use exclude_unset=True so explicitly provided nulls are preserved (to allow clearing fields)
|
| 370 |
+
update_data = calendar_request.model_dump(exclude_unset=True)
|
| 371 |
+
|
| 372 |
+
if not update_data:
|
| 373 |
+
raise HTTPException(
|
| 374 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 375 |
+
detail="No fields provided for update"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# Validate colorId if provided (calendar list uses calendar colors)
|
| 379 |
+
color_error = validate_request_colors(update_data, "calendar", database_id)
|
| 380 |
+
if color_error:
|
| 381 |
+
raise ValueError(color_error)
|
| 382 |
+
|
| 383 |
+
# Set backgroundColor and foregroundColor from colorId if they are not provided
|
| 384 |
+
color_set_error = set_colors_from_color_id(update_data, "calendar")
|
| 385 |
+
if color_set_error:
|
| 386 |
+
raise ValueError(color_set_error)
|
| 387 |
+
|
| 388 |
+
# Reject null values for boolean fields in PATCH
|
| 389 |
+
if "hidden" in update_data and update_data["hidden"] is None:
|
| 390 |
+
raise HTTPException(
|
| 391 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 392 |
+
detail="'hidden' cannot be null in PATCH"
|
| 393 |
+
)
|
| 394 |
+
if "selected" in update_data and update_data["selected"] is None:
|
| 395 |
+
raise HTTPException(
|
| 396 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 397 |
+
detail="'selected' cannot be null in PATCH"
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
# Normalize empty strings for PATCH: treat as omitted so they don't trigger validation
|
| 401 |
+
for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]:
|
| 402 |
+
if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() == "":
|
| 403 |
+
update_data.pop(key)
|
| 404 |
+
|
| 405 |
+
# Enforce colorRgbFormat rules for RGB color fields
|
| 406 |
+
has_rgb_value = any(
|
| 407 |
+
(k in update_data and isinstance(update_data[k], str) and update_data[k].strip() != "")
|
| 408 |
+
for k in ["backgroundColor", "foregroundColor"]
|
| 409 |
+
)
|
| 410 |
+
if has_rgb_value:
|
| 411 |
+
if not colorRgbFormat:
|
| 412 |
+
raise HTTPException(
|
| 413 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 414 |
+
detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true"
|
| 415 |
+
)
|
| 416 |
+
# Validate hex color pattern #RRGGBB
|
| 417 |
+
for key in ["backgroundColor", "foregroundColor"]:
|
| 418 |
+
if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() != "":
|
| 419 |
+
val = update_data[key]
|
| 420 |
+
if not re.match(r"^#[0-9A-Fa-f]{6}$", val):
|
| 421 |
+
raise HTTPException(
|
| 422 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 423 |
+
detail=f"Invalid {key}: must be a hex color like #AABBCC"
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Validate color combination and set colorId if both colors are provided
|
| 427 |
+
if ("backgroundColor" in update_data and update_data["backgroundColor"] and
|
| 428 |
+
"foregroundColor" in update_data and update_data["foregroundColor"]):
|
| 429 |
+
combination_error = validate_and_set_color_id(update_data, "calendar")
|
| 430 |
+
if combination_error:
|
| 431 |
+
raise HTTPException(
|
| 432 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 433 |
+
detail=combination_error
|
| 434 |
+
)
|
| 435 |
+
# When RGB colors are provided but no colorId was found/set, remove any existing colorId
|
| 436 |
+
elif "colorId" in update_data:
|
| 437 |
+
update_data.pop("colorId")
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# Set backgroundColor and foregroundColor from colorId if they are not provided
|
| 441 |
+
color_set_error = set_colors_from_color_id(update_data, "calendar")
|
| 442 |
+
if color_set_error:
|
| 443 |
+
raise HTTPException(
|
| 444 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 445 |
+
detail=color_set_error
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
# Coupling rule: only enforce selected=false when hidden=true.
|
| 449 |
+
# When hidden=false, do not override selected; honor client's value if provided.
|
| 450 |
+
if "hidden" in update_data and update_data["hidden"] is True:
|
| 451 |
+
update_data["selected"] = False
|
| 452 |
+
if "hidden" in update_data and update_data["hidden"] is False and "selected" not in update_data:
|
| 453 |
+
update_data["selected"] = True
|
| 454 |
+
|
| 455 |
+
# Sanitize nested lists for PATCH: drop invalid/empty reminder/notification items; clear when empty arrays provided
|
| 456 |
+
if "defaultReminders" in update_data:
|
| 457 |
+
dr = update_data["defaultReminders"]
|
| 458 |
+
if isinstance(dr, list):
|
| 459 |
+
cleaned = []
|
| 460 |
+
for item in dr:
|
| 461 |
+
if not isinstance(item, dict):
|
| 462 |
+
continue
|
| 463 |
+
method = (item.get("method") or "").strip()
|
| 464 |
+
minutes = item.get("minutes")
|
| 465 |
+
if method == "":
|
| 466 |
+
# treat as cleared; skip item
|
| 467 |
+
continue
|
| 468 |
+
if method in {"email", "popup"} and isinstance(minutes, int) and minutes >= 0:
|
| 469 |
+
cleaned.append({"method": method, "minutes": minutes})
|
| 470 |
+
update_data["defaultReminders"] = cleaned if cleaned else None
|
| 471 |
+
elif dr in (None, ""):
|
| 472 |
+
update_data["defaultReminders"] = None
|
| 473 |
+
|
| 474 |
+
if "notificationSettings" in update_data:
|
| 475 |
+
ns = update_data["notificationSettings"]
|
| 476 |
+
if isinstance(ns, dict):
|
| 477 |
+
notifs = ns.get("notifications")
|
| 478 |
+
if isinstance(notifs, list):
|
| 479 |
+
cleaned_n = []
|
| 480 |
+
for n in notifs:
|
| 481 |
+
if not isinstance(n, dict):
|
| 482 |
+
continue
|
| 483 |
+
method = (n.get("method") or "").strip()
|
| 484 |
+
ntype = (n.get("type") or "").strip()
|
| 485 |
+
if method == "" and ntype == "":
|
| 486 |
+
# cleared
|
| 487 |
+
continue
|
| 488 |
+
if method == "email" and ntype in {"eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda"}:
|
| 489 |
+
cleaned_n.append({"method": method, "type": ntype})
|
| 490 |
+
update_data["notificationSettings"] = {"notifications": cleaned_n} if cleaned_n else None
|
| 491 |
+
elif notifs in (None, ""):
|
| 492 |
+
update_data["notificationSettings"] = None
|
| 493 |
+
elif ns in (None, ""):
|
| 494 |
+
update_data["notificationSettings"] = None
|
| 495 |
+
|
| 496 |
+
# Update calendar entry
|
| 497 |
+
entry = calendar_list_manager.update_calendar_entry(user_id, calendarId, update_data, is_patch=True)
|
| 498 |
+
|
| 499 |
+
if not entry:
|
| 500 |
+
raise HTTPException(
|
| 501 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 502 |
+
detail=f"Calendar not found: {calendarId}"
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
return entry
|
| 506 |
+
|
| 507 |
+
except ValueError as verr:
|
| 508 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}")
|
| 509 |
+
except HTTPException:
|
| 510 |
+
raise
|
| 511 |
+
except PermissionError as e:
|
| 512 |
+
raise HTTPException(
|
| 513 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 514 |
+
detail=f"Access denied: {str(e)}"
|
| 515 |
+
)
|
| 516 |
+
except Exception as e:
|
| 517 |
+
logger.error(f"Error updating calendar list entry {calendarId}: {e}")
|
| 518 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
@router.put("/{calendarId}", response_model=CalendarListEntry)
|
| 522 |
+
async def replace_calendar_in_list(
|
| 523 |
+
calendarId: str,
|
| 524 |
+
calendar_request: CalendarListPatchRequest,
|
| 525 |
+
colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"),
|
| 526 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 527 |
+
):
|
| 528 |
+
"""
|
| 529 |
+
Updates an entry on the user's calendar list (full update)
|
| 530 |
+
|
| 531 |
+
PUT /users/me/calendarList/{calendarId}
|
| 532 |
+
"""
|
| 533 |
+
try:
|
| 534 |
+
database_id, user_id = user_context
|
| 535 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 536 |
+
|
| 537 |
+
# Support keyword 'primary' for calendarId (resolve to actual primary calendar ID)
|
| 538 |
+
if isinstance(calendarId, str) and calendarId.lower() == "primary":
|
| 539 |
+
entries, _, _ = calendar_list_manager.list_calendar_entries(
|
| 540 |
+
user_id=user_id,
|
| 541 |
+
show_hidden=True
|
| 542 |
+
)
|
| 543 |
+
primary_entry = next((e for e in entries if e.get("primary") is True), None)
|
| 544 |
+
if not primary_entry:
|
| 545 |
+
raise HTTPException(
|
| 546 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 547 |
+
detail="Primary calendar not found"
|
| 548 |
+
)
|
| 549 |
+
calendarId = primary_entry["id"]
|
| 550 |
+
|
| 551 |
+
# Check ACL permissions before updating calendar list entry
|
| 552 |
+
try:
|
| 553 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["writer", "owner"])
|
| 554 |
+
except PermissionError:
|
| 555 |
+
raise HTTPException(
|
| 556 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 557 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'"
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
# Convert request to dict, including None values for full update
|
| 561 |
+
update_data = calendar_request.model_dump()
|
| 562 |
+
|
| 563 |
+
# Validate colorId if provided (calendar list uses calendar colors)
|
| 564 |
+
color_error = validate_request_colors(update_data, "calendar", database_id)
|
| 565 |
+
if color_error:
|
| 566 |
+
raise HTTPException(
|
| 567 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 568 |
+
detail=color_error
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
# Set backgroundColor and foregroundColor from colorId if they are not provided
|
| 572 |
+
color_set_error = set_colors_from_color_id(update_data, "calendar")
|
| 573 |
+
if color_set_error:
|
| 574 |
+
raise HTTPException(
|
| 575 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 576 |
+
detail=color_set_error
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
# Ensure NOT NULL fields have defaults for PUT requests
|
| 580 |
+
if "hidden" not in update_data or update_data["hidden"] is None:
|
| 581 |
+
update_data["hidden"] = False
|
| 582 |
+
if "selected" not in update_data or update_data["selected"] is None:
|
| 583 |
+
update_data["selected"] = True
|
| 584 |
+
|
| 585 |
+
# Normalize empty strings to None for PUT (clear fields)
|
| 586 |
+
for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]:
|
| 587 |
+
if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() == "":
|
| 588 |
+
update_data[key] = None
|
| 589 |
+
|
| 590 |
+
# Enforce colorRgbFormat rules for RGB color fields
|
| 591 |
+
has_rgb_value = any(
|
| 592 |
+
(k in update_data and isinstance(update_data[k], str) and update_data[k].strip() != "")
|
| 593 |
+
for k in ["backgroundColor", "foregroundColor"]
|
| 594 |
+
)
|
| 595 |
+
if has_rgb_value:
|
| 596 |
+
if not colorRgbFormat:
|
| 597 |
+
raise HTTPException(
|
| 598 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 599 |
+
detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true"
|
| 600 |
+
)
|
| 601 |
+
# Validate hex color pattern #RRGGBB
|
| 602 |
+
for key in ["backgroundColor", "foregroundColor"]:
|
| 603 |
+
if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() != "":
|
| 604 |
+
val = update_data[key]
|
| 605 |
+
if not re.match(r"^#[0-9A-Fa-f]{6}$", val):
|
| 606 |
+
raise HTTPException(
|
| 607 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 608 |
+
detail=f"Invalid {key}: must be a hex color like #AABBCC"
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
# Validate color combination and set colorId if both colors are provided
|
| 612 |
+
if ("backgroundColor" in update_data and update_data["backgroundColor"] and
|
| 613 |
+
"foregroundColor" in update_data and update_data["foregroundColor"]):
|
| 614 |
+
combination_error = validate_and_set_color_id(update_data, "calendar")
|
| 615 |
+
if combination_error:
|
| 616 |
+
raise HTTPException(
|
| 617 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 618 |
+
detail=combination_error
|
| 619 |
+
)
|
| 620 |
+
# When RGB colors are provided but no colorId was found/set, remove any existing colorId
|
| 621 |
+
elif "colorId" in update_data:
|
| 622 |
+
update_data.pop("colorId")
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
if "hidden" in update_data and update_data["hidden"] is True:
|
| 626 |
+
update_data["selected"] = False
|
| 627 |
+
if "hidden" in update_data and update_data["hidden"] is False and "selected" not in update_data:
|
| 628 |
+
update_data["selected"] = True
|
| 629 |
+
|
| 630 |
+
# Sanitize nested lists for PUT: drop invalid/empty items; empty array clears
|
| 631 |
+
if "defaultReminders" in update_data:
|
| 632 |
+
dr = update_data["defaultReminders"]
|
| 633 |
+
if isinstance(dr, list):
|
| 634 |
+
cleaned = []
|
| 635 |
+
for item in dr:
|
| 636 |
+
if not isinstance(item, dict):
|
| 637 |
+
continue
|
| 638 |
+
method = (item.get("method") or "").strip()
|
| 639 |
+
minutes = item.get("minutes")
|
| 640 |
+
if method == "":
|
| 641 |
+
continue
|
| 642 |
+
if method in {"email", "popup"} and isinstance(minutes, int) and minutes >= 0:
|
| 643 |
+
cleaned.append({"method": method, "minutes": minutes})
|
| 644 |
+
update_data["defaultReminders"] = cleaned if cleaned else None
|
| 645 |
+
elif dr in (None, ""):
|
| 646 |
+
update_data["defaultReminders"] = None
|
| 647 |
+
|
| 648 |
+
if "notificationSettings" in update_data:
|
| 649 |
+
ns = update_data["notificationSettings"]
|
| 650 |
+
if isinstance(ns, dict):
|
| 651 |
+
notifs = ns.get("notifications")
|
| 652 |
+
if isinstance(notifs, list):
|
| 653 |
+
cleaned_n = []
|
| 654 |
+
for n in notifs:
|
| 655 |
+
if not isinstance(n, dict):
|
| 656 |
+
continue
|
| 657 |
+
method = (n.get("method") or "").strip()
|
| 658 |
+
ntype = (n.get("type") or "").strip()
|
| 659 |
+
if method == "" and ntype == "":
|
| 660 |
+
continue
|
| 661 |
+
if method == "email" and ntype in {"eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda"}:
|
| 662 |
+
cleaned_n.append({"method": method, "type": ntype})
|
| 663 |
+
update_data["notificationSettings"] = {"notifications": cleaned_n} if cleaned_n else None
|
| 664 |
+
elif notifs in (None, ""):
|
| 665 |
+
update_data["notificationSettings"] = None
|
| 666 |
+
elif ns in (None, ""):
|
| 667 |
+
update_data["notificationSettings"] = None
|
| 668 |
+
|
| 669 |
+
# Update calendar entry
|
| 670 |
+
entry = calendar_list_manager.update_calendar_entry(user_id, calendarId, update_data, is_patch=False)
|
| 671 |
+
|
| 672 |
+
if not entry:
|
| 673 |
+
raise HTTPException(
|
| 674 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 675 |
+
detail=f"Calendar not found: {calendarId}"
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
return entry
|
| 679 |
+
|
| 680 |
+
except HTTPException:
|
| 681 |
+
raise
|
| 682 |
+
except PermissionError as e:
|
| 683 |
+
raise HTTPException(
|
| 684 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 685 |
+
detail=f"Access denied: {str(e)}"
|
| 686 |
+
)
|
| 687 |
+
except Exception as e:
|
| 688 |
+
logger.error(f"Error updating calendar list entry {calendarId}: {e}")
|
| 689 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
@router.delete("/{calendarId}", status_code=status.HTTP_204_NO_CONTENT)
|
| 693 |
+
async def remove_calendar_from_list(
|
| 694 |
+
calendarId: str,
|
| 695 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 696 |
+
):
|
| 697 |
+
"""
|
| 698 |
+
Removes a calendar from the user's calendar list
|
| 699 |
+
|
| 700 |
+
DELETE /users/me/calendarList/{calendarId}
|
| 701 |
+
"""
|
| 702 |
+
try:
|
| 703 |
+
database_id, user_id = user_context
|
| 704 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 705 |
+
|
| 706 |
+
# Support keyword 'primary' for calendarId (resolve to actual primary calendar ID)
|
| 707 |
+
if isinstance(calendarId, str) and calendarId.lower() == "primary":
|
| 708 |
+
entries, _, _ = calendar_list_manager.list_calendar_entries(
|
| 709 |
+
user_id=user_id,
|
| 710 |
+
show_hidden=True
|
| 711 |
+
)
|
| 712 |
+
primary_entry = next((e for e in entries if e.get("primary") is True), None)
|
| 713 |
+
if not primary_entry:
|
| 714 |
+
raise HTTPException(
|
| 715 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 716 |
+
detail="Primary calendar not found"
|
| 717 |
+
)
|
| 718 |
+
calendarId = primary_entry["id"]
|
| 719 |
+
|
| 720 |
+
# Check ACL permissions before removing calendar from list
|
| 721 |
+
try:
|
| 722 |
+
calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["owner"])
|
| 723 |
+
except PermissionError:
|
| 724 |
+
raise HTTPException(
|
| 725 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 726 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'"
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
# Check if calendar exists first
|
| 730 |
+
existing_entry = calendar_list_manager.get_calendar_entry(user_id, calendarId)
|
| 731 |
+
if not existing_entry:
|
| 732 |
+
raise HTTPException(
|
| 733 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 734 |
+
detail=f"Calendar not found: {calendarId}"
|
| 735 |
+
)
|
| 736 |
+
|
| 737 |
+
# Delete calendar entry
|
| 738 |
+
try:
|
| 739 |
+
success = calendar_list_manager.delete_calendar_entry(user_id, calendarId)
|
| 740 |
+
|
| 741 |
+
if not success:
|
| 742 |
+
raise HTTPException(status_code=500, detail="Failed to remove calendar from list")
|
| 743 |
+
except ValueError as e:
|
| 744 |
+
# Handle primary calendar removal attempt
|
| 745 |
+
if "Cannot remove primary calendar" in str(e):
|
| 746 |
+
raise HTTPException(
|
| 747 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 748 |
+
detail="Cannot remove primary calendar from calendar list"
|
| 749 |
+
)
|
| 750 |
+
raise
|
| 751 |
+
|
| 752 |
+
# Return 204 No Content (no response body)
|
| 753 |
+
return None
|
| 754 |
+
|
| 755 |
+
except HTTPException:
|
| 756 |
+
raise
|
| 757 |
+
except PermissionError as e:
|
| 758 |
+
raise HTTPException(
|
| 759 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 760 |
+
detail=f"Access denied: {str(e)}"
|
| 761 |
+
)
|
| 762 |
+
except Exception as e:
|
| 763 |
+
logger.error(f"Error deleting calendar list entry {calendarId}: {e}")
|
| 764 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
@router.post("/watch")
|
| 768 |
+
async def watch_calendar_list(
|
| 769 |
+
watch_request: WatchRequest,
|
| 770 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 771 |
+
):
|
| 772 |
+
"""
|
| 773 |
+
Watch for changes to CalendarList resources
|
| 774 |
+
|
| 775 |
+
POST /users/me/calendarList/watch
|
| 776 |
+
"""
|
| 777 |
+
try:
|
| 778 |
+
database_id, user_id = user_context
|
| 779 |
+
calendar_list_manager = get_calendar_list_manager(database_id)
|
| 780 |
+
|
| 781 |
+
# Validate user exists in this database (ensures ownership context)
|
| 782 |
+
from database.session_utils import get_session
|
| 783 |
+
from database.models.user import User
|
| 784 |
+
session = get_session(database_id)
|
| 785 |
+
try:
|
| 786 |
+
user_row = session.query(User).filter(User.user_id == user_id).first()
|
| 787 |
+
if not user_row:
|
| 788 |
+
raise HTTPException(status_code=404, detail=f"User not found: {user_id}")
|
| 789 |
+
finally:
|
| 790 |
+
session.close()
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
# Create the watch channel
|
| 794 |
+
channel = calendar_list_manager.watch_calendar_list(watch_request, user_id)
|
| 795 |
+
# Return Channel response
|
| 796 |
+
return channel
|
| 797 |
+
|
| 798 |
+
except HTTPException:
|
| 799 |
+
raise
|
| 800 |
+
except Exception as e:
|
| 801 |
+
logger.error(f"Error setting up calendar list watch: {e}")
|
| 802 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
server/apis/calendars/__init__.py
ADDED
|
File without changes
|
server/apis/calendars/router.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calendar API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Handles CRUD operations for calendars
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Body, HTTPException, Depends, status
|
| 6 |
+
from schemas.calendar import Calendar, CalendarCreateRequest, CalendarUpdateRequest
|
| 7 |
+
from schemas.common import SuccessResponse
|
| 8 |
+
from database.managers.calendar_manager import CalendarManager
|
| 9 |
+
from database.session_manager import CalendarSessionManager
|
| 10 |
+
from utils.validation import validate_request_colors
|
| 11 |
+
from middleware.auth import get_user_context
|
| 12 |
+
from typing import List
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
router = APIRouter(prefix="/calendars", tags=["calendars"])
|
| 17 |
+
|
| 18 |
+
# Initialize managers
|
| 19 |
+
session_manager = CalendarSessionManager()
|
| 20 |
+
|
| 21 |
+
def get_calendar_manager(database_id: str) -> CalendarManager:
|
| 22 |
+
"""Get calendar manager for the specified database"""
|
| 23 |
+
return CalendarManager(database_id)
|
| 24 |
+
|
| 25 |
+
def check_acl_permissions(manager: CalendarManager, user_id: str, calendar_id: str, allowed_roles: List[str]):
|
| 26 |
+
calendar = manager.get_calendar_by_id(user_id, calendar_id, allowed_roles)
|
| 27 |
+
if not calendar:
|
| 28 |
+
raise HTTPException(
|
| 29 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 30 |
+
detail=f"User '{user_id}' lacks permission on calendar '{calendar_id}'"
|
| 31 |
+
)
|
| 32 |
+
return calendar
|
| 33 |
+
|
| 34 |
+
@router.get("/{calendarId}", response_model=Calendar)
|
| 35 |
+
async def get_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)):
|
| 36 |
+
"""
|
| 37 |
+
Returns metadata for a calendar
|
| 38 |
+
|
| 39 |
+
GET /calendars/{calendarId}
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
database_id, user_id = user_context
|
| 43 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 44 |
+
|
| 45 |
+
if calendarId.lower() == "primary":
|
| 46 |
+
calendar = calendar_manager.get_primary_calendar(user_id)
|
| 47 |
+
if not calendar:
|
| 48 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User '{user_id}' has no primary calendar")
|
| 49 |
+
formatted_response = calendar_manager._format_calendar_response(calendar)
|
| 50 |
+
return Calendar(**formatted_response)
|
| 51 |
+
|
| 52 |
+
calendar = check_acl_permissions(calendar_manager, user_id, calendarId, ["reader", "writer", "owner"])
|
| 53 |
+
formatted_response = calendar_manager._format_calendar_response(calendar)
|
| 54 |
+
return Calendar(**formatted_response)
|
| 55 |
+
except HTTPException:
|
| 56 |
+
raise
|
| 57 |
+
except PermissionError as e:
|
| 58 |
+
raise HTTPException(
|
| 59 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 60 |
+
detail=f"Access denied: {str(e)}"
|
| 61 |
+
)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error getting calendar {calendarId}: {e}")
|
| 64 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 65 |
+
|
| 66 |
+
@router.patch("/{calendarId}", response_model=Calendar)
|
| 67 |
+
def patch_calendar(calendarId: str, update: CalendarUpdateRequest = Body(...), user_context: tuple[str, str] = Depends(get_user_context)):
|
| 68 |
+
"""
|
| 69 |
+
Updates calendar metadata (partial update)
|
| 70 |
+
|
| 71 |
+
PATCH /calendars/{calendarId}
|
| 72 |
+
"""
|
| 73 |
+
try:
|
| 74 |
+
database_id, user_id = user_context
|
| 75 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 76 |
+
|
| 77 |
+
update_data = update.model_dump(exclude_none=True)
|
| 78 |
+
if not update_data:
|
| 79 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for patch")
|
| 80 |
+
|
| 81 |
+
color_error = validate_request_colors(update_data, "calendar", database_id)
|
| 82 |
+
if color_error:
|
| 83 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error)
|
| 84 |
+
|
| 85 |
+
resolved_calendar_id = calendarId
|
| 86 |
+
if calendarId.lower() == "primary":
|
| 87 |
+
primary_calendar = calendar_manager.get_primary_calendar(user_id)
|
| 88 |
+
if not primary_calendar:
|
| 89 |
+
raise HTTPException(status_code=404, detail="Primary calendar not found")
|
| 90 |
+
resolved_calendar_id = primary_calendar.calendar_id
|
| 91 |
+
|
| 92 |
+
check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["writer", "owner"])
|
| 93 |
+
|
| 94 |
+
updated_calendar = calendar_manager.update_calendar(user_id, resolved_calendar_id, update_data)
|
| 95 |
+
if not updated_calendar:
|
| 96 |
+
raise HTTPException(status_code=404, detail="Calendar not found")
|
| 97 |
+
|
| 98 |
+
calendar_manager.session.refresh(updated_calendar)
|
| 99 |
+
# Format the response to match the Pydantic schema
|
| 100 |
+
formatted_response = calendar_manager._format_calendar_response(updated_calendar)
|
| 101 |
+
return Calendar(**formatted_response)
|
| 102 |
+
except HTTPException:
|
| 103 |
+
raise
|
| 104 |
+
except PermissionError as e:
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 107 |
+
detail=f"Access denied: {str(e)}"
|
| 108 |
+
)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"Error updating calendar {calendarId}: {e}")
|
| 111 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 112 |
+
|
| 113 |
+
@router.put("/{calendarId}", response_model=Calendar)
|
| 114 |
+
async def update_calendar(calendarId: str, calendar_request: CalendarUpdateRequest, user_context: tuple[str, str] = Depends(get_user_context)):
|
| 115 |
+
"""
|
| 116 |
+
Updates calendar metadata (full update)
|
| 117 |
+
|
| 118 |
+
PUT /calendars/{calendarId}
|
| 119 |
+
"""
|
| 120 |
+
try:
|
| 121 |
+
database_id, user_id = user_context
|
| 122 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 123 |
+
|
| 124 |
+
update_data = calendar_request.model_dump(exclude_unset=True, exclude_none=True)
|
| 125 |
+
color_error = validate_request_colors(update_data, "calendar", database_id)
|
| 126 |
+
if color_error:
|
| 127 |
+
raise HTTPException(status_code=400, detail=color_error)
|
| 128 |
+
|
| 129 |
+
resolved_calendar_id = calendarId
|
| 130 |
+
if calendarId.lower() == "primary":
|
| 131 |
+
primary_calendar = calendar_manager.get_primary_calendar(user_id)
|
| 132 |
+
if not primary_calendar:
|
| 133 |
+
raise HTTPException(status_code=404, detail="Primary calendar not found")
|
| 134 |
+
resolved_calendar_id = primary_calendar.calendar_id
|
| 135 |
+
|
| 136 |
+
check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["writer", "owner"])
|
| 137 |
+
|
| 138 |
+
logger.debug(f"Update data: {update_data}")
|
| 139 |
+
updated_calendar = calendar_manager.update_calendar(user_id, resolved_calendar_id, update_data)
|
| 140 |
+
if not updated_calendar:
|
| 141 |
+
raise HTTPException(status_code=404, detail="Calendar not found")
|
| 142 |
+
|
| 143 |
+
calendar_manager.session.refresh(updated_calendar)
|
| 144 |
+
|
| 145 |
+
# Format the response to match the Pydantic schema
|
| 146 |
+
formatted_response = calendar_manager._format_calendar_response(updated_calendar)
|
| 147 |
+
return Calendar(**formatted_response)
|
| 148 |
+
except HTTPException:
|
| 149 |
+
raise
|
| 150 |
+
except PermissionError as e:
|
| 151 |
+
raise HTTPException(
|
| 152 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 153 |
+
detail=f"Access denied: {str(e)}"
|
| 154 |
+
)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Error updating calendar {calendarId}: {e}")
|
| 157 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 158 |
+
|
| 159 |
+
@router.post("", response_model=Calendar, status_code=status.HTTP_201_CREATED)
|
| 160 |
+
async def create_calendar(calendar_request: CalendarCreateRequest, user_context: tuple[str, str] = Depends(get_user_context)):
|
| 161 |
+
"""
|
| 162 |
+
Creates a secondary calendar
|
| 163 |
+
|
| 164 |
+
POST /calendars
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
database_id, user_id = user_context
|
| 168 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 169 |
+
|
| 170 |
+
from database.managers.user_manager import UserManager
|
| 171 |
+
user_manager = UserManager(database_id)
|
| 172 |
+
user = user_manager.get_user_by_id(user_id)
|
| 173 |
+
if not user:
|
| 174 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 175 |
+
|
| 176 |
+
calendar_data = calendar_request.model_dump(exclude_unset=True)
|
| 177 |
+
color_error = validate_request_colors(calendar_data, "calendar", database_id)
|
| 178 |
+
if color_error:
|
| 179 |
+
raise HTTPException(status_code=400, detail=color_error)
|
| 180 |
+
|
| 181 |
+
created_calendar = calendar_manager.create_calendar(user_id, calendar_data)
|
| 182 |
+
if not created_calendar:
|
| 183 |
+
raise HTTPException(status_code=500, detail="Failed to create calendar")
|
| 184 |
+
|
| 185 |
+
# Format the response to match the Pydantic schema
|
| 186 |
+
formatted_response = calendar_manager._format_calendar_response(created_calendar)
|
| 187 |
+
return Calendar(**formatted_response)
|
| 188 |
+
except HTTPException:
|
| 189 |
+
raise
|
| 190 |
+
except PermissionError as e:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 193 |
+
detail=f"Access denied: {str(e)}"
|
| 194 |
+
)
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"Error creating calendar: {e}")
|
| 197 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 198 |
+
|
| 199 |
+
@router.delete("/{calendarId}")
|
| 200 |
+
def delete_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)):
|
| 201 |
+
"""
|
| 202 |
+
Deletes a secondary calendar
|
| 203 |
+
|
| 204 |
+
DELETE /calendars/{calendarId}
|
| 205 |
+
"""
|
| 206 |
+
try:
|
| 207 |
+
database_id, user_id = user_context
|
| 208 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 209 |
+
|
| 210 |
+
resolved_calendar_id = calendarId
|
| 211 |
+
if calendarId.lower() == "primary":
|
| 212 |
+
primary_calendar = calendar_manager.get_primary_calendar(user_id)
|
| 213 |
+
if not primary_calendar:
|
| 214 |
+
raise HTTPException(status_code=404, detail="Primary calendar not found")
|
| 215 |
+
resolved_calendar_id = primary_calendar.calendar_id
|
| 216 |
+
|
| 217 |
+
# Check ACL permissions before attempting to delete
|
| 218 |
+
check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["owner"])
|
| 219 |
+
|
| 220 |
+
deleted = calendar_manager.delete_calendar(user_id, resolved_calendar_id)
|
| 221 |
+
if not deleted:
|
| 222 |
+
raise HTTPException(status_code=404, detail="Calendar not found or already deleted")
|
| 223 |
+
|
| 224 |
+
return {"message": "Calendar deleted successfully"}
|
| 225 |
+
except HTTPException:
|
| 226 |
+
raise
|
| 227 |
+
except PermissionError as e:
|
| 228 |
+
raise HTTPException(
|
| 229 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 230 |
+
detail=f"Access denied: {str(e)}"
|
| 231 |
+
)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger.error(f"Error deleting calendar {calendarId}: {e}")
|
| 234 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@router.delete("/{calendarId}/clear", response_model=int)
|
| 238 |
+
def clear_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)):
|
| 239 |
+
"""
|
| 240 |
+
Clears a primary calendar (deletes all events)
|
| 241 |
+
|
| 242 |
+
POST /calendars/{calendarId}/clear
|
| 243 |
+
"""
|
| 244 |
+
try:
|
| 245 |
+
database_id, user_id = user_context
|
| 246 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 247 |
+
|
| 248 |
+
resolved_calendar_id = calendarId
|
| 249 |
+
if calendarId.lower() == "primary":
|
| 250 |
+
primary_calendar = calendar_manager.get_primary_calendar(user_id)
|
| 251 |
+
if not primary_calendar:
|
| 252 |
+
raise HTTPException(status_code=404, detail="Primary calendar not found")
|
| 253 |
+
resolved_calendar_id = primary_calendar.calendar_id
|
| 254 |
+
|
| 255 |
+
# Check ACL permissions before attempting to clear
|
| 256 |
+
check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["owner", "writer"])
|
| 257 |
+
|
| 258 |
+
cleared_count = calendar_manager.clear_calendar(user_id, resolved_calendar_id)
|
| 259 |
+
return cleared_count
|
| 260 |
+
except HTTPException:
|
| 261 |
+
raise
|
| 262 |
+
except PermissionError as e:
|
| 263 |
+
raise HTTPException(
|
| 264 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 265 |
+
detail=f"Access denied: {str(e)}"
|
| 266 |
+
)
|
| 267 |
+
except ValueError as e:
|
| 268 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logger.error(f"Error clearing calendar {calendarId}: {e}")
|
| 271 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
server/apis/colors/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Colors API module"""
|
server/apis/colors/data.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Google Calendar API v3 Colors Data
|
| 3 |
+
Static color definitions for calendars and events
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Dict, Any
|
| 8 |
+
|
| 9 |
+
# Google Calendar color definitions based on official API
|
| 10 |
+
CALENDAR_COLORS = {
|
| 11 |
+
"1": {
|
| 12 |
+
"background": "#ac725e",
|
| 13 |
+
"foreground": "#1d1d1d"
|
| 14 |
+
},
|
| 15 |
+
"2": {
|
| 16 |
+
"background": "#d06b64",
|
| 17 |
+
"foreground": "#1d1d1d"
|
| 18 |
+
},
|
| 19 |
+
"3": {
|
| 20 |
+
"background": "#f83a22",
|
| 21 |
+
"foreground": "#1d1d1d"
|
| 22 |
+
},
|
| 23 |
+
"4": {
|
| 24 |
+
"background": "#fa57c4",
|
| 25 |
+
"foreground": "#1d1d1d"
|
| 26 |
+
},
|
| 27 |
+
"5": {
|
| 28 |
+
"background": "#9fc6e7",
|
| 29 |
+
"foreground": "#1d1d1d"
|
| 30 |
+
},
|
| 31 |
+
"6": {
|
| 32 |
+
"background": "#9a9cff",
|
| 33 |
+
"foreground": "#1d1d1d"
|
| 34 |
+
},
|
| 35 |
+
"7": {
|
| 36 |
+
"background": "#4986e7",
|
| 37 |
+
"foreground": "#1d1d1d"
|
| 38 |
+
},
|
| 39 |
+
"8": {
|
| 40 |
+
"background": "#9aa116",
|
| 41 |
+
"foreground": "#1d1d1d"
|
| 42 |
+
},
|
| 43 |
+
"9": {
|
| 44 |
+
"background": "#ef6c00",
|
| 45 |
+
"foreground": "#1d1d1d"
|
| 46 |
+
},
|
| 47 |
+
"10": {
|
| 48 |
+
"background": "#ff7537",
|
| 49 |
+
"foreground": "#1d1d1d"
|
| 50 |
+
},
|
| 51 |
+
"11": {
|
| 52 |
+
"background": "#42d692",
|
| 53 |
+
"foreground": "#1d1d1d"
|
| 54 |
+
},
|
| 55 |
+
"12": {
|
| 56 |
+
"background": "#16a765",
|
| 57 |
+
"foreground": "#1d1d1d"
|
| 58 |
+
},
|
| 59 |
+
"13": {
|
| 60 |
+
"background": "#7bd148",
|
| 61 |
+
"foreground": "#1d1d1d"
|
| 62 |
+
},
|
| 63 |
+
"14": {
|
| 64 |
+
"background": "#b3dc6c",
|
| 65 |
+
"foreground": "#1d1d1d"
|
| 66 |
+
},
|
| 67 |
+
"15": {
|
| 68 |
+
"background": "#fbe983",
|
| 69 |
+
"foreground": "#1d1d1d"
|
| 70 |
+
},
|
| 71 |
+
"16": {
|
| 72 |
+
"background": "#fad165",
|
| 73 |
+
"foreground": "#1d1d1d"
|
| 74 |
+
},
|
| 75 |
+
"17": {
|
| 76 |
+
"background": "#92e1c0",
|
| 77 |
+
"foreground": "#1d1d1d"
|
| 78 |
+
},
|
| 79 |
+
"18": {
|
| 80 |
+
"background": "#9fe1e7",
|
| 81 |
+
"foreground": "#1d1d1d"
|
| 82 |
+
},
|
| 83 |
+
"19": {
|
| 84 |
+
"background": "#9fc6e7",
|
| 85 |
+
"foreground": "#1d1d1d"
|
| 86 |
+
},
|
| 87 |
+
"20": {
|
| 88 |
+
"background": "#4986e7",
|
| 89 |
+
"foreground": "#1d1d1d"
|
| 90 |
+
},
|
| 91 |
+
"21": {
|
| 92 |
+
"background": "#9aa116",
|
| 93 |
+
"foreground": "#1d1d1d"
|
| 94 |
+
},
|
| 95 |
+
"22": {
|
| 96 |
+
"background": "#16a765",
|
| 97 |
+
"foreground": "#1d1d1d"
|
| 98 |
+
},
|
| 99 |
+
"23": {
|
| 100 |
+
"background": "#ff7537",
|
| 101 |
+
"foreground": "#1d1d1d"
|
| 102 |
+
},
|
| 103 |
+
"24": {
|
| 104 |
+
"background": "#ffad46",
|
| 105 |
+
"foreground": "#1d1d1d"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
EVENT_COLORS = {
|
| 110 |
+
"1": {
|
| 111 |
+
"background": "#a4bdfc",
|
| 112 |
+
"foreground": "#1d1d1d"
|
| 113 |
+
},
|
| 114 |
+
"2": {
|
| 115 |
+
"background": "#7ae7bf",
|
| 116 |
+
"foreground": "#1d1d1d"
|
| 117 |
+
},
|
| 118 |
+
"3": {
|
| 119 |
+
"background": "#dbadff",
|
| 120 |
+
"foreground": "#1d1d1d"
|
| 121 |
+
},
|
| 122 |
+
"4": {
|
| 123 |
+
"background": "#ff887c",
|
| 124 |
+
"foreground": "#1d1d1d"
|
| 125 |
+
},
|
| 126 |
+
"5": {
|
| 127 |
+
"background": "#fbd75b",
|
| 128 |
+
"foreground": "#1d1d1d"
|
| 129 |
+
},
|
| 130 |
+
"6": {
|
| 131 |
+
"background": "#ffb878",
|
| 132 |
+
"foreground": "#1d1d1d"
|
| 133 |
+
},
|
| 134 |
+
"7": {
|
| 135 |
+
"background": "#46d6db",
|
| 136 |
+
"foreground": "#1d1d1d"
|
| 137 |
+
},
|
| 138 |
+
"8": {
|
| 139 |
+
"background": "#e1e1e1",
|
| 140 |
+
"foreground": "#1d1d1d"
|
| 141 |
+
},
|
| 142 |
+
"9": {
|
| 143 |
+
"background": "#5484ed",
|
| 144 |
+
"foreground": "#1d1d1d"
|
| 145 |
+
},
|
| 146 |
+
"10": {
|
| 147 |
+
"background": "#51b749",
|
| 148 |
+
"foreground": "#1d1d1d"
|
| 149 |
+
},
|
| 150 |
+
"11": {
|
| 151 |
+
"background": "#dc2127",
|
| 152 |
+
"foreground": "#1d1d1d"
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
def get_colors_response() -> Dict[str, Any]:
|
| 157 |
+
"""
|
| 158 |
+
Get the complete colors response in Google Calendar API v3 format
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Dict containing calendar and event colors with metadata
|
| 162 |
+
"""
|
| 163 |
+
return {
|
| 164 |
+
"kind": "calendar#colors",
|
| 165 |
+
"updated": "2023-01-01T00:00:00.000Z",
|
| 166 |
+
"calendar": CALENDAR_COLORS,
|
| 167 |
+
"event": EVENT_COLORS
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
def get_color_by_id(color_type: str, color_id: str) -> Dict[str, str]:
|
| 171 |
+
"""
|
| 172 |
+
Get a specific color by type and ID
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
color_type: Either 'calendar' or 'event'
|
| 176 |
+
color_id: The color ID (e.g., '1', '2', etc.)
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Dict containing background and foreground colors
|
| 180 |
+
|
| 181 |
+
Raises:
|
| 182 |
+
ValueError: If color_type or color_id is invalid
|
| 183 |
+
"""
|
| 184 |
+
if color_type == "calendar":
|
| 185 |
+
colors = CALENDAR_COLORS
|
| 186 |
+
elif color_type == "event":
|
| 187 |
+
colors = EVENT_COLORS
|
| 188 |
+
else:
|
| 189 |
+
raise ValueError(f"Invalid color type: {color_type}. Must be 'calendar' or 'event'")
|
| 190 |
+
|
| 191 |
+
if color_id not in colors:
|
| 192 |
+
raise ValueError(f"Invalid color ID: {color_id} for type {color_type}")
|
| 193 |
+
|
| 194 |
+
return colors[color_id]
|
| 195 |
+
|
| 196 |
+
def get_all_calendar_colors() -> Dict[str, Dict[str, str]]:
|
| 197 |
+
"""Get all calendar colors"""
|
| 198 |
+
return CALENDAR_COLORS.copy()
|
| 199 |
+
|
| 200 |
+
def get_all_event_colors() -> Dict[str, Dict[str, str]]:
|
| 201 |
+
"""Get all event colors"""
|
| 202 |
+
return EVENT_COLORS.copy()
|
| 203 |
+
|
| 204 |
+
def validate_color_id(color_type: str, color_id: str) -> bool:
|
| 205 |
+
"""
|
| 206 |
+
Validate if a color ID exists for the given type
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
color_type: Either 'calendar' or 'event'
|
| 210 |
+
color_id: The color ID to validate
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
True if color ID exists, False otherwise
|
| 214 |
+
"""
|
| 215 |
+
try:
|
| 216 |
+
get_color_by_id(color_type, color_id)
|
| 217 |
+
return True
|
| 218 |
+
except ValueError:
|
| 219 |
+
return False
|
server/apis/colors/router.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Colors API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Provides color definitions for calendars and events
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from fastapi import APIRouter, HTTPException, Header, status
|
| 8 |
+
from typing import Dict, Any
|
| 9 |
+
from database.managers.color_manager import ColorManager
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/colors", tags=["colors"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_color_manager(database_id: str) -> ColorManager:
|
| 17 |
+
"""Get color manager for the specified database"""
|
| 18 |
+
return ColorManager(database_id)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("", response_model=Dict[str, Any])
|
| 22 |
+
async def get_colors(x_database_id: str = Header(alias="x-database-id")):
|
| 23 |
+
"""
|
| 24 |
+
Returns the color definitions for calendars and events
|
| 25 |
+
|
| 26 |
+
GET /colors
|
| 27 |
+
|
| 28 |
+
Returns a global palette of color definitions for calendars and events.
|
| 29 |
+
Color data is dynamically loaded from database with exact Google Calendar API v3 format.
|
| 30 |
+
Colors are global/shared across all users - no user_id required.
|
| 31 |
+
Use POST /api/load-sample-colors to populate database with Google's color definitions.
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
color_manager = get_color_manager(x_database_id)
|
| 35 |
+
|
| 36 |
+
colors_response = color_manager.get_colors_response()
|
| 37 |
+
|
| 38 |
+
logger.info("Retrieved calendar and event color definitions from database")
|
| 39 |
+
return colors_response
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Error getting color definitions: {e}")
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 45 |
+
detail=f"Internal server error: {str(e)}"
|
| 46 |
+
)
|
server/apis/core_apis.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core API endpoints
|
| 3 |
+
Handles health check and other core endpoints for Calendar API clone
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
|
| 9 |
+
# Configure logging
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Create router for core APIs
|
| 13 |
+
router = APIRouter(tags=["core"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/health")
|
| 17 |
+
async def health_check():
|
| 18 |
+
"""Health check endpoint"""
|
| 19 |
+
return {"status": "healthy", "service": "calendar-env"}
|
server/apis/database_router.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database Management API endpoints for Calendar API
|
| 3 |
+
Handles database operations like seeding, viewing, and schema management
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import sqlite3
|
| 10 |
+
import time
|
| 11 |
+
import random
|
| 12 |
+
import string
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Header
|
| 15 |
+
from fastapi.responses import FileResponse
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
from typing import Any, Dict, Optional
|
| 18 |
+
|
| 19 |
+
from data.google_colors import GOOGLE_CALENDAR_COLORS
|
| 20 |
+
from data.multi_user_sample import get_multi_user_sql
|
| 21 |
+
from database.session_manager import CalendarSessionManager
|
| 22 |
+
from database.base_manager import BaseManager
|
| 23 |
+
from database.session_utils import _session_manager
|
| 24 |
+
from database.managers.calendar_manager import CalendarManager
|
| 25 |
+
from database.seed_database import SeedData, get_seed_session, init_seed_database
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
# Initialize session manager
|
| 30 |
+
calendar_session_manager = CalendarSessionManager()
|
| 31 |
+
|
| 32 |
+
router = APIRouter(prefix="/api", tags=["database"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/sample-data")
|
| 36 |
+
async def get_sample_data():
|
| 37 |
+
"""Get multi-user Calendar sample data as SQL script for download/inspection"""
|
| 38 |
+
try:
|
| 39 |
+
sql_content = get_multi_user_sql()
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"success": True,
|
| 43 |
+
"message": "Sample calendar data.",
|
| 44 |
+
"format": "sql",
|
| 45 |
+
"sql_content": sql_content,
|
| 46 |
+
}
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error retrieving sample data: {e}")
|
| 49 |
+
raise HTTPException(status_code=500, detail=f"Failed to retrieve sample data: {str(e)}")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.post("/seed-database")
|
| 53 |
+
async def seed_database(body: dict):
|
| 54 |
+
"""Seed database with user-provided SQL content or default sample data"""
|
| 55 |
+
try:
|
| 56 |
+
database_id = body.get("database_id")
|
| 57 |
+
if not database_id:
|
| 58 |
+
raise HTTPException(status_code=400, detail="database_id is required")
|
| 59 |
+
sql_content = body.get("sql_content")
|
| 60 |
+
|
| 61 |
+
if not sql_content:
|
| 62 |
+
raise HTTPException(status_code=400, detail="sql_content is required")
|
| 63 |
+
|
| 64 |
+
# Initialize database
|
| 65 |
+
calendar_session_manager.init_database(database_id, create_tables=True)
|
| 66 |
+
db_path = calendar_session_manager.get_db_path(database_id)
|
| 67 |
+
|
| 68 |
+
# Execute SQL content
|
| 69 |
+
conn = sqlite3.connect(db_path)
|
| 70 |
+
try:
|
| 71 |
+
cursor = conn.cursor()
|
| 72 |
+
|
| 73 |
+
# Drop all existing tables to ensure fresh schema from SQLAlchemy models
|
| 74 |
+
# Get all table names dynamically
|
| 75 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
| 76 |
+
tables = cursor.fetchall()
|
| 77 |
+
|
| 78 |
+
# Drop all tables to get fresh schema
|
| 79 |
+
for table in tables:
|
| 80 |
+
table_name = table[0]
|
| 81 |
+
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
|
| 82 |
+
|
| 83 |
+
conn.commit()
|
| 84 |
+
conn.close()
|
| 85 |
+
|
| 86 |
+
# Reinitialize database with fresh schema from SQLAlchemy models
|
| 87 |
+
calendar_session_manager.init_database(database_id, create_tables=True)
|
| 88 |
+
|
| 89 |
+
# Reconnect to database
|
| 90 |
+
conn = sqlite3.connect(db_path)
|
| 91 |
+
cursor = conn.cursor()
|
| 92 |
+
|
| 93 |
+
# Execute custom SQL statements individually
|
| 94 |
+
statements = []
|
| 95 |
+
for line in sql_content.split("\n"):
|
| 96 |
+
line = line.strip()
|
| 97 |
+
if line and not line.startswith("--"):
|
| 98 |
+
statements.append(line)
|
| 99 |
+
|
| 100 |
+
# Join and split by semicolon
|
| 101 |
+
full_sql = " ".join(statements)
|
| 102 |
+
individual_statements = [stmt.strip() for stmt in full_sql.split(";") if stmt.strip()]
|
| 103 |
+
|
| 104 |
+
# Execute each statement
|
| 105 |
+
for statement in individual_statements:
|
| 106 |
+
try:
|
| 107 |
+
# Skip empty statements
|
| 108 |
+
if not statement.strip():
|
| 109 |
+
continue
|
| 110 |
+
cursor.execute(statement)
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error(f"Error executing statement: {statement[:100]}...")
|
| 113 |
+
logger.error(f"Error details: {e}")
|
| 114 |
+
raise e
|
| 115 |
+
|
| 116 |
+
conn.commit()
|
| 117 |
+
|
| 118 |
+
# Enforce primary calendar constraints and ensure ACL entries exist
|
| 119 |
+
try:
|
| 120 |
+
calendar_manager = CalendarManager(database_id)
|
| 121 |
+
calendar_manager.enforce_primary_calendar_constraint_for_all_users()
|
| 122 |
+
|
| 123 |
+
# Ensure ACL entries exist for all calendar owners
|
| 124 |
+
# This is important for the new ACL validation system
|
| 125 |
+
logger.info("Ensuring ACL entries exist for all calendar owners...")
|
| 126 |
+
|
| 127 |
+
# Get all calendars and ensure their owners have ACL entries
|
| 128 |
+
cursor.execute("SELECT calendar_id, user_id FROM calendars")
|
| 129 |
+
calendars = cursor.fetchall()
|
| 130 |
+
|
| 131 |
+
for calendar_row in calendars:
|
| 132 |
+
calendar_id = calendar_row[0]
|
| 133 |
+
user_id = calendar_row[1]
|
| 134 |
+
|
| 135 |
+
# Check if ACL entry already exists for this calendar owner
|
| 136 |
+
cursor.execute("""
|
| 137 |
+
SELECT COUNT(*) as count FROM acls a
|
| 138 |
+
JOIN scopes s ON a.scope_id = s.id
|
| 139 |
+
JOIN users u ON u.email = s.value
|
| 140 |
+
WHERE a.calendar_id = ? AND u.user_id = ? AND a.role = 'owner'
|
| 141 |
+
""", (calendar_id, user_id))
|
| 142 |
+
|
| 143 |
+
acl_exists = cursor.fetchone()[0] > 0
|
| 144 |
+
|
| 145 |
+
if not acl_exists:
|
| 146 |
+
# Get user email
|
| 147 |
+
cursor.execute("SELECT email FROM users WHERE user_id = ?", (user_id,))
|
| 148 |
+
user_result = cursor.fetchone()
|
| 149 |
+
|
| 150 |
+
if user_result:
|
| 151 |
+
user_email = user_result[0]
|
| 152 |
+
|
| 153 |
+
# Create scope for this user if it doesn't exist
|
| 154 |
+
scope_id = f"scope-{user_id}"
|
| 155 |
+
cursor.execute("INSERT OR IGNORE INTO scopes (id, type, value) VALUES (?, 'user', ?)",
|
| 156 |
+
(scope_id, user_email))
|
| 157 |
+
|
| 158 |
+
# Create ACL entries for calendar owner (both owner and writer roles)
|
| 159 |
+
owner_acl_id = f"acl-{calendar_id}-owner"
|
| 160 |
+
writer_acl_id = f"acl-{calendar_id}-writer"
|
| 161 |
+
|
| 162 |
+
cursor.execute("""
|
| 163 |
+
INSERT OR IGNORE INTO acls (id, calendar_id, user_id, role, scope_id, etag, created_at, updated_at)
|
| 164 |
+
VALUES (?, ?, ?, 'owner', ?, ?, datetime('now'), datetime('now'))
|
| 165 |
+
""", (owner_acl_id, calendar_id, user_id, scope_id, f'etag-{owner_acl_id}'))
|
| 166 |
+
|
| 167 |
+
cursor.execute("""
|
| 168 |
+
INSERT OR IGNORE INTO acls (id, calendar_id, user_id, role, scope_id, etag, created_at, updated_at)
|
| 169 |
+
VALUES (?, ?, ?, 'writer', ?, ?, datetime('now'), datetime('now'))
|
| 170 |
+
""", (writer_acl_id, calendar_id, user_id, scope_id, f'etag-{writer_acl_id}'))
|
| 171 |
+
|
| 172 |
+
logger.info(f"Created owner and writer ACL entries for calendar {calendar_id} owner {user_email}")
|
| 173 |
+
|
| 174 |
+
conn.commit()
|
| 175 |
+
logger.info("ACL validation completed successfully")
|
| 176 |
+
|
| 177 |
+
except Exception as constraint_error:
|
| 178 |
+
logger.warning(f"Error enforcing constraints and ACL validation: {constraint_error}")
|
| 179 |
+
|
| 180 |
+
# Store SQL content in seed_data table for future resets
|
| 181 |
+
sql_stored = False
|
| 182 |
+
try:
|
| 183 |
+
seed_db_session = get_seed_session()
|
| 184 |
+
try:
|
| 185 |
+
existing_seed = seed_db_session.query(SeedData).filter(
|
| 186 |
+
SeedData.database_id == database_id
|
| 187 |
+
).first()
|
| 188 |
+
|
| 189 |
+
name = body.get("name", f"Database {database_id}")
|
| 190 |
+
description = body.get("description", "")
|
| 191 |
+
|
| 192 |
+
if existing_seed:
|
| 193 |
+
existing_seed.sql_content = sql_content
|
| 194 |
+
existing_seed.name = name
|
| 195 |
+
existing_seed.description = description
|
| 196 |
+
logger.info(f"Updated seed SQL for database {database_id}")
|
| 197 |
+
else:
|
| 198 |
+
seed_entry = SeedData(
|
| 199 |
+
database_id=database_id,
|
| 200 |
+
name=name,
|
| 201 |
+
description=description,
|
| 202 |
+
sql_content=sql_content
|
| 203 |
+
)
|
| 204 |
+
seed_db_session.add(seed_entry)
|
| 205 |
+
logger.info(f"Stored new seed SQL for database {database_id}")
|
| 206 |
+
|
| 207 |
+
seed_db_session.commit()
|
| 208 |
+
sql_stored = True
|
| 209 |
+
finally:
|
| 210 |
+
seed_db_session.close()
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logger.warning(f"Failed to store seed SQL in database: {e}")
|
| 213 |
+
sql_stored = False
|
| 214 |
+
|
| 215 |
+
result = {
|
| 216 |
+
"success": True,
|
| 217 |
+
"message": f"Database seeded successfully",
|
| 218 |
+
"database_id": database_id,
|
| 219 |
+
"sql_stored": sql_stored,
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
logger.info(f"Successfully seeded database {database_id}")
|
| 223 |
+
return result
|
| 224 |
+
|
| 225 |
+
finally:
|
| 226 |
+
conn.close()
|
| 227 |
+
|
| 228 |
+
except HTTPException:
|
| 229 |
+
raise
|
| 230 |
+
except Exception as e:
|
| 231 |
+
logger.error(f"Error seeding database: {e}")
|
| 232 |
+
raise HTTPException(status_code=500, detail=f"Failed to seed database: {str(e)}")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@router.get("/database-state")
|
| 236 |
+
async def get_database_state(x_database_id: str = Header(alias="x-database-id")):
|
| 237 |
+
"""Get current database state and all data for frontend tabular display"""
|
| 238 |
+
try:
|
| 239 |
+
database_id = x_database_id
|
| 240 |
+
|
| 241 |
+
# Initialize database if needed
|
| 242 |
+
calendar_session_manager.init_database(database_id)
|
| 243 |
+
db_path = calendar_session_manager.get_db_path(database_id)
|
| 244 |
+
|
| 245 |
+
if not os.path.exists(db_path):
|
| 246 |
+
return {
|
| 247 |
+
"success": True,
|
| 248 |
+
"message": "Database not initialized yet",
|
| 249 |
+
"database_info": {"status": "not_created"},
|
| 250 |
+
"table_counts": {},
|
| 251 |
+
"table_data": {},
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
# Connect to database
|
| 255 |
+
conn = sqlite3.connect(db_path)
|
| 256 |
+
conn.row_factory = sqlite3.Row
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
cursor = conn.cursor()
|
| 260 |
+
|
| 261 |
+
# Get all user-defined tables
|
| 262 |
+
cursor.execute(
|
| 263 |
+
"""
|
| 264 |
+
SELECT name FROM sqlite_master
|
| 265 |
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
| 266 |
+
ORDER BY name
|
| 267 |
+
"""
|
| 268 |
+
)
|
| 269 |
+
tables = cursor.fetchall()
|
| 270 |
+
|
| 271 |
+
# Get data from each table
|
| 272 |
+
table_data = {}
|
| 273 |
+
table_counts = {}
|
| 274 |
+
|
| 275 |
+
for table in tables:
|
| 276 |
+
table_name = table["name"]
|
| 277 |
+
|
| 278 |
+
# Get count
|
| 279 |
+
cursor.execute(f"SELECT COUNT(*) as count FROM '{table_name}'")
|
| 280 |
+
count = cursor.fetchone()["count"]
|
| 281 |
+
table_counts[table_name] = count
|
| 282 |
+
|
| 283 |
+
# Get data (limit to 100 rows for performance)
|
| 284 |
+
cursor.execute(f"SELECT * FROM '{table_name}' LIMIT 100")
|
| 285 |
+
rows = cursor.fetchall()
|
| 286 |
+
|
| 287 |
+
# Convert to list of dictionaries
|
| 288 |
+
table_data[table_name] = [dict(row) for row in rows]
|
| 289 |
+
|
| 290 |
+
# Get database info
|
| 291 |
+
db_file_size = os.path.getsize(db_path)
|
| 292 |
+
db_modified = datetime.fromtimestamp(os.path.getmtime(db_path)).isoformat()
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
"success": True,
|
| 296 |
+
"database_id": database_id,
|
| 297 |
+
"service": "calendar-api-server",
|
| 298 |
+
"database_info": {
|
| 299 |
+
"path": db_path,
|
| 300 |
+
"size_bytes": db_file_size,
|
| 301 |
+
"size_mb": round(db_file_size / (1024 * 1024), 2),
|
| 302 |
+
"last_modified": db_modified,
|
| 303 |
+
"total_tables": len(tables),
|
| 304 |
+
},
|
| 305 |
+
"table_counts": table_counts,
|
| 306 |
+
"table_data": table_data,
|
| 307 |
+
"timestamp": datetime.now().isoformat(),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
finally:
|
| 311 |
+
conn.close()
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger.error(f"Error getting database state: {e}")
|
| 315 |
+
raise HTTPException(status_code=500, detail=f"Failed to get database state: {str(e)}")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@router.get("/schema")
|
| 319 |
+
async def get_schema():
|
| 320 |
+
"""Get database schema as JSON"""
|
| 321 |
+
try:
|
| 322 |
+
# Return static schema definition from SQLAlchemy models
|
| 323 |
+
return {
|
| 324 |
+
"success": True,
|
| 325 |
+
"message": "Database schema retrieved successfully",
|
| 326 |
+
"schema": calendar_session_manager.get_database_schema(),
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
except Exception as e:
|
| 330 |
+
logger.error(f"Error getting database schema: {e}")
|
| 331 |
+
raise HTTPException(status_code=500, detail=f"Failed to retrieve database schema: {str(e)}")
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
@router.get("/download-db-file")
|
| 335 |
+
async def download_database_file(x_database_id: str = Header(default="default", alias="x-database-id")):
|
| 336 |
+
"""Download the actual SQLite database file (.db)"""
|
| 337 |
+
try:
|
| 338 |
+
database_id = x_database_id.strip()
|
| 339 |
+
|
| 340 |
+
# Basic input validation
|
| 341 |
+
if not database_id or database_id == "":
|
| 342 |
+
raise HTTPException(status_code=400, detail="Database ID cannot be empty")
|
| 343 |
+
|
| 344 |
+
logger.info(f"-------Download DB File for {database_id}------")
|
| 345 |
+
|
| 346 |
+
# Initialize database if needed
|
| 347 |
+
calendar_session_manager.init_database(database_id)
|
| 348 |
+
db_path = calendar_session_manager.get_db_path(database_id)
|
| 349 |
+
|
| 350 |
+
# Check if file exists and is readable
|
| 351 |
+
if not os.path.exists(db_path):
|
| 352 |
+
raise HTTPException(status_code=404, detail=f"Database with ID '{database_id}' not found")
|
| 353 |
+
|
| 354 |
+
if not os.path.isfile(db_path):
|
| 355 |
+
raise HTTPException(status_code=400, detail=f"Database path '{db_path}' is not a valid file")
|
| 356 |
+
|
| 357 |
+
if not os.access(db_path, os.R_OK):
|
| 358 |
+
raise HTTPException(status_code=403, detail=f"Database file is not readable")
|
| 359 |
+
|
| 360 |
+
# Generate filename for download with sanitized database_id
|
| 361 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 362 |
+
safe_database_id = "".join(c for c in database_id if c.isalnum() or c in "_-")
|
| 363 |
+
filename = f"ola_database_{safe_database_id}_{timestamp}.db"
|
| 364 |
+
|
| 365 |
+
logger.info(f"Serving database file: {db_path} as {filename}")
|
| 366 |
+
|
| 367 |
+
# Return the actual database file
|
| 368 |
+
return FileResponse(
|
| 369 |
+
path=db_path,
|
| 370 |
+
filename=filename,
|
| 371 |
+
media_type="application/x-sqlite3",
|
| 372 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
except HTTPException:
|
| 376 |
+
raise
|
| 377 |
+
except Exception as e:
|
| 378 |
+
logger.error(f"Error downloading database file: {e}")
|
| 379 |
+
raise HTTPException(status_code=500, detail=f"Failed to download database file: {str(e)}")
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
class SQLQueryRequest(BaseModel):
|
| 383 |
+
"""Request model for executing SQL queries"""
|
| 384 |
+
|
| 385 |
+
query: str = Field(..., min_length=1, description="SQL query to execute")
|
| 386 |
+
limit: Optional[int] = Field(100, ge=1, le=1000, description="Maximum number of rows to return")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
@router.post("/sql-runner", response_model=Dict[str, Any])
|
| 390 |
+
def execute_custom_query(request: SQLQueryRequest, x_database_id: str = Header(...)):
|
| 391 |
+
"""Execute a custom SQL query with limit"""
|
| 392 |
+
try:
|
| 393 |
+
# Ensure database exists
|
| 394 |
+
calendar_session_manager.init_database(x_database_id)
|
| 395 |
+
db_path = calendar_session_manager.get_db_path(x_database_id)
|
| 396 |
+
|
| 397 |
+
# Use BaseManager to execute the query
|
| 398 |
+
manager = BaseManager(db_path)
|
| 399 |
+
|
| 400 |
+
# Add LIMIT clause to query if not present
|
| 401 |
+
query = request.query.strip().rstrip(";")
|
| 402 |
+
|
| 403 |
+
# Execute the query
|
| 404 |
+
data = manager.execute_query(query)
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
"success": True,
|
| 408 |
+
"message": "Query executed successfully",
|
| 409 |
+
"data": data,
|
| 410 |
+
"row_count": len(data),
|
| 411 |
+
"query": query,
|
| 412 |
+
}
|
| 413 |
+
except Exception as e:
|
| 414 |
+
logger.error(f"Error executing custom query: {e}")
|
| 415 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@router.post("/reset-database")
|
| 419 |
+
async def reset_database(body: dict):
|
| 420 |
+
"""Reset a database to its original seeded state."""
|
| 421 |
+
try:
|
| 422 |
+
database_id = body.get("database_id")
|
| 423 |
+
if not database_id:
|
| 424 |
+
raise HTTPException(status_code=400, detail="database_id is required")
|
| 425 |
+
|
| 426 |
+
sql_content = body.get("sql_content")
|
| 427 |
+
|
| 428 |
+
# Get or store seed SQL
|
| 429 |
+
seed_session = get_seed_session()
|
| 430 |
+
try:
|
| 431 |
+
seed_data = seed_session.query(SeedData).filter(SeedData.database_id == database_id).first()
|
| 432 |
+
|
| 433 |
+
if seed_data:
|
| 434 |
+
sql_content = seed_data.sql_content
|
| 435 |
+
logger.info(f"Using stored SQL for database {database_id}")
|
| 436 |
+
elif sql_content:
|
| 437 |
+
new_seed = SeedData(
|
| 438 |
+
database_id=database_id,
|
| 439 |
+
name=f"calendar_{database_id}",
|
| 440 |
+
description="Seed SQL for Calendar database (lazy migration)",
|
| 441 |
+
sql_content=sql_content
|
| 442 |
+
)
|
| 443 |
+
seed_session.add(new_seed)
|
| 444 |
+
seed_session.commit()
|
| 445 |
+
logger.info(f"Stored SQL via lazy migration for database {database_id}")
|
| 446 |
+
else:
|
| 447 |
+
raise HTTPException(
|
| 448 |
+
status_code=400,
|
| 449 |
+
detail="No stored SQL found and no sql_content provided. Cannot reset database."
|
| 450 |
+
)
|
| 451 |
+
finally:
|
| 452 |
+
seed_session.close()
|
| 453 |
+
|
| 454 |
+
# Close existing session and get db path
|
| 455 |
+
calendar_session_manager.close_session(database_id)
|
| 456 |
+
db_path = calendar_session_manager.get_db_path(database_id)
|
| 457 |
+
|
| 458 |
+
# Delete the database file completely (faster than dropping tables)
|
| 459 |
+
if os.path.exists(db_path):
|
| 460 |
+
os.remove(db_path)
|
| 461 |
+
logger.info(f"Deleted database file: {db_path}")
|
| 462 |
+
|
| 463 |
+
# Reinitialize with fresh tables
|
| 464 |
+
calendar_session_manager.init_database(database_id, create_tables=True)
|
| 465 |
+
|
| 466 |
+
# Execute seed SQL in one go using executescript
|
| 467 |
+
conn = sqlite3.connect(db_path)
|
| 468 |
+
try:
|
| 469 |
+
# executescript is much faster than executing statements one by one
|
| 470 |
+
conn.executescript(sql_content)
|
| 471 |
+
conn.commit()
|
| 472 |
+
logger.info(f"Database {database_id} reset successfully")
|
| 473 |
+
|
| 474 |
+
return {
|
| 475 |
+
"success": True,
|
| 476 |
+
"message": "Database reset successfully",
|
| 477 |
+
"database_id": database_id
|
| 478 |
+
}
|
| 479 |
+
finally:
|
| 480 |
+
conn.close()
|
| 481 |
+
|
| 482 |
+
except HTTPException:
|
| 483 |
+
raise
|
| 484 |
+
except Exception as e:
|
| 485 |
+
logger.error(f"Error resetting database: {e}")
|
| 486 |
+
raise HTTPException(status_code=500, detail=f"Failed to reset database: {str(e)}")
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@router.post("/clone-database")
|
| 490 |
+
async def clone_database(body: dict):
|
| 491 |
+
"""
|
| 492 |
+
Clone an existing database to a new database.
|
| 493 |
+
Returns the new database_id.
|
| 494 |
+
"""
|
| 495 |
+
try:
|
| 496 |
+
# Accept both "database_id" and "source_database_id" for compatibility
|
| 497 |
+
source_database_id = body.get("database_id") or body.get("source_database_id")
|
| 498 |
+
if not source_database_id:
|
| 499 |
+
raise HTTPException(status_code=400, detail="database_id is required")
|
| 500 |
+
|
| 501 |
+
logger.info(f"Clone database request for: {source_database_id}")
|
| 502 |
+
|
| 503 |
+
# Get source database path
|
| 504 |
+
source_db_path = calendar_session_manager.get_db_path(source_database_id)
|
| 505 |
+
|
| 506 |
+
if not os.path.exists(source_db_path):
|
| 507 |
+
raise HTTPException(status_code=404, detail=f"Source database '{source_database_id}' not found")
|
| 508 |
+
|
| 509 |
+
# Generate new database ID
|
| 510 |
+
timestamp = int(time.time() * 1000)
|
| 511 |
+
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=9))
|
| 512 |
+
new_db_id = f"db_clone_{timestamp}_{random_suffix}"
|
| 513 |
+
|
| 514 |
+
# Get new database path
|
| 515 |
+
new_db_path = calendar_session_manager.get_db_path(new_db_id)
|
| 516 |
+
|
| 517 |
+
# Close any existing session on source to ensure file is not locked
|
| 518 |
+
calendar_session_manager.close_session(source_database_id)
|
| 519 |
+
|
| 520 |
+
# Clone the database file
|
| 521 |
+
shutil.copy2(source_db_path, new_db_path)
|
| 522 |
+
|
| 523 |
+
# Copy associated files (WAL, SHM, journal) if they exist
|
| 524 |
+
for ext in ['-wal', '-shm', '-journal']:
|
| 525 |
+
source_extra = source_db_path + ext
|
| 526 |
+
if os.path.exists(source_extra):
|
| 527 |
+
dest_extra = new_db_path + ext
|
| 528 |
+
shutil.copy2(source_extra, dest_extra)
|
| 529 |
+
logger.debug(f"Copied {source_extra} to {dest_extra}")
|
| 530 |
+
|
| 531 |
+
# Initialize the session for the cloned database
|
| 532 |
+
calendar_session_manager.init_database(new_db_id, create_tables=False)
|
| 533 |
+
|
| 534 |
+
# Get cloned database size
|
| 535 |
+
cloned_size_bytes = os.path.getsize(new_db_path) if os.path.exists(new_db_path) else 0
|
| 536 |
+
|
| 537 |
+
result = {
|
| 538 |
+
"success": True,
|
| 539 |
+
"message": "Database cloned successfully",
|
| 540 |
+
"source_database_id": source_database_id,
|
| 541 |
+
"cloned_database_id": new_db_id,
|
| 542 |
+
"cloned_db_path": new_db_path,
|
| 543 |
+
"cloned_size_bytes": cloned_size_bytes,
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
logger.info(f"Successfully cloned database '{source_database_id}' to '{new_db_id}'")
|
| 547 |
+
return result
|
| 548 |
+
|
| 549 |
+
except HTTPException:
|
| 550 |
+
raise
|
| 551 |
+
except Exception as e:
|
| 552 |
+
logger.error(f"Error cloning database: {e}")
|
| 553 |
+
raise HTTPException(status_code=500, detail=f"Failed to clone database: {str(e)}")
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
@router.delete("/delete-database")
|
| 557 |
+
async def delete_database(body: dict):
|
| 558 |
+
"""
|
| 559 |
+
Delete a cloned database.
|
| 560 |
+
WARNING: This permanently deletes the database and cannot be undone.
|
| 561 |
+
"""
|
| 562 |
+
try:
|
| 563 |
+
database_id = body.get("database_id")
|
| 564 |
+
if not database_id:
|
| 565 |
+
raise HTTPException(status_code=400, detail="database_id is required in request body")
|
| 566 |
+
|
| 567 |
+
logger.info(f"Delete database request for: {database_id}")
|
| 568 |
+
|
| 569 |
+
# Safety check: don't allow deleting the default database
|
| 570 |
+
if database_id == "default":
|
| 571 |
+
raise HTTPException(status_code=400, detail="Cannot delete the default database")
|
| 572 |
+
|
| 573 |
+
# Get database path
|
| 574 |
+
db_path = calendar_session_manager.get_db_path(database_id)
|
| 575 |
+
|
| 576 |
+
if not os.path.exists(db_path):
|
| 577 |
+
raise HTTPException(status_code=404, detail=f"Database with ID '{database_id}' not found")
|
| 578 |
+
|
| 579 |
+
# Close any existing sessions
|
| 580 |
+
calendar_session_manager.close_session(database_id)
|
| 581 |
+
|
| 582 |
+
# Get file size before deletion
|
| 583 |
+
db_size = os.path.getsize(db_path)
|
| 584 |
+
|
| 585 |
+
# Delete the database file
|
| 586 |
+
os.remove(db_path)
|
| 587 |
+
|
| 588 |
+
# Delete associated files (WAL, SHM, journal) if they exist
|
| 589 |
+
for ext in ['-wal', '-shm', '-journal']:
|
| 590 |
+
extra_file = db_path + ext
|
| 591 |
+
if os.path.exists(extra_file):
|
| 592 |
+
os.remove(extra_file)
|
| 593 |
+
logger.debug(f"Deleted {extra_file}")
|
| 594 |
+
|
| 595 |
+
# Also remove from seed_data table if exists
|
| 596 |
+
try:
|
| 597 |
+
seed_db_session = get_seed_session()
|
| 598 |
+
try:
|
| 599 |
+
seed_entry = seed_db_session.query(SeedData).filter(
|
| 600 |
+
SeedData.database_id == database_id
|
| 601 |
+
).first()
|
| 602 |
+
if seed_entry:
|
| 603 |
+
seed_db_session.delete(seed_entry)
|
| 604 |
+
seed_db_session.commit()
|
| 605 |
+
logger.info(f"Deleted seed data for database {database_id}")
|
| 606 |
+
finally:
|
| 607 |
+
seed_db_session.close()
|
| 608 |
+
except Exception as e:
|
| 609 |
+
logger.warning(f"Failed to delete seed data: {e}")
|
| 610 |
+
|
| 611 |
+
result = {
|
| 612 |
+
"success": True,
|
| 613 |
+
"message": "Database deleted successfully",
|
| 614 |
+
"database_id": database_id,
|
| 615 |
+
"deleted_size_bytes": db_size,
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
logger.info(f"Successfully deleted database {database_id}")
|
| 619 |
+
return result
|
| 620 |
+
|
| 621 |
+
except HTTPException:
|
| 622 |
+
raise
|
| 623 |
+
except Exception as e:
|
| 624 |
+
logger.error(f"Error deleting database: {e}")
|
| 625 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete database: {str(e)}")
|
server/apis/events/router.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Events API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Handles all 11 Events operations with exact Google API compatibility
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional, List
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Header, Query, status, Depends
|
| 9 |
+
from pydantic import ValidationError
|
| 10 |
+
from schemas.event import (
|
| 11 |
+
Event,
|
| 12 |
+
EventListResponse,
|
| 13 |
+
EventCreateRequest,
|
| 14 |
+
EventUpdateRequest,
|
| 15 |
+
EventMoveRequest,
|
| 16 |
+
EventQuickAddRequest,
|
| 17 |
+
EventInstancesResponse,
|
| 18 |
+
Channel,
|
| 19 |
+
EventWatchRequest,
|
| 20 |
+
OrderByEnum,
|
| 21 |
+
EventTypesEnum,
|
| 22 |
+
)
|
| 23 |
+
from schemas.import_event import (
|
| 24 |
+
EventImportRequest,
|
| 25 |
+
EventImportResponse,
|
| 26 |
+
EventImportQueryParams,
|
| 27 |
+
EventImportError,
|
| 28 |
+
)
|
| 29 |
+
from database.managers.event_manager import EventManager
|
| 30 |
+
from database.session_manager import CalendarSessionManager
|
| 31 |
+
from utils.validation import validate_request_colors
|
| 32 |
+
from middleware.auth import get_user_context
|
| 33 |
+
from database.managers.calendar_manager import CalendarManager
|
| 34 |
+
from apis.calendars.router import get_calendar_manager
|
| 35 |
+
from database.models.user import User
|
| 36 |
+
from database.session_utils import get_session
|
| 37 |
+
import re
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
router = APIRouter(prefix="/calendars/{calendarId}/events", tags=["events"])
|
| 42 |
+
|
| 43 |
+
# Initialize managers
|
| 44 |
+
session_manager = CalendarSessionManager()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_event_manager(database_id: str) -> EventManager:
|
| 48 |
+
"""Get event manager for the specified database"""
|
| 49 |
+
return EventManager(database_id)
|
| 50 |
+
|
| 51 |
+
def check_event_acl_permissions(
|
| 52 |
+
calendar_manager: CalendarManager,
|
| 53 |
+
user_id: str,
|
| 54 |
+
calendar_id: str,
|
| 55 |
+
allowed_roles: list[str],
|
| 56 |
+
operation: str = "access"
|
| 57 |
+
):
|
| 58 |
+
"""Check ACL permissions for event operations"""
|
| 59 |
+
calendar = calendar_manager.get_calendar_by_id(user_id, calendar_id, allowed_roles)
|
| 60 |
+
if not calendar:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 63 |
+
detail=f"User '{user_id}' lacks required roles {allowed_roles} for {operation} on calendar '{calendar_id}'"
|
| 64 |
+
)
|
| 65 |
+
return calendar
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("", response_model=EventListResponse)
|
| 69 |
+
async def list_events(
|
| 70 |
+
calendarId: str,
|
| 71 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 72 |
+
eventTypes: Optional[List[EventTypesEnum]] = Query(None, description="Event types to return. Acceptable values are: 'birthday' - Special all-day events with an annual recurrence, 'default' - Regular events, 'focusTime' - Focus time events, 'fromGmail' - Events from Gmail, 'outOfOffice' - Out of office events, 'workingLocation' - Working location events. Optional. Multiple event types can be provided using repeated parameter instances"),
|
| 73 |
+
iCalUID: Optional[str] = Query(None, description="Specifies an event ID in the iCalendar format to be provided in the response. Optional. Use this if you want to search for an event by its iCalendar ID. Mutually exclusive with q. Optional."),
|
| 74 |
+
maxAttendees: Optional[int] = Query(None, gt=0, description="The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional."),
|
| 75 |
+
maxResults: Optional[int] = Query(250, gt=0, le=2500, description="Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional."),
|
| 76 |
+
orderBy: Optional[OrderByEnum] = Query(OrderByEnum.START_TIME, description="The order of the events returned in the result. Optional. The default is an unspecified, stable order."),
|
| 77 |
+
pageToken: Optional[str] = Query(None, description="Token specifying which result page to return. Optional."),
|
| 78 |
+
privateExtendedProperty: Optional[str] = Query(None, description="Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints."),
|
| 79 |
+
q: Optional[str] = Query(None, description="Free text search terms to find events that match these terms in any field, except for extended properties. Optional."),
|
| 80 |
+
sharedExtendedProperty: Optional[str] = Query(None, description="Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints."),
|
| 81 |
+
showDeleted: Optional[bool] = Query(False, description="Whether to include deleted events (with status equals 'cancelled') in the result. Cancelled instances of recurring events (but not the underlying recurring event) will still be included if showDeleted and singleEvents are both False. If showDeleted and singleEvents are both True, only single instances of deleted events (but not the underlying recurring events) are returned. Optional. The default is False."),
|
| 82 |
+
showHiddenInvitations: Optional[bool] = Query(False, description="Whether to include hidden invitations in the result. Optional. The default is False."),
|
| 83 |
+
singleEvents: Optional[bool] = Query(False, description="Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves. Optional. The default is False."),
|
| 84 |
+
syncToken: Optional[str] = Query(None, description="Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All events deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False. There are several query parameters that cannot be specified together with nextSyncToken to ensure consistency of the client state."),
|
| 85 |
+
timeMax: Optional[str] = Query(None, description="Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time. Must be an RFC3339 timestamp with mandatory time zone offset, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z. Milliseconds may be provided but are ignored. If timeMin is set, timeMax must be greater than timeMin."),
|
| 86 |
+
timeMin: Optional[str] = Query(None, description="Lower bound (exclusive) for an event's end time to filter by. Optional. The default is not to filter by end time. Must be an RFC3339 timestamp with mandatory time zone offset, for example, 2011-06-03T10:00:00-07:00, 2011-06-03T10:00:00Z. Milliseconds may be provided but are ignored. If timeMax is set, timeMin must be less than timeMax."),
|
| 87 |
+
timeZone: Optional[str] = Query(None, description="Time zone used in the response. Optional. The default is the time zone of the calendar."),
|
| 88 |
+
updatedMin: Optional[str] = Query(None, description="Lower bound for an event's last modification time (as a RFC3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time."),
|
| 89 |
+
):
|
| 90 |
+
"""
|
| 91 |
+
Returns events on the specified calendar
|
| 92 |
+
ACL Required: reader, writer, or owner role
|
| 93 |
+
|
| 94 |
+
GET /calendars/{calendarId}/events
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
database_id, user_id = user_context
|
| 98 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 99 |
+
event_manager = get_event_manager(database_id)
|
| 100 |
+
|
| 101 |
+
# Check ACL permissions - require at least reader role
|
| 102 |
+
check_event_acl_permissions(
|
| 103 |
+
calendar_manager, user_id, calendarId,
|
| 104 |
+
["reader", "writer", "owner"], "list events"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Validate iCalUID
|
| 108 |
+
if iCalUID:
|
| 109 |
+
pattern = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
| 110 |
+
if not re.match(pattern, iCalUID):
|
| 111 |
+
raise ValueError("Invalid iCalUID format. Expected something like 'abcd123@google.com'.")
|
| 112 |
+
|
| 113 |
+
# Validate pageToken
|
| 114 |
+
if pageToken:
|
| 115 |
+
try:
|
| 116 |
+
page_token_int = int(pageToken)
|
| 117 |
+
if not page_token_int >= 0:
|
| 118 |
+
raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer")
|
| 119 |
+
except:
|
| 120 |
+
raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer")
|
| 121 |
+
|
| 122 |
+
# Convert eventTypes enum list to comma-separated string for the manager
|
| 123 |
+
event_types_str = None
|
| 124 |
+
if eventTypes:
|
| 125 |
+
event_types_str = ",".join([event_type.value for event_type in eventTypes])
|
| 126 |
+
|
| 127 |
+
response = event_manager.list_events(
|
| 128 |
+
user_id=user_id,
|
| 129 |
+
calendar_id=calendarId,
|
| 130 |
+
event_types=event_types_str,
|
| 131 |
+
ical_uid=iCalUID,
|
| 132 |
+
max_attendees=maxAttendees,
|
| 133 |
+
max_results=maxResults,
|
| 134 |
+
order_by=orderBy,
|
| 135 |
+
page_token=pageToken,
|
| 136 |
+
private_extended_property=privateExtendedProperty,
|
| 137 |
+
q=q,
|
| 138 |
+
shared_extended_property=sharedExtendedProperty,
|
| 139 |
+
show_deleted=showDeleted,
|
| 140 |
+
show_hidden_invitations=showHiddenInvitations,
|
| 141 |
+
single_events=singleEvents,
|
| 142 |
+
sync_token=syncToken,
|
| 143 |
+
time_max=timeMax,
|
| 144 |
+
time_min=timeMin,
|
| 145 |
+
time_zone=timeZone,
|
| 146 |
+
updated_min=updatedMin,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return response
|
| 150 |
+
|
| 151 |
+
except ValueError as e:
|
| 152 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail= f"{str(e)}")
|
| 153 |
+
except HTTPException:
|
| 154 |
+
raise
|
| 155 |
+
except PermissionError as e:
|
| 156 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"Error listing events for calendar {calendarId}: {e}")
|
| 159 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.post("", response_model=Event, status_code=status.HTTP_201_CREATED)
|
| 163 |
+
async def create_event(
|
| 164 |
+
calendarId: str,
|
| 165 |
+
event_request: EventCreateRequest,
|
| 166 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 167 |
+
conferenceDataVersion: Optional[int] = Query(
|
| 168 |
+
None,
|
| 169 |
+
ge=0,
|
| 170 |
+
le=1,
|
| 171 |
+
description="Version number of conference data supported by API client"
|
| 172 |
+
),
|
| 173 |
+
maxAttendees: Optional[int] = Query(
|
| 174 |
+
None,
|
| 175 |
+
gt=0,
|
| 176 |
+
description="The maximum number of attendees to include in the response"
|
| 177 |
+
),
|
| 178 |
+
sendUpdates: Optional[str] = Query("none", description="Whether to send notifications about the creation of the new event. Note that some emails might still be sent. The default is 'none'. Acceptable values are: 'all' (notifications sent to all guests), 'externalOnly' (notifications sent to non-Google Calendar guests only), 'none' (no notifications sent)"),
|
| 179 |
+
supportsAttachments: Optional[bool] = Query(
|
| 180 |
+
False,
|
| 181 |
+
description="Whether API client performing operation supports event attachments"
|
| 182 |
+
),
|
| 183 |
+
):
|
| 184 |
+
"""
|
| 185 |
+
Creates an event
|
| 186 |
+
ACL Required: writer or owner role
|
| 187 |
+
|
| 188 |
+
The sendUpdates parameter controls notification behavior:
|
| 189 |
+
- "all": Notifications are sent to all guests
|
| 190 |
+
- "externalOnly": Notifications are sent to non-Google Calendar guests only
|
| 191 |
+
- "none": No notifications are sent (default)
|
| 192 |
+
|
| 193 |
+
POST /calendars/{calendarId}/events
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
database_id, user_id = user_context
|
| 197 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 198 |
+
event_manager = get_event_manager(database_id)
|
| 199 |
+
|
| 200 |
+
# Check ACL permissions - require writer or owner role
|
| 201 |
+
check_event_acl_permissions(
|
| 202 |
+
calendar_manager, user_id, calendarId,
|
| 203 |
+
["writer", "owner"], "create events"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
# Validate sendUpdates parameter
|
| 207 |
+
if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]:
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 210 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Validate colorId if provided (events use event colors)
|
| 214 |
+
event_data = event_request.model_dump(exclude_none=True)
|
| 215 |
+
color_error = validate_request_colors(event_data, "event", database_id)
|
| 216 |
+
if color_error:
|
| 217 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error)
|
| 218 |
+
|
| 219 |
+
# Validate required fields per Google API specification
|
| 220 |
+
if not event_request.start or not event_request.end:
|
| 221 |
+
raise HTTPException(
|
| 222 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 223 |
+
detail="Both 'start' and 'end' fields are required"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Create query parameters object for event creation
|
| 227 |
+
query_params = {
|
| 228 |
+
'conferenceDataVersion': conferenceDataVersion,
|
| 229 |
+
'maxAttendees': maxAttendees,
|
| 230 |
+
'sendUpdates': sendUpdates,
|
| 231 |
+
'supportsAttachments': supportsAttachments
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
event = event_manager.create_event(user_id, calendarId, event_request, query_params)
|
| 235 |
+
|
| 236 |
+
if not event:
|
| 237 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to create event")
|
| 238 |
+
|
| 239 |
+
# Filter attendees based on maxAttendees parameter
|
| 240 |
+
if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees:
|
| 241 |
+
event.attendees = event.attendees[:maxAttendees]
|
| 242 |
+
event.attendeesOmitted = True
|
| 243 |
+
|
| 244 |
+
return event
|
| 245 |
+
|
| 246 |
+
except ValueError as verr:
|
| 247 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr))
|
| 248 |
+
except ValidationError as e:
|
| 249 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors())
|
| 250 |
+
except HTTPException:
|
| 251 |
+
raise
|
| 252 |
+
except PermissionError as e:
|
| 253 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 254 |
+
except Exception as e:
|
| 255 |
+
logger.error(f"Error creating event in calendar {calendarId}: {e}")
|
| 256 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@router.get("/{eventId}", response_model=Event)
|
| 260 |
+
async def get_event(
|
| 261 |
+
calendarId: str,
|
| 262 |
+
eventId: str,
|
| 263 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 264 |
+
timeZone: Optional[str] = Query(None, description="Time zone for returned times"),
|
| 265 |
+
maxAttendees: Optional[int] = Query(None, description="Maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional."),
|
| 266 |
+
):
|
| 267 |
+
"""
|
| 268 |
+
Returns an event
|
| 269 |
+
|
| 270 |
+
GET /calendars/{calendarId}/events/{eventId}
|
| 271 |
+
"""
|
| 272 |
+
try:
|
| 273 |
+
database_id, user_id = user_context
|
| 274 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 275 |
+
event_manager = get_event_manager(database_id)
|
| 276 |
+
|
| 277 |
+
# Check ACL permissions - require at least reader role
|
| 278 |
+
check_event_acl_permissions(
|
| 279 |
+
calendar_manager, user_id, calendarId,
|
| 280 |
+
["reader", "writer", "owner"], "read event"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
event = event_manager.get_event(user_id, calendarId, eventId, timeZone, maxAttendees)
|
| 284 |
+
|
| 285 |
+
if not event:
|
| 286 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 287 |
+
|
| 288 |
+
return event
|
| 289 |
+
|
| 290 |
+
except HTTPException:
|
| 291 |
+
raise
|
| 292 |
+
except PermissionError as e:
|
| 293 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 294 |
+
except Exception as e:
|
| 295 |
+
logger.error(f"Error getting event {eventId} from calendar {calendarId}: {e}")
|
| 296 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.patch("/{eventId}", response_model=Event)
|
| 300 |
+
async def patch_event(
|
| 301 |
+
calendarId: str,
|
| 302 |
+
eventId: str,
|
| 303 |
+
event_request: EventUpdateRequest,
|
| 304 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 305 |
+
conferenceDataVersion: Optional[int] = Query(
|
| 306 |
+
None,
|
| 307 |
+
ge=0,
|
| 308 |
+
le=1,
|
| 309 |
+
description="Version number of conference data supported by API client"
|
| 310 |
+
),
|
| 311 |
+
maxAttendees: Optional[int] = Query(
|
| 312 |
+
None,
|
| 313 |
+
gt=0,
|
| 314 |
+
description="The maximum number of attendees to include in the response"
|
| 315 |
+
),
|
| 316 |
+
sendUpdates: Optional[str] = Query("none", description="Guests who should receive notifications (all, externalOnly, none)"),
|
| 317 |
+
supportsAttachments: Optional[bool] = Query(
|
| 318 |
+
False,
|
| 319 |
+
description="Whether API client performing operation supports event attachments"
|
| 320 |
+
),
|
| 321 |
+
):
|
| 322 |
+
"""
|
| 323 |
+
Updates an event (partial update) following Google Calendar API v3 specification.
|
| 324 |
+
|
| 325 |
+
This method supports patch semantics and only updates the fields that are explicitly provided.
|
| 326 |
+
To do a full update, use PUT which replaces the entire event resource.
|
| 327 |
+
|
| 328 |
+
ACL Required: writer or owner role
|
| 329 |
+
|
| 330 |
+
PATCH /calendars/{calendarId}/events/{eventId}
|
| 331 |
+
"""
|
| 332 |
+
try:
|
| 333 |
+
database_id, user_id = user_context
|
| 334 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 335 |
+
event_manager = get_event_manager(database_id)
|
| 336 |
+
|
| 337 |
+
# Check ACL permissions - require writer or owner role
|
| 338 |
+
check_event_acl_permissions(
|
| 339 |
+
calendar_manager, user_id, calendarId,
|
| 340 |
+
["writer", "owner"], "update event"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# Validate sendUpdates parameter
|
| 344 |
+
if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]:
|
| 345 |
+
raise HTTPException(
|
| 346 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 347 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
# Get update data, excluding None values for partial update
|
| 351 |
+
update_data = event_request.model_dump(exclude_none=True)
|
| 352 |
+
|
| 353 |
+
if not update_data:
|
| 354 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for update")
|
| 355 |
+
|
| 356 |
+
# Validate colorId if provided (events use event colors)
|
| 357 |
+
color_error = validate_request_colors(update_data, "event", database_id)
|
| 358 |
+
if color_error:
|
| 359 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error)
|
| 360 |
+
|
| 361 |
+
# Create query parameters object for event update
|
| 362 |
+
query_params = {
|
| 363 |
+
'conferenceDataVersion': conferenceDataVersion,
|
| 364 |
+
'maxAttendees': maxAttendees,
|
| 365 |
+
'sendUpdates': sendUpdates,
|
| 366 |
+
'supportsAttachments': supportsAttachments
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
event = event_manager.update_event(user_id, calendarId, eventId, event_request, is_patch=True, query_params=query_params)
|
| 370 |
+
|
| 371 |
+
if not event:
|
| 372 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 373 |
+
|
| 374 |
+
# Filter attendees based on maxAttendees parameter
|
| 375 |
+
if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees:
|
| 376 |
+
event.attendees = event.attendees[:maxAttendees]
|
| 377 |
+
event.attendeesOmitted = True
|
| 378 |
+
|
| 379 |
+
return event
|
| 380 |
+
|
| 381 |
+
except ValueError as verr:
|
| 382 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr))
|
| 383 |
+
except ValidationError as e:
|
| 384 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors())
|
| 385 |
+
except HTTPException:
|
| 386 |
+
raise
|
| 387 |
+
except PermissionError as e:
|
| 388 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 389 |
+
except Exception as e:
|
| 390 |
+
logger.error(f"Error updating event {eventId} in calendar {calendarId}: {e}")
|
| 391 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
@router.put("/{eventId}", response_model=Event)
|
| 395 |
+
async def update_event(
|
| 396 |
+
calendarId: str,
|
| 397 |
+
eventId: str,
|
| 398 |
+
event_request: EventUpdateRequest,
|
| 399 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 400 |
+
conferenceDataVersion: Optional[int] = Query(
|
| 401 |
+
None,
|
| 402 |
+
ge=0,
|
| 403 |
+
le=1,
|
| 404 |
+
description="Version number of conference data supported by API client"
|
| 405 |
+
),
|
| 406 |
+
maxAttendees: Optional[int] = Query(
|
| 407 |
+
None,
|
| 408 |
+
gt=0,
|
| 409 |
+
description="The maximum number of attendees to include in the response"
|
| 410 |
+
),
|
| 411 |
+
sendUpdates: Optional[str] = Query("none", description="Guests who should receive notifications (all, externalOnly, none)"),
|
| 412 |
+
supportsAttachments: Optional[bool] = Query(
|
| 413 |
+
False,
|
| 414 |
+
description="Whether API client performing operation supports event attachments"
|
| 415 |
+
),
|
| 416 |
+
):
|
| 417 |
+
"""
|
| 418 |
+
Updates an event (full update) following Google Calendar API v3 specification.
|
| 419 |
+
|
| 420 |
+
This method does not support patch semantics and always updates the entire event resource.
|
| 421 |
+
To do a partial update, perform a get followed by an update using etags to ensure atomicity.
|
| 422 |
+
|
| 423 |
+
PUT /calendars/{calendarId}/events/{eventId}
|
| 424 |
+
"""
|
| 425 |
+
try:
|
| 426 |
+
database_id, user_id = user_context
|
| 427 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 428 |
+
event_manager = get_event_manager(database_id)
|
| 429 |
+
|
| 430 |
+
# Check ACL permissions - require writer or owner role
|
| 431 |
+
check_event_acl_permissions(
|
| 432 |
+
calendar_manager, user_id, calendarId,
|
| 433 |
+
["writer", "owner"], "update event"
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
# For PUT operations, validate that start and end are provided (required per Google API v3)
|
| 437 |
+
if not event_request.start or not event_request.end:
|
| 438 |
+
raise HTTPException(
|
| 439 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 440 |
+
detail="Both 'start' and 'end' fields are required for PUT operations"
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
# Validate sendUpdates parameter
|
| 444 |
+
if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]:
|
| 445 |
+
raise HTTPException(
|
| 446 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 447 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
# Ensure required fields have defaults for PUT requests
|
| 451 |
+
update_data = event_request.model_dump()
|
| 452 |
+
if not update_data.get("status"):
|
| 453 |
+
event_request.status = "confirmed"
|
| 454 |
+
if not update_data.get("visibility"):
|
| 455 |
+
event_request.visibility = "default"
|
| 456 |
+
|
| 457 |
+
# Validate colorId if provided (events use event colors)
|
| 458 |
+
color_error = validate_request_colors(update_data, "event", database_id)
|
| 459 |
+
if color_error:
|
| 460 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error)
|
| 461 |
+
|
| 462 |
+
# Create query parameters object for event update
|
| 463 |
+
query_params = {
|
| 464 |
+
'conferenceDataVersion': conferenceDataVersion,
|
| 465 |
+
'maxAttendees': maxAttendees,
|
| 466 |
+
'sendUpdates': sendUpdates,
|
| 467 |
+
'supportsAttachments': supportsAttachments
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
event = event_manager.update_event(user_id, calendarId, eventId, event_request, is_patch=False, query_params=query_params)
|
| 471 |
+
|
| 472 |
+
if not event:
|
| 473 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 474 |
+
|
| 475 |
+
# Filter attendees based on maxAttendees parameter
|
| 476 |
+
if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees:
|
| 477 |
+
event.attendees = event.attendees[:maxAttendees]
|
| 478 |
+
event.attendeesOmitted = True
|
| 479 |
+
|
| 480 |
+
return event
|
| 481 |
+
|
| 482 |
+
except ValueError as verr:
|
| 483 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr))
|
| 484 |
+
except ValidationError as e:
|
| 485 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors())
|
| 486 |
+
except HTTPException:
|
| 487 |
+
raise
|
| 488 |
+
except PermissionError as e:
|
| 489 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 490 |
+
except Exception as e:
|
| 491 |
+
logger.error(f"Error updating event {eventId} in calendar {calendarId}: {e}")
|
| 492 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@router.delete("/{eventId}", status_code=status.HTTP_204_NO_CONTENT)
|
| 496 |
+
async def delete_event(
|
| 497 |
+
calendarId: str,
|
| 498 |
+
eventId: str,
|
| 499 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 500 |
+
sendUpdates: Optional[str] = Query("all", description="Guests who should receive notifications. Acceptable values are: 'all' (notifications sent to all guests), 'externalOnly' (notifications sent to non-Google Calendar guests only), 'none' (no notifications sent)"),
|
| 501 |
+
):
|
| 502 |
+
"""
|
| 503 |
+
Deletes an event
|
| 504 |
+
ACL Required: writer or owner role (owner required for events created by others)
|
| 505 |
+
|
| 506 |
+
DELETE /calendars/{calendarId}/events/{eventId}
|
| 507 |
+
"""
|
| 508 |
+
try:
|
| 509 |
+
database_id, user_id = user_context
|
| 510 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 511 |
+
event_manager = get_event_manager(database_id)
|
| 512 |
+
|
| 513 |
+
# Validate sendUpdates parameter
|
| 514 |
+
if sendUpdates not in ["all", "externalOnly", "none"]:
|
| 515 |
+
raise HTTPException(
|
| 516 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 517 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
# Check ACL permissions - require writer or owner role
|
| 521 |
+
check_event_acl_permissions(
|
| 522 |
+
calendar_manager, user_id, calendarId,
|
| 523 |
+
["writer", "owner"], "delete event"
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
# Check if event exists first
|
| 527 |
+
existing_event = event_manager.get_event(user_id, calendarId, eventId)
|
| 528 |
+
if not existing_event:
|
| 529 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 530 |
+
|
| 531 |
+
# Delete event with sendUpdates parameter
|
| 532 |
+
success = event_manager.delete_event(user_id, calendarId, eventId, sendUpdates)
|
| 533 |
+
|
| 534 |
+
if not success:
|
| 535 |
+
raise HTTPException(status_code=500, detail="Failed to delete event")
|
| 536 |
+
|
| 537 |
+
# Return 204 No Content (no response body)
|
| 538 |
+
return None
|
| 539 |
+
|
| 540 |
+
except HTTPException:
|
| 541 |
+
raise
|
| 542 |
+
except PermissionError as e:
|
| 543 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 544 |
+
except Exception as e:
|
| 545 |
+
logger.error(f"Error deleting event {eventId} from calendar {calendarId}: {e}")
|
| 546 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@router.post("/{eventId}/move", response_model=Event)
|
| 550 |
+
async def move_event(
|
| 551 |
+
calendarId: str,
|
| 552 |
+
eventId: str,
|
| 553 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 554 |
+
destination: str = Query(..., description="Calendar identifier of the target calendar"),
|
| 555 |
+
sendUpdates: Optional[str] = Query("all", description="Guests who should receive notifications"),
|
| 556 |
+
):
|
| 557 |
+
"""
|
| 558 |
+
Moves an event to another calendar
|
| 559 |
+
ACL Required:
|
| 560 |
+
- writer or owner role on source calendar
|
| 561 |
+
- writer or owner role on destination calendar
|
| 562 |
+
|
| 563 |
+
POST /calendars/{calendarId}/events/{eventId}/move
|
| 564 |
+
"""
|
| 565 |
+
try:
|
| 566 |
+
database_id, user_id = user_context
|
| 567 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 568 |
+
event_manager = get_event_manager(database_id)
|
| 569 |
+
|
| 570 |
+
# Validate sendUpdates parameter
|
| 571 |
+
if sendUpdates not in ["all", "externalOnly", "none"]:
|
| 572 |
+
raise HTTPException(
|
| 573 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 574 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
# Check ACL permissions on source calendar
|
| 578 |
+
check_event_acl_permissions(
|
| 579 |
+
calendar_manager, user_id, calendarId,
|
| 580 |
+
["writer", "owner"], "move event from"
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
# Check if event exists and validate event type for move operation
|
| 584 |
+
existing_event = event_manager.get_event(user_id, calendarId, eventId)
|
| 585 |
+
if not existing_event:
|
| 586 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 587 |
+
|
| 588 |
+
# Only default events can be moved - validate event type
|
| 589 |
+
if existing_event.eventType and existing_event.eventType != "default":
|
| 590 |
+
raise HTTPException(
|
| 591 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 592 |
+
detail=f"Cannot move event of type '{existing_event.eventType}'. Only default events can be moved. Events of type 'birthday', 'focusTime', 'fromGmail', 'outOfOffice', and 'workingLocation' cannot be moved."
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
move_request = EventMoveRequest(
|
| 596 |
+
destination=destination, sendUpdates=sendUpdates
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
event = event_manager.move_event(user_id, calendarId, eventId, move_request)
|
| 600 |
+
|
| 601 |
+
if not event:
|
| 602 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}")
|
| 603 |
+
|
| 604 |
+
return event
|
| 605 |
+
|
| 606 |
+
except HTTPException:
|
| 607 |
+
raise
|
| 608 |
+
except ValueError as e:
|
| 609 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 610 |
+
except PermissionError as e:
|
| 611 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 612 |
+
except Exception as e:
|
| 613 |
+
logger.error(f"Error moving event {eventId} from calendar {calendarId}: {e}")
|
| 614 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
@router.post("/quickAdd", response_model=Event, status_code=status.HTTP_201_CREATED)
|
| 618 |
+
async def quick_add_event(
|
| 619 |
+
calendarId: str,
|
| 620 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 621 |
+
text: str = Query(..., description="The text describing the event to be created"),
|
| 622 |
+
sendUpdates: Optional[str] = Query("all", description="Guests who should receive notifications"),
|
| 623 |
+
):
|
| 624 |
+
"""
|
| 625 |
+
Creates an event based on a simple text string
|
| 626 |
+
|
| 627 |
+
POST /calendars/{calendarId}/events/quickAdd
|
| 628 |
+
"""
|
| 629 |
+
try:
|
| 630 |
+
database_id, user_id = user_context
|
| 631 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 632 |
+
event_manager = get_event_manager(database_id)
|
| 633 |
+
|
| 634 |
+
# Validate sendUpdates parameter
|
| 635 |
+
if sendUpdates not in ["all", "externalOnly", "none"]:
|
| 636 |
+
raise HTTPException(
|
| 637 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 638 |
+
detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'"
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
# Check ACL permissions - require writer or owner role
|
| 642 |
+
check_event_acl_permissions(
|
| 643 |
+
calendar_manager, user_id, calendarId,
|
| 644 |
+
["writer", "owner"], "quick add event"
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
quick_add_request = EventQuickAddRequest(
|
| 648 |
+
text=text, sendUpdates=sendUpdates
|
| 649 |
+
)
|
| 650 |
+
|
| 651 |
+
event = event_manager.quick_add_event(user_id, calendarId, quick_add_request)
|
| 652 |
+
|
| 653 |
+
return event
|
| 654 |
+
|
| 655 |
+
except HTTPException:
|
| 656 |
+
raise
|
| 657 |
+
except ValidationError as e:
|
| 658 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors())
|
| 659 |
+
except PermissionError as e:
|
| 660 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 661 |
+
except Exception as e:
|
| 662 |
+
logger.error(f"Error quick adding event to calendar {calendarId}: {e}")
|
| 663 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
@router.post("/import", response_model=Event, status_code=status.HTTP_201_CREATED)
|
| 667 |
+
async def import_event(
|
| 668 |
+
calendarId: str,
|
| 669 |
+
event_request: EventImportRequest,
|
| 670 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 671 |
+
conferenceDataVersion: Optional[int] = Query(
|
| 672 |
+
None,
|
| 673 |
+
ge=0,
|
| 674 |
+
le=1,
|
| 675 |
+
description="Version number of conference data supported by API client"
|
| 676 |
+
),
|
| 677 |
+
supportsAttachments: Optional[bool] = Query(
|
| 678 |
+
False,
|
| 679 |
+
description="Whether API client performing operation supports event attachments"
|
| 680 |
+
),
|
| 681 |
+
):
|
| 682 |
+
"""
|
| 683 |
+
Imports an event. This operation is used to add a private copy of an
|
| 684 |
+
existing event to a calendar.
|
| 685 |
+
|
| 686 |
+
Requires authorization with at least one of the following scopes:
|
| 687 |
+
- https://www.googleapis.com/auth/calendar
|
| 688 |
+
- https://www.googleapis.com/auth/calendar.events
|
| 689 |
+
- https://www.googleapis.com/auth/calendar.app.created
|
| 690 |
+
- https://www.googleapis.com/auth/calendar.events.owned
|
| 691 |
+
|
| 692 |
+
POST /calendars/{calendarId}/events/import
|
| 693 |
+
"""
|
| 694 |
+
try:
|
| 695 |
+
database_id, user_id = user_context
|
| 696 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 697 |
+
event_manager = get_event_manager(database_id)
|
| 698 |
+
|
| 699 |
+
# Check ACL permissions - require writer or owner role
|
| 700 |
+
check_event_acl_permissions(
|
| 701 |
+
calendar_manager, user_id, calendarId,
|
| 702 |
+
["writer", "owner"], "import event"
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
# Validate required fields
|
| 706 |
+
if not event_request.start or not event_request.end:
|
| 707 |
+
raise HTTPException(
|
| 708 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 709 |
+
detail="Both 'start' and 'end' fields are required for event import"
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
# Validate iCalUID
|
| 713 |
+
if not event_request.iCalUID:
|
| 714 |
+
raise ValueError("iCalUID is required field")
|
| 715 |
+
|
| 716 |
+
# Validate attendees email addresses exist in database
|
| 717 |
+
if event_request.attendees:
|
| 718 |
+
session = get_session(database_id)
|
| 719 |
+
try:
|
| 720 |
+
for attendee in event_request.attendees:
|
| 721 |
+
if attendee.email:
|
| 722 |
+
user = session.query(User).filter(User.email == attendee.email).first()
|
| 723 |
+
if not user:
|
| 724 |
+
raise HTTPException(
|
| 725 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 726 |
+
detail=f"Attendee email '{attendee.email}' not found in database. All attendee emails must be valid and present in the system."
|
| 727 |
+
)
|
| 728 |
+
finally:
|
| 729 |
+
session.close()
|
| 730 |
+
|
| 731 |
+
# Validate colorId if provided (events use event colors)
|
| 732 |
+
event_data = event_request.model_dump(exclude_none=True)
|
| 733 |
+
color_error = validate_request_colors(event_data, "event", database_id)
|
| 734 |
+
if color_error:
|
| 735 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error)
|
| 736 |
+
|
| 737 |
+
# Create query params object
|
| 738 |
+
query_params = EventImportQueryParams(
|
| 739 |
+
conferenceDataVersion=conferenceDataVersion,
|
| 740 |
+
supportsAttachments=supportsAttachments
|
| 741 |
+
)
|
| 742 |
+
# Import the event as a private copy
|
| 743 |
+
imported_event = event_manager.import_event(
|
| 744 |
+
user_id=user_id,
|
| 745 |
+
calendar_id=calendarId,
|
| 746 |
+
event_request=event_request,
|
| 747 |
+
query_params=query_params
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
if not imported_event:
|
| 751 |
+
raise HTTPException(
|
| 752 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 753 |
+
detail="Failed to import event"
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
return imported_event
|
| 757 |
+
|
| 758 |
+
except HTTPException:
|
| 759 |
+
raise
|
| 760 |
+
except ValidationError as e:
|
| 761 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors())
|
| 762 |
+
except ValueError as e:
|
| 763 |
+
logger.error(f"Validation error importing event to calendar {calendarId}: {e}")
|
| 764 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 765 |
+
except PermissionError as e:
|
| 766 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 767 |
+
except Exception as e:
|
| 768 |
+
logger.error(f"Error importing event to calendar {calendarId}: {e}")
|
| 769 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
@router.get("/{eventId}/instances", response_model=EventInstancesResponse)
|
| 773 |
+
async def get_event_instances(
|
| 774 |
+
calendarId: str,
|
| 775 |
+
eventId: str,
|
| 776 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 777 |
+
maxAttendees: Optional[int] = Query(None, description="The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional."),
|
| 778 |
+
maxResults: Optional[int] = Query(250, lt=2500, description="Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.", gt=0, le=2500),
|
| 779 |
+
originalStart: Optional[str] = Query(None, description="The original start time of the instance in the result. Optional."),
|
| 780 |
+
pageToken: Optional[str] = Query(None, description="Token specifying which result page to return. Optional."),
|
| 781 |
+
showDeleted: Optional[bool] = Query(False, description="Whether to include deleted events (with status equals 'cancelled') in the result. Cancelled instances of recurring events will still be included if singleEvents is False. Optional."),
|
| 782 |
+
timeMin: Optional[str] = Query(None, description="Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time. Must be an RFC3339 timestamp with mandatory time zone offset."),
|
| 783 |
+
timeMax: Optional[str] = Query(None, description="Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time. Must be an RFC3339 timestamp with mandatory time zone offset."),
|
| 784 |
+
timeZone: Optional[str] = Query(None, description="Time zone used in the response. Optional. The default is the time zone of the calendar."),
|
| 785 |
+
):
|
| 786 |
+
"""
|
| 787 |
+
Returns instances of the specified recurring event
|
| 788 |
+
|
| 789 |
+
GET /calendars/{calendarId}/events/{eventId}/instances
|
| 790 |
+
"""
|
| 791 |
+
try:
|
| 792 |
+
database_id, user_id = user_context
|
| 793 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 794 |
+
event_manager = get_event_manager(database_id)
|
| 795 |
+
|
| 796 |
+
# Check ACL permissions - require at least reader role
|
| 797 |
+
check_event_acl_permissions(
|
| 798 |
+
calendar_manager, user_id, calendarId,
|
| 799 |
+
["reader", "writer", "owner"], "read event instances"
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
# Validate pageToken
|
| 803 |
+
if pageToken:
|
| 804 |
+
try:
|
| 805 |
+
page_token_int = int(pageToken)
|
| 806 |
+
if not page_token_int >= 0:
|
| 807 |
+
raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer")
|
| 808 |
+
except:
|
| 809 |
+
raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer")
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
response = event_manager.get_event_instances(
|
| 813 |
+
user_id=user_id,
|
| 814 |
+
calendar_id=calendarId,
|
| 815 |
+
event_id=eventId,
|
| 816 |
+
max_attendees=maxAttendees,
|
| 817 |
+
max_results=maxResults,
|
| 818 |
+
original_start=originalStart,
|
| 819 |
+
page_token=pageToken,
|
| 820 |
+
show_deleted=showDeleted,
|
| 821 |
+
time_min=timeMin,
|
| 822 |
+
time_max=timeMax,
|
| 823 |
+
time_zone=timeZone,
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
return response
|
| 827 |
+
|
| 828 |
+
except HTTPException:
|
| 829 |
+
raise
|
| 830 |
+
except ValueError as e:
|
| 831 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 832 |
+
except PermissionError as e:
|
| 833 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 834 |
+
except Exception as e:
|
| 835 |
+
logger.error(f"Error getting instances for event {eventId}: {e}")
|
| 836 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
@router.post("/watch", response_model=Channel)
|
| 840 |
+
async def watch_events(
|
| 841 |
+
calendarId: str,
|
| 842 |
+
watch_request: EventWatchRequest,
|
| 843 |
+
user_context: tuple[str, str] = Depends(get_user_context),
|
| 844 |
+
eventTypes: Optional[str] = Query(None, description="Event types of resources to watch. Optional. This parameter can be repeated multiple times to watch resources of different types. If unset, returns all event types. Acceptable values are: 'birthday' - Special all-day events with an annual recurrence, 'default' - Regular events, 'focusTime' - Focus time events, 'fromGmail' - Events from Gmail, 'outOfOffice' - Out of office events, 'workingLocation' - Working location events.")
|
| 845 |
+
):
|
| 846 |
+
"""
|
| 847 |
+
Watch for changes to Events resources
|
| 848 |
+
|
| 849 |
+
POST /calendars/{calendarId}/events/watch
|
| 850 |
+
"""
|
| 851 |
+
try:
|
| 852 |
+
database_id, user_id = user_context
|
| 853 |
+
calendar_manager = get_calendar_manager(database_id)
|
| 854 |
+
event_manager = get_event_manager(database_id)
|
| 855 |
+
|
| 856 |
+
if eventTypes and eventTypes not in ["birthday", "default", "focusTime", "fromGmail", "outOfOffice", "workingLocation"]:
|
| 857 |
+
raise HTTPException(
|
| 858 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 859 |
+
detail=f"Invalid eventType value '{eventTypes}'. Acceptable values are: 'birthday', 'default', 'focusTime', 'fromGmail', 'outOfOffice', 'workingLocation'"
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
# Validate user exists in this database (ensures ownership context)
|
| 863 |
+
from database.session_utils import get_session
|
| 864 |
+
from database.models.user import User
|
| 865 |
+
session = get_session(database_id)
|
| 866 |
+
|
| 867 |
+
try:
|
| 868 |
+
user_row = session.query(User).filter(User.user_id == user_id).first()
|
| 869 |
+
if not user_row:
|
| 870 |
+
raise HTTPException(status_code=404, detail=f"User not found: {user_id}")
|
| 871 |
+
finally:
|
| 872 |
+
session.close()
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
# Check ACL permissions - require at least reader role
|
| 876 |
+
check_event_acl_permissions(
|
| 877 |
+
calendar_manager, user_id, calendarId,
|
| 878 |
+
["reader", "writer", "owner"], "watch events"
|
| 879 |
+
)
|
| 880 |
+
|
| 881 |
+
# Set up watch channel with event types filter
|
| 882 |
+
channel = event_manager.watch_events(user_id, calendarId, watch_request, eventTypes)
|
| 883 |
+
return channel
|
| 884 |
+
|
| 885 |
+
except ValueError as e:
|
| 886 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(e)}")
|
| 887 |
+
except HTTPException:
|
| 888 |
+
raise
|
| 889 |
+
except PermissionError as e:
|
| 890 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
| 891 |
+
except Exception as e:
|
| 892 |
+
logger.error(f"Error setting up events watch for calendar {calendarId}: {e}")
|
| 893 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
server/apis/freebusy/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FreeBusy API module
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .router import router
|
| 6 |
+
|
| 7 |
+
__all__ = ["router"]
|
server/apis/freebusy/router.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FreeBusy API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Handles FreeBusy query operations with exact Google API compatibility
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Header, Query, status, Depends
|
| 9 |
+
from pydantic import ValidationError
|
| 10 |
+
from schemas.freebusy import (
|
| 11 |
+
FreeBusyQueryRequest,
|
| 12 |
+
FreeBusyQueryResponse
|
| 13 |
+
)
|
| 14 |
+
from database.managers.freebusy_manager import FreeBusyManager
|
| 15 |
+
from database.session_manager import CalendarSessionManager
|
| 16 |
+
from middleware.auth import get_user_context
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/freebusy", tags=["freebusy"])
|
| 21 |
+
|
| 22 |
+
# Initialize managers
|
| 23 |
+
session_manager = CalendarSessionManager()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_freebusy_manager(database_id: str) -> FreeBusyManager:
|
| 27 |
+
"""Get freebusy manager for the specified database"""
|
| 28 |
+
return FreeBusyManager(database_id)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/query", response_model=FreeBusyQueryResponse)
|
| 32 |
+
async def query_freebusy(
|
| 33 |
+
request: FreeBusyQueryRequest,
|
| 34 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Returns free/busy information for a set of calendars
|
| 38 |
+
|
| 39 |
+
POST /freeBusy
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
database_id, user_id = user_context
|
| 43 |
+
freebusy_manager = get_freebusy_manager(database_id)
|
| 44 |
+
|
| 45 |
+
# Validate request
|
| 46 |
+
if not request.items:
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 49 |
+
detail="At least one calendar item is required"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if len(request.items) > 50:
|
| 53 |
+
raise HTTPException(
|
| 54 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 55 |
+
detail="Maximum 50 calendars allowed per query"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
response = freebusy_manager.query_freebusy(user_id, request)
|
| 59 |
+
|
| 60 |
+
return response
|
| 61 |
+
|
| 62 |
+
except ValidationError as e:
|
| 63 |
+
# Handle pydantic validation errors (timezone, schema validation)
|
| 64 |
+
logger.error(f"Schema validation error in FreeBusy query: {e}")
|
| 65 |
+
error_messages = []
|
| 66 |
+
for error in e.errors():
|
| 67 |
+
field = " -> ".join(str(loc) for loc in error["loc"])
|
| 68 |
+
msg = error["msg"]
|
| 69 |
+
error_messages.append(f"{field}: {msg}")
|
| 70 |
+
detail = "Validation failed: " + "; ".join(error_messages)
|
| 71 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
| 72 |
+
except ValueError as e:
|
| 73 |
+
# Handle business logic validation errors (calendar ID existence, time range, etc.)
|
| 74 |
+
logger.error(f"Business validation error in FreeBusy query: {e}")
|
| 75 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(f"Unexpected error processing FreeBusy query: {e}")
|
| 78 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 79 |
+
detail="Internal server error occurred while processing the request")
|
| 80 |
+
|
server/apis/mcp/__init__.py
ADDED
|
File without changes
|
server/apis/mcp/router.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP (Model Context Protocol) API Router
|
| 3 |
+
Handles MCP protocol messages ONLY for Calendar API
|
| 4 |
+
Database management APIs are in apis.database_router
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from fastapi import APIRouter, Request, Response
|
| 9 |
+
from handlers.mcp_handler import handle_mcp_request
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/mcp")
|
| 17 |
+
async def handle_mcp(request: Request):
|
| 18 |
+
"""Handle MCP protocol messages"""
|
| 19 |
+
response = await handle_mcp_request(request)
|
| 20 |
+
if response is None:
|
| 21 |
+
# For notifications, return 204 No Content
|
| 22 |
+
return Response(content="", status_code=204, headers={"Content-Length": "0"})
|
| 23 |
+
|
| 24 |
+
# Check if response is already a dict or needs to be converted
|
| 25 |
+
if hasattr(response, "model_dump"):
|
| 26 |
+
return response.model_dump()
|
| 27 |
+
else:
|
| 28 |
+
return response
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# MCP protocol endpoint only - all other routes are at root level (no /api prefix)
|
server/apis/settings/router.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calendar Settings API endpoints following Google Calendar API v3 structure
|
| 3 |
+
Handles GET and LIST operations for calendar settings
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from schemas.settings import SettingItem, SettingsListResponse, SettingsWatchRequest, Channel
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 9 |
+
from database.managers.settings_manager import SettingManager
|
| 10 |
+
from database.session_manager import CalendarSessionManager
|
| 11 |
+
from middleware.auth import get_user_context
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/settings", tags=["settings"])
|
| 16 |
+
|
| 17 |
+
# Initialize managers
|
| 18 |
+
session_manager = CalendarSessionManager()
|
| 19 |
+
|
| 20 |
+
def get_setting_manager(database_id: str) -> SettingManager:
|
| 21 |
+
return SettingManager(database_id)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("", response_model=SettingsListResponse, operation_id="list_settings")
|
| 25 |
+
async def list_settings(user_context: tuple[str, str] = Depends(get_user_context)):
|
| 26 |
+
"""
|
| 27 |
+
Lists all user-visible settings
|
| 28 |
+
|
| 29 |
+
GET /settings
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
database_id, user_id = user_context
|
| 33 |
+
manager = get_setting_manager(database_id)
|
| 34 |
+
|
| 35 |
+
# Pass user_id to manager
|
| 36 |
+
settings = manager.list_settings(user_id=user_id)
|
| 37 |
+
|
| 38 |
+
return SettingsListResponse(items=settings, etag="settings-collection-etag")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Error listing settings: {e}")
|
| 41 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/{settingId}", response_model=SettingItem, operation_id="get_settings")
|
| 45 |
+
async def get_settings(
|
| 46 |
+
settingId: str,
|
| 47 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 48 |
+
):
|
| 49 |
+
"""
|
| 50 |
+
Returns a setting for the user
|
| 51 |
+
|
| 52 |
+
GET /settings/{settingId}
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
database_id, user_id = user_context
|
| 56 |
+
manager = get_setting_manager(database_id)
|
| 57 |
+
|
| 58 |
+
logger.info(f"Fetching setting {settingId} for user {user_id}")
|
| 59 |
+
setting = manager.get_setting_by_id(settingId, user_id=user_id)
|
| 60 |
+
|
| 61 |
+
if not setting:
|
| 62 |
+
raise HTTPException(status_code=404, detail=f"Setting {settingId} not found for user {user_id}")
|
| 63 |
+
return setting
|
| 64 |
+
except HTTPException:
|
| 65 |
+
raise
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Error getting setting {settingId}: {e}")
|
| 68 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.post("/watch", response_model=Channel, operation_id="watch_settings")
|
| 72 |
+
async def watch_settings(
|
| 73 |
+
watch_request: SettingsWatchRequest,
|
| 74 |
+
user_context: tuple[str, str] = Depends(get_user_context)
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Watch for changes to settings
|
| 78 |
+
|
| 79 |
+
POST /settings/watch
|
| 80 |
+
|
| 81 |
+
Sets up a notification channel to receive updates when settings change.
|
| 82 |
+
Following the Google Calendar API v3 pattern.
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
database_id, user_id = user_context
|
| 86 |
+
manager = get_setting_manager(database_id)
|
| 87 |
+
|
| 88 |
+
logger.info(f"Setting up settings watch for user {user_id} with channel {watch_request.id}")
|
| 89 |
+
|
| 90 |
+
# Validate the watch request
|
| 91 |
+
if not watch_request.address:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=400,
|
| 94 |
+
detail="Webhook address is required"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if not watch_request.id:
|
| 98 |
+
raise HTTPException(
|
| 99 |
+
status_code=400,
|
| 100 |
+
detail="Channel ID is required"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Create the watch channel
|
| 104 |
+
channel = manager.watch_settings(watch_request, user_id)
|
| 105 |
+
|
| 106 |
+
logger.info(f"Successfully created settings watch channel {watch_request.id} for user {user_id}")
|
| 107 |
+
return channel
|
| 108 |
+
|
| 109 |
+
except ValueError as verr:
|
| 110 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}")
|
| 111 |
+
except HTTPException:
|
| 112 |
+
raise
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"Error setting up settings watch: {e}")
|
| 115 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 116 |
+
|
server/apis/users/router.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User API endpoints for user management operations
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from fastapi import APIRouter, HTTPException, status, Header, Depends
|
| 7 |
+
from database.managers.user_manager import UserManager
|
| 8 |
+
from middleware.auth import authenticate_user_with_token
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/users", tags=["users"])
|
| 13 |
+
|
| 14 |
+
# Separate router for API endpoints that don't follow the "/users" pattern
|
| 15 |
+
api_router = APIRouter(tags=["user-info"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_user_manager(database_id: str) -> UserManager:
|
| 19 |
+
"""Get user manager for the specified database"""
|
| 20 |
+
return UserManager(database_id)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/email/{email}")
|
| 24 |
+
async def get_user_by_email(email: str, x_database_id: str = Header(alias="x-database-id")):
|
| 25 |
+
"""
|
| 26 |
+
Get user details by email address
|
| 27 |
+
|
| 28 |
+
GET /users/email/{email}
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
if not x_database_id:
|
| 32 |
+
raise HTTPException(
|
| 33 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing required header: x-database-id"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
user_manager = get_user_manager(x_database_id)
|
| 37 |
+
user_info = user_manager.get_user_by_email(email)
|
| 38 |
+
|
| 39 |
+
if not user_info:
|
| 40 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User not found with email: {email}")
|
| 41 |
+
|
| 42 |
+
return user_info
|
| 43 |
+
|
| 44 |
+
except HTTPException:
|
| 45 |
+
raise
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.error(f"Error getting user by email {email}: {e}")
|
| 48 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@api_router.get("/api/user-info")
|
| 52 |
+
async def get_authenticated_user_info(auth_context: tuple[str, str] = Depends(authenticate_user_with_token)):
|
| 53 |
+
"""
|
| 54 |
+
Get authenticated user information using access token
|
| 55 |
+
|
| 56 |
+
GET /api/user-info
|
| 57 |
+
|
| 58 |
+
Headers:
|
| 59 |
+
- x-database-id: Database identifier
|
| 60 |
+
- x-access-token: User access token
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
- user_id: User identifier
|
| 64 |
+
- name: User's display name
|
| 65 |
+
- email: User's email address
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
database_id, user_id = auth_context
|
| 69 |
+
|
| 70 |
+
user_manager = get_user_manager(database_id)
|
| 71 |
+
user_info = user_manager.get_user_by_id(user_id)
|
| 72 |
+
|
| 73 |
+
if not user_info:
|
| 74 |
+
raise HTTPException(
|
| 75 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 76 |
+
detail="User not found"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Return only the required fields: user_id, name, email
|
| 80 |
+
response = {
|
| 81 |
+
"user_id": user_info["id"],
|
| 82 |
+
"name": user_info["name"],
|
| 83 |
+
"email": user_info["email"]
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return response
|
| 87 |
+
|
| 88 |
+
except HTTPException:
|
| 89 |
+
raise
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Error getting authenticated user info: {e}")
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 94 |
+
detail=f"Internal server error: {str(e)}"
|
| 95 |
+
)
|
server/app.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
FastAPI application entry point for the Calendar environment.
|
| 9 |
+
|
| 10 |
+
This module re-exports the existing FastAPI app from main.py and provides
|
| 11 |
+
the standard server entry point used by OpenEnv tooling.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
SERVER_DIR = Path(__file__).resolve().parent
|
| 21 |
+
if str(SERVER_DIR) not in sys.path:
|
| 22 |
+
sys.path.insert(0, str(SERVER_DIR))
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
REPO_ROOT = SERVER_DIR.parents[3]
|
| 26 |
+
except IndexError:
|
| 27 |
+
REPO_ROOT = None
|
| 28 |
+
|
| 29 |
+
if REPO_ROOT is not None:
|
| 30 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 31 |
+
if SRC_DIR.is_dir() and str(SRC_DIR) not in sys.path:
|
| 32 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
from . import main as _main
|
| 36 |
+
except ImportError:
|
| 37 |
+
import importlib
|
| 38 |
+
|
| 39 |
+
_main = importlib.import_module("main")
|
| 40 |
+
|
| 41 |
+
app = _main.app
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main(host: str = "0.0.0.0", port: int | None = None):
|
| 45 |
+
"""Run the Calendar environment server with uvicorn."""
|
| 46 |
+
|
| 47 |
+
import uvicorn
|
| 48 |
+
|
| 49 |
+
if port is None:
|
| 50 |
+
port = int(os.getenv("API_PORT", "8004"))
|
| 51 |
+
|
| 52 |
+
uvicorn.run(app, host=host, port=port)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
server/calendar_environment.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 2 |
+
# All rights reserved.
|
| 3 |
+
#
|
| 4 |
+
# This source code is licensed under the BSD-style license found in the
|
| 5 |
+
# LICENSE file in the root directory of this source tree.
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
Calendar Environment wrapper.
|
| 9 |
+
|
| 10 |
+
This file provides the standard environment class name expected in OpenEnv
|
| 11 |
+
layouts while reusing the existing MCP environment implementation.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from .openenv_wrapper.mcp_env_environment import MCPEnvironment
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CalendarEnvironment(MCPEnvironment):
|
| 18 |
+
"""Calendar environment backed by MCP tools."""
|
| 19 |
+
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
__all__ = ["CalendarEnvironment"]
|
server/calendar_mcp/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP (Model Context Protocol) module for Calendar API
|
| 3 |
+
"""
|
server/calendar_mcp/tools/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP Tools Module - Calendar API Tools
|
| 3 |
+
|
| 4 |
+
This module aggregates all MCP tools for the Calendar API endpoints.
|
| 5 |
+
Includes calendar management tools:
|
| 6 |
+
- Calendars (create, read, update, delete, clear, list)
|
| 7 |
+
- CalendarList (list, get, insert, patch, put, delete, watch)
|
| 8 |
+
- Events (list, create, get, patch, update, delete, move, quickAdd, import, instances, watch)
|
| 9 |
+
- Colors (get color definitions)
|
| 10 |
+
- FreeBusy (query)
|
| 11 |
+
- ACL (access control list management)
|
| 12 |
+
- Users (user management)
|
| 13 |
+
- Settings (calendar settings)
|
| 14 |
+
|
| 15 |
+
Each tool corresponds to Calendar API endpoints
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# Import calendar tool categories
|
| 19 |
+
from .acl import ACL_TOOLS
|
| 20 |
+
from .calendars import CALENDARS_TOOLS
|
| 21 |
+
from .calendar_list import CALENDAR_LIST_TOOLS
|
| 22 |
+
from .events import EVENTS_TOOLS
|
| 23 |
+
from .colors import COLORS_TOOLS
|
| 24 |
+
from .users import USERS_TOOLS
|
| 25 |
+
from .settings import SETTINGS_TOOLS
|
| 26 |
+
from .freebusy import FREEBUSY_TOOLS
|
| 27 |
+
|
| 28 |
+
# Combine all tools into the main MCP_TOOLS list
|
| 29 |
+
MCP_TOOLS = []
|
| 30 |
+
MCP_TOOLS.extend(CALENDARS_TOOLS)
|
| 31 |
+
MCP_TOOLS.extend(CALENDAR_LIST_TOOLS)
|
| 32 |
+
MCP_TOOLS.extend(EVENTS_TOOLS)
|
| 33 |
+
MCP_TOOLS.extend(COLORS_TOOLS)
|
| 34 |
+
MCP_TOOLS.extend(USERS_TOOLS)
|
| 35 |
+
MCP_TOOLS.extend(SETTINGS_TOOLS)
|
| 36 |
+
MCP_TOOLS.extend(ACL_TOOLS)
|
| 37 |
+
MCP_TOOLS.extend(FREEBUSY_TOOLS)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
print(f"📦 MCP Tools Module Loaded: {len(MCP_TOOLS)} calendar API tools across 8 modules")
|
server/calendar_mcp/tools/acl.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ACL Tools Module
|
| 3 |
+
|
| 4 |
+
This module contains tools related to ACL.
|
| 5 |
+
Follows Google Calendar API v3 structure for ACL operations.
|
| 6 |
+
"""
|
| 7 |
+
ACL_TOOLS = [
|
| 8 |
+
{
|
| 9 |
+
"name": "get_acl_rule",
|
| 10 |
+
"description": """Retrieve an access control rule by ID.
|
| 11 |
+
|
| 12 |
+
Fetches the ACL rule for a calendar by rule ID.
|
| 13 |
+
Follows the structure of Google Calendar API v3 `/calendars/{calendarId}/acl/{ruleId}`.
|
| 14 |
+
|
| 15 |
+
Required Parameters:
|
| 16 |
+
- calendarId: Calendar identifier
|
| 17 |
+
- ruleId: ACL rule identifier
|
| 18 |
+
|
| 19 |
+
Response Structure:
|
| 20 |
+
- kind: "calendar#aclRule"
|
| 21 |
+
- etag: ETag
|
| 22 |
+
- id: ACL rule ID
|
| 23 |
+
- scope: { type, value }
|
| 24 |
+
- role: ACL role (e.g., "reader", "writer")
|
| 25 |
+
|
| 26 |
+
Status Codes:
|
| 27 |
+
- 200: Success
|
| 28 |
+
- 404: Not Found
|
| 29 |
+
- 500: Internal Server Error
|
| 30 |
+
""",
|
| 31 |
+
"inputSchema": {
|
| 32 |
+
"type": "object",
|
| 33 |
+
"properties": {
|
| 34 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 35 |
+
"ruleId": {"type": "string", "minLength": 1}
|
| 36 |
+
},
|
| 37 |
+
"required": ["calendarId", "ruleId"]
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"name": "list_acl_rules",
|
| 42 |
+
"description": """List ACL rules for a calendar with pagination and filtering support.
|
| 43 |
+
|
| 44 |
+
Returns access control rules for the given calendar with support for pagination and incremental synchronization.
|
| 45 |
+
Follows the structure of Google Calendar API v3 `/calendars/{calendarId}/acl`.
|
| 46 |
+
|
| 47 |
+
Required Parameters:
|
| 48 |
+
- calendarId: Calendar identifier
|
| 49 |
+
|
| 50 |
+
Optional Parameters:
|
| 51 |
+
- maxResults: Maximum number of entries returned on one result page (1-250, default 100)
|
| 52 |
+
- pageToken: Token specifying which result page to return
|
| 53 |
+
- showDeleted: Whether to include deleted ACLs (role="none") in the result (default False)
|
| 54 |
+
- syncToken: Token for incremental synchronization, returns only entries changed since token
|
| 55 |
+
|
| 56 |
+
Response Structure:
|
| 57 |
+
- kind: "calendar#acl"
|
| 58 |
+
- etag: ETag of the ACL collection
|
| 59 |
+
- items: Array of ACL rules
|
| 60 |
+
- nextPageToken: Token for next page (if more results available)
|
| 61 |
+
- nextSyncToken: Token for next sync operation
|
| 62 |
+
|
| 63 |
+
Synchronization:
|
| 64 |
+
- When syncToken is provided, showDeleted is automatically set to True
|
| 65 |
+
- Deleted ACLs are always included in sync responses
|
| 66 |
+
- If syncToken expires, server responds with 410 GONE
|
| 67 |
+
|
| 68 |
+
Status Codes:
|
| 69 |
+
- 200: Success
|
| 70 |
+
- 400: Bad Request (invalid parameters)
|
| 71 |
+
- 410: Gone (sync token expired)
|
| 72 |
+
- 500: Internal Server Error
|
| 73 |
+
""",
|
| 74 |
+
"inputSchema": {
|
| 75 |
+
"type": "object",
|
| 76 |
+
"properties": {
|
| 77 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 78 |
+
"maxResults": {
|
| 79 |
+
"type": "integer",
|
| 80 |
+
"minimum": 1,
|
| 81 |
+
"maximum": 250,
|
| 82 |
+
"default": 100,
|
| 83 |
+
"description": "Maximum number of entries returned on one result page"
|
| 84 |
+
},
|
| 85 |
+
"pageToken": {
|
| 86 |
+
"type": "string",
|
| 87 |
+
"description": "Token specifying which result page to return"
|
| 88 |
+
},
|
| 89 |
+
"showDeleted": {
|
| 90 |
+
"type": "boolean",
|
| 91 |
+
"default": False,
|
| 92 |
+
"description": "Whether to include deleted ACLs in the result"
|
| 93 |
+
},
|
| 94 |
+
"syncToken": {
|
| 95 |
+
"type": "string",
|
| 96 |
+
"description": "Token for incremental synchronization"
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"required": ["calendarId"]
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"name": "insert_acl_rule",
|
| 104 |
+
"description": """Insert a new access control rule.
|
| 105 |
+
|
| 106 |
+
Adds a new ACL rule to the specified calendar.
|
| 107 |
+
Equivalent to: POST /calendars/{calendarId}/acl
|
| 108 |
+
|
| 109 |
+
Required Parameters:
|
| 110 |
+
- calendarId: Calendar identifier
|
| 111 |
+
- rule: ACL rule input (scope, role)
|
| 112 |
+
|
| 113 |
+
Optional Parameters:
|
| 114 |
+
- sendNotifications: Whether to send notifications about the calendar sharing change (default: True)
|
| 115 |
+
|
| 116 |
+
Response Structure:
|
| 117 |
+
- kind: "calendar#aclRule"
|
| 118 |
+
- etag: ETag
|
| 119 |
+
- id: ACL rule ID
|
| 120 |
+
- scope: { type, value }
|
| 121 |
+
- role: ACL role
|
| 122 |
+
|
| 123 |
+
Status Codes:
|
| 124 |
+
- 201: Created
|
| 125 |
+
- 400: Bad Request
|
| 126 |
+
- 500: Internal Server Error
|
| 127 |
+
""",
|
| 128 |
+
"inputSchema": {
|
| 129 |
+
"type": "object",
|
| 130 |
+
"properties": {
|
| 131 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 132 |
+
"scope": {
|
| 133 |
+
"type": "object",
|
| 134 |
+
"properties": {
|
| 135 |
+
"type": {"type": "string", "minLength": 1},
|
| 136 |
+
"value": {"type": "string", "minLength": 1}
|
| 137 |
+
},
|
| 138 |
+
"required": ["type"]
|
| 139 |
+
},
|
| 140 |
+
"role": {"type": "string", "minLength": 1},
|
| 141 |
+
"sendNotifications": {
|
| 142 |
+
"type": "boolean",
|
| 143 |
+
"default": True,
|
| 144 |
+
"description": "Whether to send notifications about the calendar sharing change"
|
| 145 |
+
}
|
| 146 |
+
},
|
| 147 |
+
"required": ["calendarId", "scope", "role"]
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"name": "update_acl_rule",
|
| 152 |
+
"description": """Fully update an existing ACL rule.
|
| 153 |
+
|
| 154 |
+
Replaces an ACL rule with a new one.
|
| 155 |
+
Equivalent to: PUT /calendars/{calendarId}/acl/{ruleId}
|
| 156 |
+
|
| 157 |
+
Required Parameters:
|
| 158 |
+
- calendarId: Calendar identifier
|
| 159 |
+
- ruleId: ACL rule ID
|
| 160 |
+
- rule: Complete rule replacement (scope, role)
|
| 161 |
+
|
| 162 |
+
Optional Parameters:
|
| 163 |
+
- sendNotifications: Whether to send notifications about the calendar sharing change (default: True)
|
| 164 |
+
|
| 165 |
+
Response:
|
| 166 |
+
- Updated ACL rule object
|
| 167 |
+
|
| 168 |
+
Status Codes:
|
| 169 |
+
- 200: Success
|
| 170 |
+
- 404: Not Found
|
| 171 |
+
- 500: Internal Server Error
|
| 172 |
+
""",
|
| 173 |
+
"inputSchema": {
|
| 174 |
+
"type": "object",
|
| 175 |
+
"properties": {
|
| 176 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 177 |
+
"ruleId": {"type": "string", "minLength": 1},
|
| 178 |
+
"scope": {
|
| 179 |
+
"type": "object",
|
| 180 |
+
"properties": {
|
| 181 |
+
"type": {"type": "string", "minLength": 1},
|
| 182 |
+
"value": {"type": "string", "minLength": 1}
|
| 183 |
+
},
|
| 184 |
+
"required": ["type"]
|
| 185 |
+
},
|
| 186 |
+
"role": {"type": "string", "minLength": 1},
|
| 187 |
+
"sendNotifications": {
|
| 188 |
+
"type": "boolean",
|
| 189 |
+
"default": True,
|
| 190 |
+
"description": "Whether to send notifications about the calendar sharing change"
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
"required": ["calendarId", "ruleId", "scope"]
|
| 194 |
+
}
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"name": "patch_acl_rule",
|
| 198 |
+
"description": """Partially update an ACL rule.
|
| 199 |
+
|
| 200 |
+
Allows modifying select fields of an ACL rule.
|
| 201 |
+
Equivalent to: PATCH /calendars/{calendarId}/acl/{ruleId}
|
| 202 |
+
|
| 203 |
+
Required Parameters:
|
| 204 |
+
- calendarId: Calendar identifier
|
| 205 |
+
- ruleId: ACL rule ID
|
| 206 |
+
- rule: Partial updates (any of: scope, role)
|
| 207 |
+
|
| 208 |
+
Optional Parameters:
|
| 209 |
+
- sendNotifications: Whether to send notifications about the calendar sharing change (default: True)
|
| 210 |
+
|
| 211 |
+
Response:
|
| 212 |
+
- Updated ACL rule object
|
| 213 |
+
|
| 214 |
+
Status Codes:
|
| 215 |
+
- 200: Success
|
| 216 |
+
- 404: Not Found
|
| 217 |
+
- 500: Internal Server Error
|
| 218 |
+
""",
|
| 219 |
+
"inputSchema": {
|
| 220 |
+
"type": "object",
|
| 221 |
+
"properties": {
|
| 222 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 223 |
+
"ruleId": {"type": "string", "minLength": 1},
|
| 224 |
+
"scope": {
|
| 225 |
+
"type": "object",
|
| 226 |
+
"properties": {
|
| 227 |
+
"type": {"type": "string"},
|
| 228 |
+
"value": {"type": "string"}
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
"role": {"type": "string"},
|
| 232 |
+
"sendNotifications": {
|
| 233 |
+
"type": "boolean",
|
| 234 |
+
"default": True,
|
| 235 |
+
"description": "Whether to send notifications about the calendar sharing change"
|
| 236 |
+
},
|
| 237 |
+
},
|
| 238 |
+
"required": ["calendarId", "ruleId"]
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"name": "delete_acl_rule",
|
| 243 |
+
"description": """Delete an ACL rule.
|
| 244 |
+
|
| 245 |
+
Deletes a specific ACL rule from the calendar.
|
| 246 |
+
Equivalent to: DELETE /calendars/{calendarId}/acl/{ruleId}
|
| 247 |
+
|
| 248 |
+
Required Parameters:
|
| 249 |
+
- calendarId: Calendar identifier
|
| 250 |
+
- ruleId: ACL rule ID
|
| 251 |
+
|
| 252 |
+
Response:
|
| 253 |
+
- No content (204) on success
|
| 254 |
+
|
| 255 |
+
Status Codes:
|
| 256 |
+
- 204: No Content
|
| 257 |
+
- 404: Not Found
|
| 258 |
+
- 500: Internal Server Error
|
| 259 |
+
""",
|
| 260 |
+
"inputSchema": {
|
| 261 |
+
"type": "object",
|
| 262 |
+
"properties": {
|
| 263 |
+
"calendarId": {"type": "string", "minLength": 1},
|
| 264 |
+
"ruleId": {"type": "string", "minLength": 1},
|
| 265 |
+
},
|
| 266 |
+
"required": ["calendarId", "ruleId"]
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
{
|
| 270 |
+
"name": "watch_acl",
|
| 271 |
+
"description": """Set up a webhook to receive notifications when ACL rules change.
|
| 272 |
+
|
| 273 |
+
Sets up webhook notifications for ACL rule changes following Google Calendar API v3 structure.
|
| 274 |
+
Returns a channel for managing the watch. Monitors ACL rules in the specified calendar for changes.
|
| 275 |
+
|
| 276 |
+
Equivalent to: POST /calendars/{calendarId}/acl/watch
|
| 277 |
+
|
| 278 |
+
Required Parameters:
|
| 279 |
+
- calendarId: Calendar identifier to watch for ACL changes
|
| 280 |
+
- id: Unique channel identifier
|
| 281 |
+
- address: Webhook URL to receive notifications
|
| 282 |
+
- type: Channel type (default: "web_hook")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
Optional Parameters:
|
| 286 |
+
- token: Optional token for webhook authentication
|
| 287 |
+
- params: Additional channel parameters
|
| 288 |
+
|
| 289 |
+
Response Structure:
|
| 290 |
+
- Returns Channel resource with Google Calendar API v3 format:
|
| 291 |
+
* kind: "api#channel"
|
| 292 |
+
* id: Channel identifier
|
| 293 |
+
* resourceId: Resource being watched
|
| 294 |
+
* resourceUri: Resource URI path
|
| 295 |
+
* token: Authentication token (if provided)
|
| 296 |
+
* expiration: Channel expiration time (if set)
|
| 297 |
+
|
| 298 |
+
Channel Management:
|
| 299 |
+
- Each watch creates a unique notification channel
|
| 300 |
+
- Channels can expire (set expiration time)
|
| 301 |
+
- Multiple channels can watch the same calendar ACL
|
| 302 |
+
- Use unique channel IDs to avoid conflicts
|
| 303 |
+
|
| 304 |
+
Status Codes:
|
| 305 |
+
- 200: Success - Watch channel created successfully
|
| 306 |
+
- 400: Bad Request - Invalid channel configuration
|
| 307 |
+
- 404: Not Found - Calendar not found
|
| 308 |
+
- 500: Internal Server Error
|
| 309 |
+
""",
|
| 310 |
+
"inputSchema": {
|
| 311 |
+
"type": "object",
|
| 312 |
+
"properties": {
|
| 313 |
+
"calendarId": {
|
| 314 |
+
"type": "string",
|
| 315 |
+
"minLength": 1,
|
| 316 |
+
"description": "Calendar identifier to watch for ACL changes"
|
| 317 |
+
},
|
| 318 |
+
"id": {
|
| 319 |
+
"type": "string",
|
| 320 |
+
"minLength": 1,
|
| 321 |
+
"description": "Unique channel identifier"
|
| 322 |
+
},
|
| 323 |
+
"type": {
|
| 324 |
+
"type": "string",
|
| 325 |
+
"default": "web_hook",
|
| 326 |
+
"description": "Channel type (only 'web_hook' supported)"
|
| 327 |
+
},
|
| 328 |
+
"address": {
|
| 329 |
+
"type": "string",
|
| 330 |
+
"minLength": 1,
|
| 331 |
+
"description": "Webhook URL to receive notifications"
|
| 332 |
+
},
|
| 333 |
+
"token": {
|
| 334 |
+
"type": "string",
|
| 335 |
+
"description": "Optional token for webhook authentication"
|
| 336 |
+
},
|
| 337 |
+
"params": {
|
| 338 |
+
"type": "object",
|
| 339 |
+
"description": "Additional channel parameters",
|
| 340 |
+
"properties": {
|
| 341 |
+
"ttl": {
|
| 342 |
+
"type": "string",
|
| 343 |
+
"description": "Time to live in seconds (string)."
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
},
|
| 348 |
+
"required": ["calendarId", "id", "type", "address"]
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
]
|
server/calendar_mcp/tools/calendar_list.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CalendarList Tools Module
|
| 3 |
+
|
| 4 |
+
This module contains tools related to calendar list management.
|
| 5 |
+
Covers all 7 Google Calendar API v3 CalendarList endpoints for user calendar list operations.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
CALENDAR_LIST_TOOLS = [
|
| 9 |
+
{
|
| 10 |
+
"name": "get_calendar_list",
|
| 11 |
+
"description": """Returns the calendars on the user's calendar list.
|
| 12 |
+
|
| 13 |
+
Lists all calendars in the user's calendar list with their display settings and access permissions.
|
| 14 |
+
Returns calendars with user-specific customizations like colors, visibility, and notification settings.
|
| 15 |
+
Supports pagination through pageToken and incremental synchronization through syncToken.
|
| 16 |
+
|
| 17 |
+
Request Body Requirements:
|
| 18 |
+
- maxResults: Optional. Maximum number of entries returned (pagination). If 0, returns no items.
|
| 19 |
+
- minAccessRole: Optional. Minimum access role filter (freeBusyReader, reader, writer, owner)
|
| 20 |
+
- pageToken: Optional. Token specifying which result page to return (for pagination)
|
| 21 |
+
- showDeleted: Optional. Include deleted calendars in results (default: false)
|
| 22 |
+
- showHidden: Optional. Include hidden calendars in results (default: false)
|
| 23 |
+
- syncToken: Optional. Token for incremental synchronization (returns only changed entries)
|
| 24 |
+
|
| 25 |
+
Pagination Support:
|
| 26 |
+
- Use maxResults to limit the number of calendars returned per page
|
| 27 |
+
- Use pageToken to retrieve subsequent pages of results
|
| 28 |
+
- Check nextPageToken in response to determine if more results are available
|
| 29 |
+
- Pass nextPageToken as pageToken in the next request to get the next page
|
| 30 |
+
|
| 31 |
+
Incremental Synchronization:
|
| 32 |
+
- Use syncToken to get only entries that have changed since the last request
|
| 33 |
+
- syncToken cannot be used together with minAccessRole parameter
|
| 34 |
+
- When using syncToken, deleted and hidden entries are automatically included
|
| 35 |
+
- If syncToken expires, server returns 410 GONE and client should perform full sync
|
| 36 |
+
- Use nextSyncToken from response for subsequent incremental sync requests
|
| 37 |
+
|
| 38 |
+
Response Structure:
|
| 39 |
+
- Returns calendar list collection with Google Calendar API v3 format:
|
| 40 |
+
* kind: "calendar#calendarList"
|
| 41 |
+
* etag: ETag of the collection
|
| 42 |
+
* items: Array of CalendarListEntry objects
|
| 43 |
+
* nextPageToken: Token for next page (if more results available)
|
| 44 |
+
* nextSyncToken: Token for incremental sync (always provided when items are returned)
|
| 45 |
+
|
| 46 |
+
CalendarListEntry Structure:
|
| 47 |
+
- Each item contains calendar metadata plus user-specific settings:
|
| 48 |
+
* id: Calendar identifier
|
| 49 |
+
* summary: Display title (with summaryOverride if set)
|
| 50 |
+
* accessRole: User's permission level
|
| 51 |
+
* primary: Whether it's the user's primary calendar
|
| 52 |
+
* backgroundColor/foregroundColor: Display colors
|
| 53 |
+
* hidden: Whether hidden from calendar list
|
| 54 |
+
* selected: Whether selected in UI
|
| 55 |
+
* defaultReminders: User-specific default reminders
|
| 56 |
+
* notificationSettings: Notification preferences
|
| 57 |
+
|
| 58 |
+
Status Codes:
|
| 59 |
+
- 200: Success - Calendar list retrieved successfully
|
| 60 |
+
- 500: Internal Server Error""",
|
| 61 |
+
"inputSchema": {
|
| 62 |
+
"type": "object",
|
| 63 |
+
"properties": {
|
| 64 |
+
"maxResults": {
|
| 65 |
+
"type": "integer",
|
| 66 |
+
"description": "Maximum number of entries returned. If 0, returns no items.",
|
| 67 |
+
"minimum": 0,
|
| 68 |
+
"maximum": 250,
|
| 69 |
+
"default": 100
|
| 70 |
+
},
|
| 71 |
+
"minAccessRole": {
|
| 72 |
+
"type": "string",
|
| 73 |
+
"description": "Minimum access role filter",
|
| 74 |
+
"enum": ["freeBusyReader", "reader", "writer", "owner"]
|
| 75 |
+
},
|
| 76 |
+
"pageToken": {
|
| 77 |
+
"type": "string",
|
| 78 |
+
"description": "Token specifying which result page to return (for pagination)"
|
| 79 |
+
},
|
| 80 |
+
"showDeleted": {
|
| 81 |
+
"type": "boolean",
|
| 82 |
+
"description": "Include deleted calendars in results",
|
| 83 |
+
"default": False
|
| 84 |
+
},
|
| 85 |
+
"showHidden": {
|
| 86 |
+
"type": "boolean",
|
| 87 |
+
"description": "Include hidden calendars in results",
|
| 88 |
+
"default": False
|
| 89 |
+
},
|
| 90 |
+
"syncToken": {
|
| 91 |
+
"type": "string",
|
| 92 |
+
"description": "Token for incremental synchronization (returns only changed entries since last sync)"
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
"required": []
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"name": "get_calendar_from_list",
|
| 100 |
+
"description": """Returns a calendar from the user's calendar list.
|
| 101 |
+
|
| 102 |
+
Retrieves a specific calendar entry from the user's calendar list with all user-specific settings.
|
| 103 |
+
Shows how the calendar appears in the user's list with customizations and access permissions.
|
| 104 |
+
|
| 105 |
+
Request Body Requirements:
|
| 106 |
+
- calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary' to refer to the user's primary calendar
|
| 107 |
+
|
| 108 |
+
Response Structure:
|
| 109 |
+
- Returns CalendarListEntry with Google Calendar API v3 format:
|
| 110 |
+
* kind: "calendar#calendarListEntry"
|
| 111 |
+
* etag: ETag of the resource
|
| 112 |
+
* id: Calendar identifier
|
| 113 |
+
* summary: Display title (with summaryOverride if set)
|
| 114 |
+
* description: Calendar description
|
| 115 |
+
* location: Calendar location
|
| 116 |
+
* timeZone: Calendar timezone
|
| 117 |
+
* summaryOverride: Custom title override for this user
|
| 118 |
+
* colorId: Calendar color ID
|
| 119 |
+
* backgroundColor: Background color (hex)
|
| 120 |
+
* foregroundColor: Foreground color (hex)
|
| 121 |
+
* hidden: Whether hidden from calendar list
|
| 122 |
+
* selected: Whether selected in UI
|
| 123 |
+
* accessRole: User's permission level (freeBusyReader, reader, writer, owner)
|
| 124 |
+
* defaultReminders: User-specific default reminders
|
| 125 |
+
* notificationSettings: Notification preferences
|
| 126 |
+
* primary: Whether it's the user's primary calendar
|
| 127 |
+
* deleted: Whether the calendar is deleted
|
| 128 |
+
|
| 129 |
+
Status Codes:
|
| 130 |
+
- 200: Success - Calendar entry retrieved successfully
|
| 131 |
+
- 404: Not Found - Calendar not found in user's list
|
| 132 |
+
- 500: Internal Server Error""",
|
| 133 |
+
"inputSchema": {
|
| 134 |
+
"type": "object",
|
| 135 |
+
"properties": {
|
| 136 |
+
"calendarId": {
|
| 137 |
+
"type": "string",
|
| 138 |
+
"description": "Unique calendar identifier (UUID)",
|
| 139 |
+
"minLength": 1
|
| 140 |
+
}
|
| 141 |
+
},
|
| 142 |
+
"required": ["calendarId"]
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"name": "add_calendar_to_list",
|
| 147 |
+
"description": """Inserts an existing calendar into the user's calendar list.
|
| 148 |
+
|
| 149 |
+
Adds an existing calendar to the user's calendar list with custom display settings.
|
| 150 |
+
The calendar must already exist - this endpoint only adds it to the user's list with personalization.
|
| 151 |
+
|
| 152 |
+
Request Body Requirements:
|
| 153 |
+
- id: Required. Calendar ID to add to user's list
|
| 154 |
+
- summaryOverride: Optional. Custom calendar title override
|
| 155 |
+
- colorId: Optional. Calendar color ID
|
| 156 |
+
- backgroundColor: Optional. Background color (hex format like #FF5733)
|
| 157 |
+
- foregroundColor: Optional. Foreground color (hex format like #FFFFFF)
|
| 158 |
+
- hidden: Optional. Whether calendar is hidden from list (default: false)
|
| 159 |
+
- selected: Optional. Whether calendar is selected in UI (default: true)
|
| 160 |
+
- defaultReminders: Optional. Array of default reminder settings
|
| 161 |
+
- notificationSettings: Optional. Notification preferences
|
| 162 |
+
|
| 163 |
+
Calendar Must Exist:
|
| 164 |
+
- The calendar with the specified ID must already exist in the database
|
| 165 |
+
- This endpoint does not create new calendars, only adds them to user's list
|
| 166 |
+
- Use create_calendar tool first if the calendar doesn't exist
|
| 167 |
+
|
| 168 |
+
Response Structure:
|
| 169 |
+
- Returns the created CalendarListEntry with Google Calendar API v3 format
|
| 170 |
+
- Includes all user-specific customizations applied
|
| 171 |
+
|
| 172 |
+
Colors:
|
| 173 |
+
- To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true
|
| 174 |
+
- When RGB fields are provided, colorId (if present) is ignored
|
| 175 |
+
|
| 176 |
+
Status Codes:
|
| 177 |
+
- 201: Created - Calendar added to list successfully
|
| 178 |
+
- 404: Not Found - Calendar with specified ID not found
|
| 179 |
+
- 500: Internal Server Error""",
|
| 180 |
+
"inputSchema": {
|
| 181 |
+
"type": "object",
|
| 182 |
+
"properties": {
|
| 183 |
+
"id": {
|
| 184 |
+
"type": "string",
|
| 185 |
+
"description": "Calendar ID to add to user's list (UUID)",
|
| 186 |
+
"minLength": 1
|
| 187 |
+
},
|
| 188 |
+
"colorRgbFormat": {
|
| 189 |
+
"type": "boolean",
|
| 190 |
+
"description": "Query param: if true, allows writing backgroundColor/foregroundColor",
|
| 191 |
+
"default": False
|
| 192 |
+
},
|
| 193 |
+
"summaryOverride": {
|
| 194 |
+
"type": "string",
|
| 195 |
+
"description": "Custom calendar title override",
|
| 196 |
+
"maxLength": 255
|
| 197 |
+
},
|
| 198 |
+
"colorId": {
|
| 199 |
+
"type": "string",
|
| 200 |
+
"description": "Calendar color ID",
|
| 201 |
+
"maxLength": 50
|
| 202 |
+
},
|
| 203 |
+
"backgroundColor": {
|
| 204 |
+
"type": "string",
|
| 205 |
+
"description": "Background color (hex format like #FF5733)",
|
| 206 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 207 |
+
},
|
| 208 |
+
"foregroundColor": {
|
| 209 |
+
"type": "string",
|
| 210 |
+
"description": "Foreground color (hex format like #FFFFFF)",
|
| 211 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 212 |
+
},
|
| 213 |
+
"hidden": {
|
| 214 |
+
"type": "boolean",
|
| 215 |
+
"description": "Whether calendar is hidden from list",
|
| 216 |
+
"default": False
|
| 217 |
+
},
|
| 218 |
+
"selected": {
|
| 219 |
+
"type": "boolean",
|
| 220 |
+
"description": "Whether calendar is selected in UI",
|
| 221 |
+
"default": True
|
| 222 |
+
},
|
| 223 |
+
"defaultReminders": {
|
| 224 |
+
"type": "array",
|
| 225 |
+
"description": "Default reminder settings",
|
| 226 |
+
"items": {
|
| 227 |
+
"type": "object",
|
| 228 |
+
"properties": {
|
| 229 |
+
"method": {
|
| 230 |
+
"type": "string",
|
| 231 |
+
"enum": ["email", "popup"],
|
| 232 |
+
"description": "Reminder delivery method"
|
| 233 |
+
},
|
| 234 |
+
"minutes": {
|
| 235 |
+
"type": "integer",
|
| 236 |
+
"description": "Minutes before event to trigger reminder",
|
| 237 |
+
"minimum": 0
|
| 238 |
+
}
|
| 239 |
+
},
|
| 240 |
+
"required": ["method", "minutes"]
|
| 241 |
+
}
|
| 242 |
+
},
|
| 243 |
+
"notificationSettings": {
|
| 244 |
+
"type": "object",
|
| 245 |
+
"description": "Notification preferences",
|
| 246 |
+
"properties": {
|
| 247 |
+
"notifications": {
|
| 248 |
+
"type": "array",
|
| 249 |
+
"description": "List of notification settings",
|
| 250 |
+
"items": {
|
| 251 |
+
"type": "object",
|
| 252 |
+
"description": "Individual notification setting",
|
| 253 |
+
"properties": {
|
| 254 |
+
"method": {
|
| 255 |
+
"type": "string",
|
| 256 |
+
"enum": ["email"],
|
| 257 |
+
"description": "Notification delivery method (only 'email' supported)"
|
| 258 |
+
},
|
| 259 |
+
"type": {
|
| 260 |
+
"type": "string",
|
| 261 |
+
"enum": [
|
| 262 |
+
"eventCreation",
|
| 263 |
+
"eventChange",
|
| 264 |
+
"eventCancellation",
|
| 265 |
+
"eventResponse",
|
| 266 |
+
"agenda"
|
| 267 |
+
],
|
| 268 |
+
"description": "Notification type"
|
| 269 |
+
}
|
| 270 |
+
},
|
| 271 |
+
"required": ["method", "type"]
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
},
|
| 277 |
+
"required": ["id"]
|
| 278 |
+
}
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
"name": "update_calendar_in_list",
|
| 282 |
+
"description": """Updates an entry on the user's calendar list (partial update).
|
| 283 |
+
|
| 284 |
+
Partially updates calendar list entry settings. Only provided fields will be updated,
|
| 285 |
+
others remain unchanged. This allows fine-grained control over calendar display settings.
|
| 286 |
+
|
| 287 |
+
Request Body Requirements:
|
| 288 |
+
- calendarId: Required. Unique calendar identifier (UUID) or 'primary'
|
| 289 |
+
|
| 290 |
+
Request Body (all optional):
|
| 291 |
+
- summaryOverride: Custom calendar title override
|
| 292 |
+
- colorId: Calendar color ID
|
| 293 |
+
- backgroundColor: Background color (hex format like #FF5733)
|
| 294 |
+
- foregroundColor: Foreground color (hex format like #FFFFFF)
|
| 295 |
+
- hidden: Whether calendar is hidden from list
|
| 296 |
+
- selected: Whether calendar is selected in UI
|
| 297 |
+
- defaultReminders: Array of default reminder settings
|
| 298 |
+
- notificationSettings: Notification preferences
|
| 299 |
+
|
| 300 |
+
Partial Update Behavior:
|
| 301 |
+
- Only fields provided in request body are updated
|
| 302 |
+
- Null values will clear the field (set to null) for string/complex fields
|
| 303 |
+
- 'hidden' and 'selected' cannot be null (booleans are NOT NULL in our DB)
|
| 304 |
+
- Missing fields are left unchanged
|
| 305 |
+
- At least one field must be provided for update
|
| 306 |
+
|
| 307 |
+
Response Structure:
|
| 308 |
+
- Returns updated CalendarListEntry with Google Calendar API v3 format
|
| 309 |
+
- Shows all current settings including unchanged fields
|
| 310 |
+
|
| 311 |
+
Colors:
|
| 312 |
+
- To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true
|
| 313 |
+
- When RGB fields are provided, colorId (if present) is ignored
|
| 314 |
+
|
| 315 |
+
Behavioral coupling:
|
| 316 |
+
- If hidden=true, the server will force selected=false
|
| 317 |
+
- If hidden=false, the server will force selected=true (matches observed UI behavior)
|
| 318 |
+
|
| 319 |
+
Status Codes:
|
| 320 |
+
- 200: Success - Calendar list entry updated successfully
|
| 321 |
+
- 400: Bad Request - No fields provided for update
|
| 322 |
+
- 404: Not Found - Calendar not found in user's list
|
| 323 |
+
- 500: Internal Server Error""",
|
| 324 |
+
"inputSchema": {
|
| 325 |
+
"type": "object",
|
| 326 |
+
"properties": {
|
| 327 |
+
"calendarId": {
|
| 328 |
+
"type": "string",
|
| 329 |
+
"description": "Unique calendar identifier (UUID)",
|
| 330 |
+
"minLength": 1
|
| 331 |
+
},
|
| 332 |
+
"colorRgbFormat": {
|
| 333 |
+
"type": "boolean",
|
| 334 |
+
"description": "Query param: if true, allows writing backgroundColor/foregroundColor",
|
| 335 |
+
"default": False
|
| 336 |
+
},
|
| 337 |
+
"summaryOverride": {
|
| 338 |
+
"type": "string",
|
| 339 |
+
"description": "Custom calendar title override",
|
| 340 |
+
"maxLength": 255
|
| 341 |
+
},
|
| 342 |
+
"colorId": {
|
| 343 |
+
"type": "string",
|
| 344 |
+
"description": "Calendar color ID",
|
| 345 |
+
"maxLength": 50
|
| 346 |
+
},
|
| 347 |
+
"backgroundColor": {
|
| 348 |
+
"type": "string",
|
| 349 |
+
"description": "Background color (hex format like #FF5733)",
|
| 350 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 351 |
+
},
|
| 352 |
+
"foregroundColor": {
|
| 353 |
+
"type": "string",
|
| 354 |
+
"description": "Foreground color (hex format like #FFFFFF)",
|
| 355 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 356 |
+
},
|
| 357 |
+
"hidden": {
|
| 358 |
+
"type": "boolean",
|
| 359 |
+
"description": "Whether calendar is hidden from list",
|
| 360 |
+
"default": False
|
| 361 |
+
},
|
| 362 |
+
"selected": {
|
| 363 |
+
"type": "boolean",
|
| 364 |
+
"description": "Whether calendar is selected in UI",
|
| 365 |
+
"default": True
|
| 366 |
+
},
|
| 367 |
+
"defaultReminders": {
|
| 368 |
+
"type": "array",
|
| 369 |
+
"description": "Default reminder settings",
|
| 370 |
+
"items": {
|
| 371 |
+
"type": "object",
|
| 372 |
+
"properties": {
|
| 373 |
+
"method": {
|
| 374 |
+
"type": "string",
|
| 375 |
+
"enum": ["email", "popup"],
|
| 376 |
+
"description": "Reminder delivery method"
|
| 377 |
+
},
|
| 378 |
+
"minutes": {
|
| 379 |
+
"type": "integer",
|
| 380 |
+
"description": "Minutes before event to trigger reminder",
|
| 381 |
+
"minimum": 0
|
| 382 |
+
}
|
| 383 |
+
},
|
| 384 |
+
"required": ["method", "minutes"]
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
"notificationSettings": {
|
| 388 |
+
"type": "object",
|
| 389 |
+
"description": "Notification preferences",
|
| 390 |
+
"properties": {
|
| 391 |
+
"notifications": {
|
| 392 |
+
"type": "array",
|
| 393 |
+
"description": "List of notification settings",
|
| 394 |
+
"items": {
|
| 395 |
+
"type": "object",
|
| 396 |
+
"description": "Individual notification setting",
|
| 397 |
+
"properties": {
|
| 398 |
+
"method": {
|
| 399 |
+
"type": "string",
|
| 400 |
+
"enum": ["email"],
|
| 401 |
+
"description": "Notification delivery method (only 'email' supported)"
|
| 402 |
+
},
|
| 403 |
+
"type": {
|
| 404 |
+
"type": "string",
|
| 405 |
+
"enum": [
|
| 406 |
+
"eventCreation",
|
| 407 |
+
"eventChange",
|
| 408 |
+
"eventCancellation",
|
| 409 |
+
"eventResponse",
|
| 410 |
+
"agenda"
|
| 411 |
+
],
|
| 412 |
+
"description": "Notification type"
|
| 413 |
+
}
|
| 414 |
+
},
|
| 415 |
+
"required": ["method", "type"]
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
},
|
| 421 |
+
"required": ["calendarId"]
|
| 422 |
+
}
|
| 423 |
+
},
|
| 424 |
+
{
|
| 425 |
+
"name": "replace_calendar_in_list",
|
| 426 |
+
"description": """Updates an entry on the user's calendar list (full update).
|
| 427 |
+
|
| 428 |
+
Fully updates calendar list entry settings. All fields are replaced with provided values.
|
| 429 |
+
Fields not provided will be set to their default values (full replacement).
|
| 430 |
+
|
| 431 |
+
Request Body Requirements:
|
| 432 |
+
- calendarId: Required. Unique calendar identifier (UUID) or 'primary'
|
| 433 |
+
|
| 434 |
+
Request Body (all optional, but null/missing values will be set to defaults):
|
| 435 |
+
- summaryOverride: Custom calendar title override (null to clear)
|
| 436 |
+
- colorId: Calendar color ID (null to clear)
|
| 437 |
+
- backgroundColor: Background color (hex format like #FF5733, null to clear)
|
| 438 |
+
- foregroundColor: Foreground color (hex format like #FFFFFF, null to clear)
|
| 439 |
+
- hidden: Whether calendar is hidden from list (default: false)
|
| 440 |
+
- selected: Whether calendar is selected in UI (default: true)
|
| 441 |
+
- defaultReminders: Array of default reminder settings (null to clear)
|
| 442 |
+
- notificationSettings: Notification preferences (null to clear)
|
| 443 |
+
- conferenceProperties: Conference properties for this calendar (null to clear)
|
| 444 |
+
|
| 445 |
+
Full Update Behavior:
|
| 446 |
+
- All fields are replaced (full replacement operation)
|
| 447 |
+
- Missing optional fields are set to null/defaults
|
| 448 |
+
- Required fields (hidden, selected) get default values if not provided
|
| 449 |
+
- This is different from PATCH which only updates provided fields
|
| 450 |
+
|
| 451 |
+
Response Structure:
|
| 452 |
+
- Returns updated CalendarListEntry with Google Calendar API v3 format
|
| 453 |
+
- Shows all current settings after full update
|
| 454 |
+
|
| 455 |
+
Colors:
|
| 456 |
+
- To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true
|
| 457 |
+
- When RGB fields are provided, colorId (if present) is ignored
|
| 458 |
+
|
| 459 |
+
Behavioral coupling:
|
| 460 |
+
- If hidden=true, the server will force selected=false
|
| 461 |
+
- If hidden=false, the server will force selected=true (matches observed UI behavior)
|
| 462 |
+
|
| 463 |
+
Status Codes:
|
| 464 |
+
- 200: Success - Calendar list entry updated successfully
|
| 465 |
+
- 404: Not Found - Calendar not found in user's list
|
| 466 |
+
- 500: Internal Server Error""",
|
| 467 |
+
"inputSchema": {
|
| 468 |
+
"type": "object",
|
| 469 |
+
"properties": {
|
| 470 |
+
"calendarId": {
|
| 471 |
+
"type": "string",
|
| 472 |
+
"description": "Unique calendar identifier (UUID)",
|
| 473 |
+
"minLength": 1
|
| 474 |
+
},
|
| 475 |
+
"colorRgbFormat": {
|
| 476 |
+
"type": "boolean",
|
| 477 |
+
"description": "Query param: if true, allows writing backgroundColor/foregroundColor",
|
| 478 |
+
"default": False
|
| 479 |
+
},
|
| 480 |
+
"summaryOverride": {
|
| 481 |
+
"type": "string",
|
| 482 |
+
"description": "Custom calendar title override",
|
| 483 |
+
"maxLength": 255
|
| 484 |
+
},
|
| 485 |
+
"colorId": {
|
| 486 |
+
"type": "string",
|
| 487 |
+
"description": "Calendar color ID",
|
| 488 |
+
"maxLength": 50
|
| 489 |
+
},
|
| 490 |
+
"backgroundColor": {
|
| 491 |
+
"type": "string",
|
| 492 |
+
"description": "Background color (hex format like #FF5733)",
|
| 493 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 494 |
+
},
|
| 495 |
+
"foregroundColor": {
|
| 496 |
+
"type": "string",
|
| 497 |
+
"description": "Foreground color (hex format like #FFFFFF)",
|
| 498 |
+
"pattern": "^#[0-9A-Fa-f]{6}$"
|
| 499 |
+
},
|
| 500 |
+
"hidden": {
|
| 501 |
+
"type": "boolean",
|
| 502 |
+
"description": "Whether calendar is hidden from list",
|
| 503 |
+
"default": False
|
| 504 |
+
},
|
| 505 |
+
"selected": {
|
| 506 |
+
"type": "boolean",
|
| 507 |
+
"description": "Whether calendar is selected in UI",
|
| 508 |
+
"default": True
|
| 509 |
+
},
|
| 510 |
+
"defaultReminders": {
|
| 511 |
+
"type": "array",
|
| 512 |
+
"description": "Default reminder settings",
|
| 513 |
+
"items": {
|
| 514 |
+
"type": "object",
|
| 515 |
+
"properties": {
|
| 516 |
+
"method": {
|
| 517 |
+
"type": "string",
|
| 518 |
+
"description": "Reminder delivery method (email, popup). Empty string allowed to clear"
|
| 519 |
+
},
|
| 520 |
+
"minutes": {
|
| 521 |
+
"type": "integer",
|
| 522 |
+
"description": "Minutes before event to trigger reminder",
|
| 523 |
+
"minimum": 0
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
},
|
| 528 |
+
"notificationSettings": {
|
| 529 |
+
"type": "object",
|
| 530 |
+
"description": "Notification preferences",
|
| 531 |
+
"properties": {
|
| 532 |
+
"notifications": {
|
| 533 |
+
"type": "array",
|
| 534 |
+
"description": "List of notification settings",
|
| 535 |
+
"items": {
|
| 536 |
+
"type": "object",
|
| 537 |
+
"description": "Individual notification setting",
|
| 538 |
+
"properties": {
|
| 539 |
+
"method": {
|
| 540 |
+
"type": "string",
|
| 541 |
+
"description": "Notification delivery method. Empty string allowed to clear"
|
| 542 |
+
},
|
| 543 |
+
"type": {
|
| 544 |
+
"type": "string",
|
| 545 |
+
"description": "Notification type. Empty string allowed to clear"
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
},
|
| 552 |
+
"conferenceProperties": {
|
| 553 |
+
"type": "object",
|
| 554 |
+
"description": "Conference properties for this calendar",
|
| 555 |
+
"properties": {
|
| 556 |
+
"allowedConferenceSolutionTypes": {
|
| 557 |
+
"type": "array",
|
| 558 |
+
"description": "The types of conference solutions that are supported for this calendar",
|
| 559 |
+
"items": {
|
| 560 |
+
"type": "string",
|
| 561 |
+
"enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"],
|
| 562 |
+
"description": "Conference solution type"
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
},
|
| 568 |
+
"required": ["calendarId"]
|
| 569 |
+
}
|
| 570 |
+
},
|
| 571 |
+
{
|
| 572 |
+
"name": "remove_calendar_from_list",
|
| 573 |
+
"description": """Removes a calendar from the user's calendar list.
|
| 574 |
+
|
| 575 |
+
Removes a calendar from the user's calendar list (soft delete).
|
| 576 |
+
The calendar itself remains in the database but is no longer visible in the user's list.
|
| 577 |
+
Primary calendars cannot be removed from the calendar list.
|
| 578 |
+
|
| 579 |
+
Request Body Requirements:
|
| 580 |
+
- calendarId: Required. Unique calendar identifier (UUID)
|
| 581 |
+
|
| 582 |
+
Primary Calendar Protection:
|
| 583 |
+
- Primary calendars cannot be removed from the calendar list
|
| 584 |
+
- Attempting to remove primary calendar returns 400 Bad Request
|
| 585 |
+
- Primary calendars are always part of the user's list
|
| 586 |
+
|
| 587 |
+
Operation Details:
|
| 588 |
+
- Calendar is soft-deleted (marked as deleted, not physically removed)
|
| 589 |
+
- Calendar data remains in database but is hidden from list
|
| 590 |
+
- Calendar can potentially be re-added to list later
|
| 591 |
+
- Does not affect the underlying calendar data or events
|
| 592 |
+
|
| 593 |
+
Response Structure:
|
| 594 |
+
- Returns 204 No Content on successful removal
|
| 595 |
+
- No response body as per Google Calendar API v3
|
| 596 |
+
|
| 597 |
+
Status Codes:
|
| 598 |
+
- 204: No Content - Calendar removed from list successfully
|
| 599 |
+
- 400: Bad Request - Attempted to remove primary calendar from list
|
| 600 |
+
- 404: Not Found - Calendar not found in user's list
|
| 601 |
+
- 500: Internal Server Error""",
|
| 602 |
+
"inputSchema": {
|
| 603 |
+
"type": "object",
|
| 604 |
+
"properties": {
|
| 605 |
+
"calendarId": {
|
| 606 |
+
"type": "string",
|
| 607 |
+
"description": "Unique calendar identifier (UUID)",
|
| 608 |
+
"minLength": 1
|
| 609 |
+
}
|
| 610 |
+
},
|
| 611 |
+
"required": ["calendarId"]
|
| 612 |
+
}
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"name": "watch_calendar_list",
|
| 616 |
+
"description": """Watch for changes to CalendarList resources.
|
| 617 |
+
|
| 618 |
+
Sets up webhook notifications (Channel) for changes to the user's calendar list.
|
| 619 |
+
Monitors for additions, removals, and updates to calendar list entries.
|
| 620 |
+
|
| 621 |
+
Request Body (Channel):
|
| 622 |
+
- id: Required. Unique channel identifier for this watch
|
| 623 |
+
- type: Channel type (only "web_hook" (or "webhook") supported)
|
| 624 |
+
- address: Required. HTTPS URL where notifications will be sent
|
| 625 |
+
- token: Optional. Verification token for webhook security
|
| 626 |
+
|
| 627 |
+
Webhook Notifications (simplified):
|
| 628 |
+
- Server will send POST requests to the specified address
|
| 629 |
+
- Notifications triggered by calendar list changes:
|
| 630 |
+
* Calendar added to or removed from list
|
| 631 |
+
* Calendar list entry settings updated
|
| 632 |
+
* Calendar permissions changed
|
| 633 |
+
|
| 634 |
+
Channel Response:
|
| 635 |
+
- Returns Channel object with fields: kind, id, resourceId, resourceUri, token, expiration, type, address
|
| 636 |
+
- resourceUri is the collection path: /users/me/calendarList
|
| 637 |
+
- resourceId is generated by the server
|
| 638 |
+
|
| 639 |
+
Channel Management:
|
| 640 |
+
- Each watch creates a unique notification channel
|
| 641 |
+
- Channels can expire (set expiration time)
|
| 642 |
+
- Multiple channels can watch the same resource
|
| 643 |
+
- Use unique channel IDs to avoid conflicts
|
| 644 |
+
|
| 645 |
+
Response Structure:
|
| 646 |
+
- Returns Channel resource with Google Calendar API v3 format:
|
| 647 |
+
* kind: "api#channel"
|
| 648 |
+
* id: Channel identifier
|
| 649 |
+
* resourceId: Resource being watched
|
| 650 |
+
* resourceUri: Resource URI path
|
| 651 |
+
* token: Verification token (if provided)
|
| 652 |
+
* expiration: Channel expiration time (if set)
|
| 653 |
+
* type: Channel type "web_hook" (or "webhook").
|
| 654 |
+
* address: Notification delivery address
|
| 655 |
+
|
| 656 |
+
Status Codes:
|
| 657 |
+
- 200: Success - Watch channel created successfully
|
| 658 |
+
- 400: Bad Request - Invalid channel configuration or query parameters
|
| 659 |
+
- 500: Internal Server Error
|
| 660 |
+
|
| 661 |
+
Note: This is a simplified implementation. In production, you would need:
|
| 662 |
+
- Webhook endpoint verification
|
| 663 |
+
- Channel management and cleanup
|
| 664 |
+
- Actual change detection and notification dispatch""",
|
| 665 |
+
"inputSchema": {
|
| 666 |
+
"type": "object",
|
| 667 |
+
"properties": {
|
| 668 |
+
"id": {
|
| 669 |
+
"type": "string",
|
| 670 |
+
"description": "Unique channel identifier for this watch",
|
| 671 |
+
"minLength": 1
|
| 672 |
+
},
|
| 673 |
+
"type": {
|
| 674 |
+
"type": "string",
|
| 675 |
+
"description": "Channel type (only web_hook supported; 'webhook' accepted as alias)",
|
| 676 |
+
"enum": ["web_hook", "webhook"],
|
| 677 |
+
"default": "web_hook"
|
| 678 |
+
},
|
| 679 |
+
"address": {
|
| 680 |
+
"type": "string",
|
| 681 |
+
"description": "HTTPS URL where notifications will be sent",
|
| 682 |
+
"format": "uri",
|
| 683 |
+
"minLength": 1
|
| 684 |
+
},
|
| 685 |
+
"token": {
|
| 686 |
+
"type": "string",
|
| 687 |
+
"description": "Verification token for webhook security",
|
| 688 |
+
"maxLength": 256
|
| 689 |
+
},
|
| 690 |
+
"params": {
|
| 691 |
+
"type": "object",
|
| 692 |
+
"description": "Optional parameters (Google spec supports 'ttl' in seconds as string)",
|
| 693 |
+
"properties": {
|
| 694 |
+
"ttl": {
|
| 695 |
+
"type": "string",
|
| 696 |
+
"description": "Time to live in seconds (string). Server computes expiration = now + ttl"
|
| 697 |
+
}
|
| 698 |
+
}
|
| 699 |
+
}
|
| 700 |
+
},
|
| 701 |
+
"required": ["id", "type", "address"]
|
| 702 |
+
}
|
| 703 |
+
}
|
| 704 |
+
]
|
server/calendar_mcp/tools/calendars.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calendars Tools Module
|
| 3 |
+
|
| 4 |
+
This module contains tools related to calendar management.
|
| 5 |
+
Covers calendar CRUD operations, clearing, and listing functionality.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
CALENDARS_TOOLS = [
|
| 9 |
+
{
|
| 10 |
+
"name": "create_calendar",
|
| 11 |
+
"description": """Create a new secondary calendar.
|
| 12 |
+
|
| 13 |
+
Creates a new calendar following Google Calendar API v3 structure. This endpoint
|
| 14 |
+
strictly creates a secondary calendar. It does not create or promote a primary calendar.
|
| 15 |
+
The user must already exist; otherwise a 404 is returned.
|
| 16 |
+
|
| 17 |
+
Request Body Requirements:
|
| 18 |
+
- summary: Required. Calendar title (1-255 characters)
|
| 19 |
+
- description: Optional. Calendar description (max 1000 characters)
|
| 20 |
+
- location: Optional. Geographic location (max 500 characters)
|
| 21 |
+
- timeZone: Optional. Calendar timezone in IANA format (default: UTC)
|
| 22 |
+
- conferenceProperties: Optional. Conference solution settings
|
| 23 |
+
|
| 24 |
+
Notes:
|
| 25 |
+
- This operation cannot create a primary calendar. Use account provisioning or separate tooling to ensure a primary exists.
|
| 26 |
+
|
| 27 |
+
Response Structure:
|
| 28 |
+
- Returns the created calendar with Google Calendar API v3 format:
|
| 29 |
+
* kind: "calendar#calendar"
|
| 30 |
+
* etag: ETag of the resource
|
| 31 |
+
* id: Unique calendar identifier (UUID)
|
| 32 |
+
* summary: Calendar title
|
| 33 |
+
* description: Calendar description (if provided)
|
| 34 |
+
* location: Calendar location (if provided)
|
| 35 |
+
* timeZone: Calendar timezone
|
| 36 |
+
* conferenceProperties: Conference settings (if provided)
|
| 37 |
+
|
| 38 |
+
Status Codes:
|
| 39 |
+
- 201: Created - Calendar created successfully
|
| 40 |
+
- 400: Bad Request - Invalid calendar data
|
| 41 |
+
- 404: Not Found - User not found
|
| 42 |
+
- 500: Internal Server Error""",
|
| 43 |
+
"inputSchema": {
|
| 44 |
+
"type": "object",
|
| 45 |
+
"properties": {
|
| 46 |
+
"summary": {
|
| 47 |
+
"type": "string",
|
| 48 |
+
"description": "Calendar title (1-255 characters)",
|
| 49 |
+
"minLength": 1,
|
| 50 |
+
"maxLength": 255,
|
| 51 |
+
},
|
| 52 |
+
"description": {
|
| 53 |
+
"type": "string",
|
| 54 |
+
"description": "Calendar description (max 1000 characters)",
|
| 55 |
+
"maxLength": 1000,
|
| 56 |
+
},
|
| 57 |
+
"location": {
|
| 58 |
+
"type": "string",
|
| 59 |
+
"description": "Geographic location (max 500 characters)",
|
| 60 |
+
"maxLength": 500,
|
| 61 |
+
},
|
| 62 |
+
"timeZone": {
|
| 63 |
+
"type": "string",
|
| 64 |
+
"description": "Calendar timezone in IANA format (default: UTC)",
|
| 65 |
+
"default": "UTC",
|
| 66 |
+
},
|
| 67 |
+
"conferenceProperties": {
|
| 68 |
+
"type": "object",
|
| 69 |
+
"description": "Conference solution settings",
|
| 70 |
+
"properties": {
|
| 71 |
+
"allowedConferenceSolutionTypes": {
|
| 72 |
+
"type": "array",
|
| 73 |
+
"items": {
|
| 74 |
+
"type": "string",
|
| 75 |
+
"enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"]
|
| 76 |
+
},
|
| 77 |
+
"description": "Allowed conference solution types"
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
"required": ["summary"]
|
| 83 |
+
}
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"name": "get_calendar",
|
| 87 |
+
"description": """Retrieve a specific calendar by its ID (supports 'primary').
|
| 88 |
+
|
| 89 |
+
Returns calendar metadata following Google Calendar API v3 structure.
|
| 90 |
+
Supports using the special keyword 'primary' as the calendar identifier to
|
| 91 |
+
target the user's primary calendar.
|
| 92 |
+
|
| 93 |
+
Request Body Requirements:
|
| 94 |
+
- calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary'
|
| 95 |
+
|
| 96 |
+
Response Structure:
|
| 97 |
+
- Returns calendar with Google Calendar API v3 format:
|
| 98 |
+
* kind: "calendar#calendar"
|
| 99 |
+
* etag: ETag of the resource
|
| 100 |
+
* id: Unique calendar identifier
|
| 101 |
+
* summary: Calendar title
|
| 102 |
+
* description: Calendar description (if present)
|
| 103 |
+
* location: Calendar location (if present)
|
| 104 |
+
* timeZone: Calendar timezone
|
| 105 |
+
* conferenceProperties: Conference settings (if present)
|
| 106 |
+
|
| 107 |
+
Status Codes:
|
| 108 |
+
- 200: Success - Calendar retrieved successfully
|
| 109 |
+
- 404: Not Found - Calendar not found
|
| 110 |
+
- 500: Internal Server Error""",
|
| 111 |
+
"inputSchema": {
|
| 112 |
+
"type": "object",
|
| 113 |
+
"properties": {
|
| 114 |
+
"calendarId": {
|
| 115 |
+
"type": "string",
|
| 116 |
+
"description": "Unique calendar identifier (UUID) or the keyword 'primary'",
|
| 117 |
+
"minLength": 1,
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
"required": ["calendarId"]
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"name": "patch_calendar",
|
| 125 |
+
"description": """Partially update calendar metadata (cannot change which calendar is primary).
|
| 126 |
+
|
| 127 |
+
Partially updates calendar metadata following Google Calendar API v3 structure.
|
| 128 |
+
Only provided fields will be updated, others remain unchanged.
|
| 129 |
+
You can update both primary and secondary calendars.
|
| 130 |
+
Supports using the special keyword 'primary' as the calendar identifier.
|
| 131 |
+
|
| 132 |
+
Request Body Requirements:
|
| 133 |
+
- calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary'
|
| 134 |
+
|
| 135 |
+
Request Body (all optional):
|
| 136 |
+
- summary: Calendar title (1-255 characters)
|
| 137 |
+
- description: Calendar description (max 1000 characters)
|
| 138 |
+
- location: Geographic location (max 500 characters)
|
| 139 |
+
- timeZone: Calendar timezone in IANA format
|
| 140 |
+
- conferenceProperties: Conference solution settings
|
| 141 |
+
|
| 142 |
+
Restrictions:
|
| 143 |
+
- Cannot modify which calendar is primary (the is_primary flag is immutable via PATCH)
|
| 144 |
+
- At least one field must be provided for update
|
| 145 |
+
- Primary status is automatically assigned and protected
|
| 146 |
+
|
| 147 |
+
Response Structure:
|
| 148 |
+
- Returns updated calendar with Google Calendar API v3 format
|
| 149 |
+
|
| 150 |
+
Status Codes:
|
| 151 |
+
- 200: Success - Calendar updated successfully
|
| 152 |
+
- 400: Bad Request - No fields provided or attempt to modify primary status
|
| 153 |
+
- 404: Not Found - Calendar not found
|
| 154 |
+
- 500: Internal Server Error""",
|
| 155 |
+
"inputSchema": {
|
| 156 |
+
"type": "object",
|
| 157 |
+
"properties": {
|
| 158 |
+
"calendarId": {
|
| 159 |
+
"type": "string",
|
| 160 |
+
"description": "Unique calendar identifier (UUID) or the keyword 'primary'",
|
| 161 |
+
"minLength": 1,
|
| 162 |
+
},
|
| 163 |
+
"summary": {
|
| 164 |
+
"type": "string",
|
| 165 |
+
"description": "Calendar title (1-255 characters)",
|
| 166 |
+
"minLength": 1,
|
| 167 |
+
"maxLength": 255,
|
| 168 |
+
},
|
| 169 |
+
"description": {
|
| 170 |
+
"type": "string",
|
| 171 |
+
"description": "Calendar description (max 1000 characters)",
|
| 172 |
+
"maxLength": 1000,
|
| 173 |
+
},
|
| 174 |
+
"location": {
|
| 175 |
+
"type": "string",
|
| 176 |
+
"description": "Geographic location (max 500 characters)",
|
| 177 |
+
"maxLength": 500,
|
| 178 |
+
},
|
| 179 |
+
"timeZone": {
|
| 180 |
+
"type": "string",
|
| 181 |
+
"description": "Calendar timezone in IANA format",
|
| 182 |
+
},
|
| 183 |
+
"conferenceProperties": {
|
| 184 |
+
"type": "object",
|
| 185 |
+
"description": "Conference solution settings",
|
| 186 |
+
"properties": {
|
| 187 |
+
"allowedConferenceSolutionTypes": {
|
| 188 |
+
"type": "array",
|
| 189 |
+
"items": {
|
| 190 |
+
"type": "string",
|
| 191 |
+
"enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"]
|
| 192 |
+
},
|
| 193 |
+
"description": "Allowed conference solution types"
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"required": ["calendarId"]
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"name": "update_calendar",
|
| 203 |
+
"description": """Fully update calendar metadata (cannot change which calendar is primary).
|
| 204 |
+
|
| 205 |
+
Completely updates calendar metadata following Google Calendar API v3 structure.
|
| 206 |
+
All provided fields replace existing values. Optional fields omitted will remain unchanged
|
| 207 |
+
in the current implementation. You can update both primary and secondary calendars.
|
| 208 |
+
Primary calendar status (which calendar is primary) cannot be modified via this endpoint.
|
| 209 |
+
Supports using the special keyword 'primary' as the calendar identifier.
|
| 210 |
+
|
| 211 |
+
Request Body Requirements:
|
| 212 |
+
- calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary'
|
| 213 |
+
|
| 214 |
+
Request Body (all optional; null clears for description/location/conferenceProperties):
|
| 215 |
+
- summary: Calendar title (1-255 characters)
|
| 216 |
+
- description: Calendar description (max 1000 characters) - null to clear
|
| 217 |
+
- location: Geographic location (max 500 characters) - null to clear
|
| 218 |
+
- timeZone: Calendar timezone in IANA format
|
| 219 |
+
- conferenceProperties: Conference solution settings - null to clear
|
| 220 |
+
|
| 221 |
+
Restrictions:
|
| 222 |
+
- Cannot modify which calendar is primary (the is_primary flag is immutable via PUT)
|
| 223 |
+
|
| 224 |
+
Response Structure:
|
| 225 |
+
- Returns updated calendar with Google Calendar API v3 format
|
| 226 |
+
|
| 227 |
+
Status Codes:
|
| 228 |
+
- 200: Success - Calendar updated successfully
|
| 229 |
+
- 400: Bad Request - Invalid update data or attempt to modify primary status
|
| 230 |
+
- 404: Not Found - Calendar not found
|
| 231 |
+
- 500: Internal Server Error""",
|
| 232 |
+
"inputSchema": {
|
| 233 |
+
"type": "object",
|
| 234 |
+
"properties": {
|
| 235 |
+
"calendarId": {
|
| 236 |
+
"type": "string",
|
| 237 |
+
"description": "Unique calendar identifier (UUID) or the keyword 'primary'",
|
| 238 |
+
"minLength": 1
|
| 239 |
+
},
|
| 240 |
+
"summary": {
|
| 241 |
+
"type": "string",
|
| 242 |
+
"description": "Calendar title (1-255 characters)",
|
| 243 |
+
"minLength": 1,
|
| 244 |
+
"maxLength": 255
|
| 245 |
+
},
|
| 246 |
+
"description": {
|
| 247 |
+
"type": "string",
|
| 248 |
+
"description": "Calendar description (max 1000 characters)",
|
| 249 |
+
"maxLength": 1000
|
| 250 |
+
},
|
| 251 |
+
"location": {
|
| 252 |
+
"type": "string",
|
| 253 |
+
"description": "Geographic location (max 500 characters)",
|
| 254 |
+
"maxLength": 500
|
| 255 |
+
},
|
| 256 |
+
"timeZone": {
|
| 257 |
+
"type": "string",
|
| 258 |
+
"description": "Calendar timezone in IANA format"
|
| 259 |
+
},
|
| 260 |
+
"conferenceProperties": {
|
| 261 |
+
"type": "object",
|
| 262 |
+
"description": "Conference solution settings",
|
| 263 |
+
"properties": {
|
| 264 |
+
"allowedConferenceSolutionTypes": {
|
| 265 |
+
"type": "array",
|
| 266 |
+
"items": {
|
| 267 |
+
"type": "string",
|
| 268 |
+
"enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"]
|
| 269 |
+
},
|
| 270 |
+
"description": "Allowed conference solution types"
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
},
|
| 275 |
+
"required": ["calendarId"]
|
| 276 |
+
}
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"name": "delete_calendar",
|
| 280 |
+
"description": """Delete a secondary calendar (cannot delete primary calendar).
|
| 281 |
+
|
| 282 |
+
Deletes a calendar following Google Calendar API v3 behavior.
|
| 283 |
+
Primary calendars cannot be deleted - use clear operation instead.
|
| 284 |
+
|
| 285 |
+
Request Body Requirements:
|
| 286 |
+
- calendarId: Required. Unique calendar identifier (UUID)
|
| 287 |
+
|
| 288 |
+
Primary Calendar Protection:
|
| 289 |
+
- Primary calendars cannot be deleted
|
| 290 |
+
- Attempting to delete primary calendar returns 400 Bad Request
|
| 291 |
+
- Use clear_calendar tool to remove events from primary calendar
|
| 292 |
+
|
| 293 |
+
Cascade Behavior:
|
| 294 |
+
- Deleting a calendar also deletes all associated events
|
| 295 |
+
- This operation is irreversible
|
| 296 |
+
|
| 297 |
+
Response Structure:
|
| 298 |
+
- Returns 204 No Content on successful deletion
|
| 299 |
+
- No response body as per Google Calendar API v3
|
| 300 |
+
|
| 301 |
+
Status Codes:
|
| 302 |
+
- 204: No Content - Calendar deleted successfully
|
| 303 |
+
- 400: Bad Request - Attempted to delete primary calendar
|
| 304 |
+
- 404: Not Found - Calendar not found
|
| 305 |
+
- 500: Internal Server Error""",
|
| 306 |
+
"inputSchema": {
|
| 307 |
+
"type": "object",
|
| 308 |
+
"properties": {
|
| 309 |
+
"calendarId": {
|
| 310 |
+
"type": "string",
|
| 311 |
+
"description": "Unique calendar identifier (UUID)",
|
| 312 |
+
"minLength": 1,
|
| 313 |
+
}
|
| 314 |
+
},
|
| 315 |
+
"required": ["calendarId"]
|
| 316 |
+
}
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
"name": "clear_calendar",
|
| 320 |
+
"description": """Clear all events from a calendar (useful for primary calendars).
|
| 321 |
+
|
| 322 |
+
Clears all events from a calendar following Google Calendar API v3 behavior.
|
| 323 |
+
This is the recommended way to "reset" a primary calendar since primary calendars cannot be deleted.
|
| 324 |
+
|
| 325 |
+
Request Body Requirements:
|
| 326 |
+
- calendarId: Required. Unique calendar identifier (UUID)
|
| 327 |
+
|
| 328 |
+
Operation Details:
|
| 329 |
+
- Removes all events from the specified calendar
|
| 330 |
+
- Calendar metadata remains unchanged
|
| 331 |
+
- Useful for primary calendars that cannot be deleted
|
| 332 |
+
- Can be used on any calendar (primary or secondary)
|
| 333 |
+
|
| 334 |
+
Response:
|
| 335 |
+
- Returns 204 No Content on successful clear (no response body)
|
| 336 |
+
|
| 337 |
+
Status Codes:
|
| 338 |
+
- 204: No Content - Calendar cleared successfully
|
| 339 |
+
- 404: Not Found - Calendar not found
|
| 340 |
+
- 500: Internal Server Error""",
|
| 341 |
+
"inputSchema": {
|
| 342 |
+
"type": "object",
|
| 343 |
+
"properties": {
|
| 344 |
+
"calendarId": {
|
| 345 |
+
"type": "string",
|
| 346 |
+
"description": "Unique calendar identifier (UUID)",
|
| 347 |
+
"minLength": 1,
|
| 348 |
+
}
|
| 349 |
+
},
|
| 350 |
+
"required": ["calendarId"]
|
| 351 |
+
}
|
| 352 |
+
},
|
| 353 |
+
]
|
server/calendar_mcp/tools/colors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Colors Tools Module
|
| 3 |
+
|
| 4 |
+
This module contains tools related to Google Calendar color definitions.
|
| 5 |
+
Provides static color palettes for calendars and events.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
COLORS_TOOLS = [
|
| 9 |
+
{
|
| 10 |
+
"name": "get_colors",
|
| 11 |
+
"description": """Retrieve the color definitions for calendars and events.
|
| 12 |
+
|
| 13 |
+
Returns the global palette of color definitions used by Google Calendar.
|
| 14 |
+
This endpoint provides static color data and does not require authentication
|
| 15 |
+
or user context as it returns predefined color schemes.
|
| 16 |
+
|
| 17 |
+
Color Structure:
|
| 18 |
+
- calendar: Object mapping color IDs to calendar color definitions
|
| 19 |
+
- event: Object mapping color IDs to event color definitions
|
| 20 |
+
|
| 21 |
+
Each color definition contains:
|
| 22 |
+
- background: The background color (hex format)
|
| 23 |
+
- foreground: The foreground color for text (hex format)
|
| 24 |
+
|
| 25 |
+
Available Calendar Colors: 24 predefined colors (IDs 1-24)
|
| 26 |
+
Available Event Colors: 11 predefined colors (IDs 1-11)
|
| 27 |
+
|
| 28 |
+
Usage Examples:
|
| 29 |
+
- Get all available colors for UI color pickers
|
| 30 |
+
- Validate color IDs before setting calendar/event colors
|
| 31 |
+
- Display color options to users
|
| 32 |
+
|
| 33 |
+
Response Structure:
|
| 34 |
+
- Returns colors with Google Calendar API v3 format:
|
| 35 |
+
* kind: "calendar#colors"
|
| 36 |
+
* updated: Last modification timestamp
|
| 37 |
+
* calendar: Object with calendar color definitions
|
| 38 |
+
* event: Object with event color definitions
|
| 39 |
+
|
| 40 |
+
Color Examples:
|
| 41 |
+
- Calendar Color 1: Background "#ac725e" (brown), Foreground "#1d1d1d" (dark)
|
| 42 |
+
- Event Color 1: Background "#a4bdfc" (light blue), Foreground "#1d1d1d" (dark)
|
| 43 |
+
- Event Color 11: Background "#dc2127" (red), Foreground "#1d1d1d" (dark)
|
| 44 |
+
|
| 45 |
+
API Endpoint: GET /colors
|
| 46 |
+
|
| 47 |
+
Status Codes:
|
| 48 |
+
- 200: Success - Color definitions retrieved
|
| 49 |
+
- 500: Internal Server Error""",
|
| 50 |
+
"inputSchema": {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"properties": {},
|
| 53 |
+
"required": [],
|
| 54 |
+
"additionalProperties": False
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
]
|
server/calendar_mcp/tools/events.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
server/calendar_mcp/tools/freebusy.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FreeBusy MCP tools for Google Calendar API v3 compatibility
|
| 3 |
+
All FreeBusy API endpoints with clean tool definitions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
FREEBUSY_TOOLS = [
|
| 7 |
+
{
|
| 8 |
+
"name": "query_freebusy",
|
| 9 |
+
"description": """Query free/busy information for a set of calendars.
|
| 10 |
+
|
| 11 |
+
Returns free/busy information for specified calendars following Google Calendar API v3 structure.
|
| 12 |
+
Shows busy time periods when calendars have confirmed events that block time.
|
| 13 |
+
Essential for scheduling meetings and finding available time slots.
|
| 14 |
+
|
| 15 |
+
Request Body Requirements:
|
| 16 |
+
- timeMin: Required. Lower bound for the query (RFC3339 timestamp)
|
| 17 |
+
- timeMax: Required. Upper bound for the query (RFC3339 timestamp)
|
| 18 |
+
- items: Required. List of calendar identifiers to query
|
| 19 |
+
|
| 20 |
+
Optional Parameters:
|
| 21 |
+
- timeZone: Time zone for the query (default: UTC)
|
| 22 |
+
- groupExpansionMax: Maximum number of calendars to expand for groups
|
| 23 |
+
- calendarExpansionMax: Maximum number of events to expand for calendars
|
| 24 |
+
|
| 25 |
+
Time Period Handling:
|
| 26 |
+
- Only confirmed events block time (status = "confirmed")
|
| 27 |
+
- Transparent events do not block time
|
| 28 |
+
- Overlapping events are merged into continuous busy periods
|
| 29 |
+
- Results are clipped to the requested time range
|
| 30 |
+
|
| 31 |
+
Response Structure:
|
| 32 |
+
- Returns FreeBusy resource with Google Calendar API v3 format:
|
| 33 |
+
* kind: "calendar#freeBusy"
|
| 34 |
+
* timeMin: Query start time
|
| 35 |
+
* timeMax: Query end time
|
| 36 |
+
* calendars: Object with calendar IDs as keys
|
| 37 |
+
* Each calendar contains:
|
| 38 |
+
- busy: Array of busy time periods
|
| 39 |
+
- errors: Array of errors (if any)
|
| 40 |
+
|
| 41 |
+
Status Codes:
|
| 42 |
+
- 200: Success - FreeBusy information retrieved successfully
|
| 43 |
+
- 400: Bad Request - Invalid query parameters
|
| 44 |
+
- 404: Not Found - Calendar not found
|
| 45 |
+
- 500: Internal Server Error""",
|
| 46 |
+
"inputSchema": {
|
| 47 |
+
"type": "object",
|
| 48 |
+
"properties": {
|
| 49 |
+
"timeMin": {
|
| 50 |
+
"type": "string",
|
| 51 |
+
"description": "Lower bound for the query (RFC3339 timestamp)"
|
| 52 |
+
},
|
| 53 |
+
"timeMax": {
|
| 54 |
+
"type": "string",
|
| 55 |
+
"description": "Upper bound for the query (RFC3339 timestamp)"
|
| 56 |
+
},
|
| 57 |
+
"timeZone": {
|
| 58 |
+
"type": "string",
|
| 59 |
+
"description": "Time zone for the query (IANA timezone, default: UTC)"
|
| 60 |
+
},
|
| 61 |
+
"groupExpansionMax": {
|
| 62 |
+
"type": "integer",
|
| 63 |
+
"description": "Maximum number of calendars to expand for groups"
|
| 64 |
+
},
|
| 65 |
+
"calendarExpansionMax": {
|
| 66 |
+
"type": "integer",
|
| 67 |
+
"description": "Maximum number of events to expand for calendars"
|
| 68 |
+
},
|
| 69 |
+
"items": {
|
| 70 |
+
"type": "array",
|
| 71 |
+
"description": "List of calendar identifiers to query",
|
| 72 |
+
"items": {
|
| 73 |
+
"type": "object",
|
| 74 |
+
"properties": {
|
| 75 |
+
"id": {
|
| 76 |
+
"type": "string",
|
| 77 |
+
"description": "Calendar identifier"
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
"required": ["id"]
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
+
"required": ["timeMin", "timeMax", "items"]
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Additional helper information for FreeBusy functionality
|
| 91 |
+
FREEBUSY_CONCEPTS = {
|
| 92 |
+
"busy_time_calculation": {
|
| 93 |
+
"description": "How busy times are calculated from calendar events",
|
| 94 |
+
"rules": [
|
| 95 |
+
"Only events with status='confirmed' block time",
|
| 96 |
+
"Events with transparency='transparent' do not block time",
|
| 97 |
+
"All-day events block the entire day",
|
| 98 |
+
"Overlapping events are merged into continuous periods",
|
| 99 |
+
"Event times are clipped to the query time range"
|
| 100 |
+
]
|
| 101 |
+
},
|
| 102 |
+
"time_zone_handling": {
|
| 103 |
+
"description": "How timezones are handled in FreeBusy queries",
|
| 104 |
+
"details": [
|
| 105 |
+
"Query times should be in RFC3339 format",
|
| 106 |
+
"All calculations are done in UTC internally",
|
| 107 |
+
"Results are returned in the requested timezone",
|
| 108 |
+
"Event times are converted from their native timezone",
|
| 109 |
+
"Default timezone is UTC if not specified"
|
| 110 |
+
]
|
| 111 |
+
},
|
| 112 |
+
"error_handling": {
|
| 113 |
+
"description": "How errors are handled in FreeBusy responses",
|
| 114 |
+
"scenarios": [
|
| 115 |
+
"Calendar not found: Returns error in calendar result",
|
| 116 |
+
"No access to calendar: Returns error in calendar result",
|
| 117 |
+
"Invalid time range: Returns 400 Bad Request",
|
| 118 |
+
"Too many calendars: Returns 400 Bad Request",
|
| 119 |
+
"Internal errors: Calendar marked with backend error"
|
| 120 |
+
]
|
| 121 |
+
},
|
| 122 |
+
"performance_considerations": {
|
| 123 |
+
"description": "Performance aspects of FreeBusy queries",
|
| 124 |
+
"guidelines": [
|
| 125 |
+
"Limit time range to reasonable periods (max 366 days)",
|
| 126 |
+
"Limit number of calendars per query (max 50)",
|
| 127 |
+
"Use batch queries for multiple time ranges efficiently",
|
| 128 |
+
"Consider caching for frequently accessed calendars"
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Sample usage examples for documentation
|
| 135 |
+
FREEBUSY_EXAMPLES = {
|
| 136 |
+
"simple_query": {
|
| 137 |
+
"description": "Query busy times for a single calendar today",
|
| 138 |
+
"request": {
|
| 139 |
+
"timeMin": "2024-01-15T00:00:00Z",
|
| 140 |
+
"timeMax": "2024-01-16T00:00:00Z",
|
| 141 |
+
"timeZone": "UTC",
|
| 142 |
+
"items": [{"id": "primary"}]
|
| 143 |
+
}
|
| 144 |
+
},
|
| 145 |
+
"multiple_calendars": {
|
| 146 |
+
"description": "Query multiple calendars for the next week",
|
| 147 |
+
"request": {
|
| 148 |
+
"timeMin": "2024-01-15T00:00:00Z",
|
| 149 |
+
"timeMax": "2024-01-22T00:00:00Z",
|
| 150 |
+
"timeZone": "America/New_York",
|
| 151 |
+
"items": [
|
| 152 |
+
{"id": "primary"},
|
| 153 |
+
{"id": "work@company.com"},
|
| 154 |
+
{"id": "team@company.com"}
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
}
|
server/calendar_mcp/tools/settings.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Settings Tools Module
|
| 3 |
+
|
| 4 |
+
This module contains tools related to user settings management.
|
| 5 |
+
Follows Google Calendar API v3 structure for settings operations.
|
| 6 |
+
Covers listing and retrieving settings only.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
SETTINGS_TOOLS = [
|
| 10 |
+
{
|
| 11 |
+
"name": "get_settings",
|
| 12 |
+
"description": """Retrieve a specific user setting by ID.
|
| 13 |
+
|
| 14 |
+
Returns the value of a setting resource by its ID.
|
| 15 |
+
Follows Google Calendar API v3 `/settings/{settingId}` structure.
|
| 16 |
+
|
| 17 |
+
Request Body Requirements:
|
| 18 |
+
- settingId: Required. Unique identifier of the setting (e.g., "timezone")
|
| 19 |
+
|
| 20 |
+
Response Structure:
|
| 21 |
+
- kind: "calendar#setting"
|
| 22 |
+
- etag: ETag of the setting
|
| 23 |
+
- id: Unique setting identifier
|
| 24 |
+
- value: Current value of the setting
|
| 25 |
+
- user_id: ID of the user who owns the setting
|
| 26 |
+
|
| 27 |
+
Status Codes:
|
| 28 |
+
- 200: Success - Setting returned successfully
|
| 29 |
+
- 404: Not Found - No setting exists with the given ID
|
| 30 |
+
- 500: Internal Server Error""",
|
| 31 |
+
"inputSchema": {
|
| 32 |
+
"type": "object",
|
| 33 |
+
"properties": {
|
| 34 |
+
"settingId": {
|
| 35 |
+
"type": "string",
|
| 36 |
+
"description": "Unique identifier of the setting (e.g., 'timezone')",
|
| 37 |
+
"minLength": 1
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"required": ["settingId"]
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"name": "list_settings",
|
| 45 |
+
"description": """List all visible user settings.
|
| 46 |
+
|
| 47 |
+
Retrieves all settings that are visible to the authenticated user.
|
| 48 |
+
Follows the structure of Google Calendar API v3 `/settings` endpoint.
|
| 49 |
+
|
| 50 |
+
No request body required.
|
| 51 |
+
|
| 52 |
+
Response Structure:
|
| 53 |
+
- kind: "calendar#settings"
|
| 54 |
+
- etag: ETag for the entire settings collection
|
| 55 |
+
- items: Array of setting resources, each containing:
|
| 56 |
+
* kind: "calendar#setting"
|
| 57 |
+
* etag: ETag of the setting
|
| 58 |
+
* id: Unique identifier of the setting (e.g., "timezone")
|
| 59 |
+
* value: Current value of the setting
|
| 60 |
+
|
| 61 |
+
Status Codes:
|
| 62 |
+
- 200: Success - List of settings returned
|
| 63 |
+
- 500: Internal Server Error""",
|
| 64 |
+
"inputSchema": {
|
| 65 |
+
"type": "object",
|
| 66 |
+
"properties": {},
|
| 67 |
+
"required": []
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"name": "watch_settings",
|
| 72 |
+
"description": """Watch for changes to user settings.
|
| 73 |
+
|
| 74 |
+
Sets up a notification channel to receive updates when settings change.
|
| 75 |
+
Follows Google Calendar API v3 `/settings/watch` structure.
|
| 76 |
+
|
| 77 |
+
Creates a watch channel that will send webhook notifications to the specified
|
| 78 |
+
address whenever settings are modified. The channel will automatically expire
|
| 79 |
+
after a maximum of 24 hours or at the specified expiration time.
|
| 80 |
+
|
| 81 |
+
Request Body Requirements:
|
| 82 |
+
- id: Required. Unique identifier for the channel
|
| 83 |
+
- type: Optional. Channel type (defaults to "web_hook")
|
| 84 |
+
- address: Required. URL where notifications will be sent
|
| 85 |
+
- token: Optional. Verification token for webhook security
|
| 86 |
+
- params: Optional. Additional parameters as key-value pairs
|
| 87 |
+
|
| 88 |
+
Optional Parameters:
|
| 89 |
+
- token: Optional token for webhook authentication
|
| 90 |
+
- params: Additional channel parameters
|
| 91 |
+
|
| 92 |
+
Response Structure:
|
| 93 |
+
- kind: "api#channel"
|
| 94 |
+
- id: Channel identifier
|
| 95 |
+
- resourceId: Resource being watched
|
| 96 |
+
- resourceUri: URI of the resource ("/settings")
|
| 97 |
+
- token: Verification token (if provided)
|
| 98 |
+
- expiration: Channel expiration time
|
| 99 |
+
|
| 100 |
+
Webhook Notification Format:
|
| 101 |
+
Your webhook will receive POST requests with this payload:
|
| 102 |
+
{
|
| 103 |
+
"kind": "api#channel",
|
| 104 |
+
"id": "channel-id",
|
| 105 |
+
"resourceId": "settings-user-id",
|
| 106 |
+
"resourceUri": "/settings",
|
| 107 |
+
"eventType": "update|insert|delete",
|
| 108 |
+
"resourceState": "sync",
|
| 109 |
+
"timestamp": "2024-10-01T18:30:00Z",
|
| 110 |
+
"data": {
|
| 111 |
+
"kind": "calendar#setting",
|
| 112 |
+
"id": "setting-id",
|
| 113 |
+
"value": "new-value",
|
| 114 |
+
"oldValue": "previous-value",
|
| 115 |
+
"user_id": "user-id"
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
Status Codes:
|
| 120 |
+
- 200: Success - Watch channel created
|
| 121 |
+
- 400: Bad Request - Invalid request parameters
|
| 122 |
+
- 500: Internal Server Error""",
|
| 123 |
+
"inputSchema": {
|
| 124 |
+
"type": "object",
|
| 125 |
+
"properties": {
|
| 126 |
+
"id": {
|
| 127 |
+
"type": "string",
|
| 128 |
+
"description": "Unique identifier for the watch channel",
|
| 129 |
+
"minLength": 1
|
| 130 |
+
},
|
| 131 |
+
"type": {
|
| 132 |
+
"type": "string",
|
| 133 |
+
"description": "Type of notification channel",
|
| 134 |
+
"default": "web_hook",
|
| 135 |
+
"enum": ["web_hook"]
|
| 136 |
+
},
|
| 137 |
+
"address": {
|
| 138 |
+
"type": "string",
|
| 139 |
+
"description": "URL where webhook notifications will be sent",
|
| 140 |
+
"format": "uri",
|
| 141 |
+
"minLength": 1
|
| 142 |
+
},
|
| 143 |
+
"token": {
|
| 144 |
+
"type": "string",
|
| 145 |
+
"description": "Optional verification token for webhook security"
|
| 146 |
+
},
|
| 147 |
+
"params": {
|
| 148 |
+
"type": "object",
|
| 149 |
+
"description": "Additional parameters as key-value pairs",
|
| 150 |
+
"properties": {
|
| 151 |
+
"ttl": {
|
| 152 |
+
"type": "string",
|
| 153 |
+
"description": "Time to live in seconds (string)."
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
"required": ["id", "type", "address"]
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
]
|
server/calendar_mcp/tools/users.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP Tools for User Management
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
USERS_TOOLS = [
|
| 6 |
+
{
|
| 7 |
+
"name": "get_user_by_email",
|
| 8 |
+
"description": """Get user details by email address
|
| 9 |
+
|
| 10 |
+
This tool retrieves complete user information using their email address, including user ID, name, and other profile details.
|
| 11 |
+
|
| 12 |
+
**API Endpoint:** GET /users/email/{email}
|
| 13 |
+
|
| 14 |
+
**Parameters:**
|
| 15 |
+
- email (required): The email address to lookup
|
| 16 |
+
|
| 17 |
+
**Returns:**
|
| 18 |
+
Complete user information including id, name, given_name, family_name, picture, locale, timezone, is_active, is_verified, etc.
|
| 19 |
+
|
| 20 |
+
**Example:**
|
| 21 |
+
```json
|
| 22 |
+
{
|
| 23 |
+
"email": "user@example.com"
|
| 24 |
+
}
|
| 25 |
+
```""",
|
| 26 |
+
"inputSchema": {
|
| 27 |
+
"type": "object",
|
| 28 |
+
"properties": {
|
| 29 |
+
"email": {
|
| 30 |
+
"type": "string",
|
| 31 |
+
"description": "Email address to lookup"
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
"required": ["email"]
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
]
|
server/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Data package"""
|
server/data/enhanced_event_seed_data.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced Event Seed Data with all new fields and related tables
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_enhanced_event_seed_data() -> Dict[str, Any]:
|
| 11 |
+
"""
|
| 12 |
+
Generate comprehensive seed data demonstrating all Event model features including:
|
| 13 |
+
- All new Event fields (conference_data, reminders, extended_properties, etc.)
|
| 14 |
+
- Attendees with various statuses and roles
|
| 15 |
+
- Attachments (file URLs)
|
| 16 |
+
- Working location properties for different work modes
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
# Base datetime for consistent relative dates
|
| 20 |
+
base_date = datetime.now(timezone.utc).replace(hour=9, minute=0, second=0, microsecond=0)
|
| 21 |
+
|
| 22 |
+
# Office locations - required for working location properties
|
| 23 |
+
office_locations_data = [
|
| 24 |
+
{
|
| 25 |
+
"id": "office-building-1",
|
| 26 |
+
"buildingId": "building-1",
|
| 27 |
+
"deskId": None,
|
| 28 |
+
"floorId": None,
|
| 29 |
+
"floorSectionId": None,
|
| 30 |
+
"label": "TechCorp Main Campus - Building 1"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"id": "office-building-2-floor-3",
|
| 34 |
+
"buildingId": "building-2",
|
| 35 |
+
"deskId": "desk-W3-45",
|
| 36 |
+
"floorId": "floor-3",
|
| 37 |
+
"floorSectionId": "west-wing",
|
| 38 |
+
"label": "TechCorp Main Campus - Building 2, Floor 3, West Wing, Desk W3-45"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"id": "office-meeting-room-a",
|
| 42 |
+
"buildingId": "building-1",
|
| 43 |
+
"deskId": None,
|
| 44 |
+
"floorId": "floor-1",
|
| 45 |
+
"floorSectionId": "conference-area",
|
| 46 |
+
"label": "Conference Room A - Building 1"
|
| 47 |
+
}
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
# Events data - to match model exactly
|
| 51 |
+
events_data = [
|
| 52 |
+
{
|
| 53 |
+
"event_id": "event-corrected-001",
|
| 54 |
+
"calendar_id": "alice-projects",
|
| 55 |
+
"user_id": "alice_manager",
|
| 56 |
+
"organizer_id": "alice_manager",
|
| 57 |
+
"organizer_email": "alice.johnson@techcorp.com",
|
| 58 |
+
"organizer_display_name": "Alice Johnson",
|
| 59 |
+
"organizer_self": True,
|
| 60 |
+
"summary": "Sprint Planning & Architecture Review",
|
| 61 |
+
"description": "Detailed sprint planning session with architecture discussion for Q4 features. We'll review user stories, estimate effort, and plan the technical approach.",
|
| 62 |
+
"location": "Conference Room A, Building 1",
|
| 63 |
+
"start_datetime": base_date,
|
| 64 |
+
"end_datetime": base_date + timedelta(hours=2),
|
| 65 |
+
"start_timezone": "America/New_York",
|
| 66 |
+
"end_timezone": "America/New_York",
|
| 67 |
+
"originalStartTime_date": None,
|
| 68 |
+
"originalStartTime_dateTime": base_date,
|
| 69 |
+
"originalStartTime_timeZone": "America/New_York",
|
| 70 |
+
"recurrence": None,
|
| 71 |
+
"status": "confirmed",
|
| 72 |
+
"visibility": "default",
|
| 73 |
+
"color_id": "7",
|
| 74 |
+
"eventType": "default",
|
| 75 |
+
"focusTimeProperties": None,
|
| 76 |
+
"guestsCanInviteOthers": True,
|
| 77 |
+
"guestsCanModify": False,
|
| 78 |
+
"guestsCanSeeOtherGuests": True,
|
| 79 |
+
"outOfOfficeProperties": None,
|
| 80 |
+
"sequence": 1,
|
| 81 |
+
"iCalUID":"event-corrected-001@gmail.com",
|
| 82 |
+
"source": {
|
| 83 |
+
"title": "Sprint Planning Board",
|
| 84 |
+
"url": "https://jira.techcorp.com/sprint-planning-q4"
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"event_id": "event-corrected-002",
|
| 89 |
+
"calendar_id": "bob-development",
|
| 90 |
+
"user_id": "bob_developer",
|
| 91 |
+
"organizer_id": "bob_developer",
|
| 92 |
+
"organizer_email": "bob.smith@techcorp.com",
|
| 93 |
+
"organizer_display_name": "Bob Smith",
|
| 94 |
+
"organizer_self": True,
|
| 95 |
+
"summary": "Deep Work: Core Algorithm Implementation",
|
| 96 |
+
"description": "Focused development time for implementing the new search algorithm. No interruptions please.",
|
| 97 |
+
"location": "Developer Workspace, Building 2",
|
| 98 |
+
"start_datetime": base_date + timedelta(days=1, hours=1),
|
| 99 |
+
"end_datetime": base_date + timedelta(days=1, hours=4),
|
| 100 |
+
"start_timezone": "America/Los_Angeles",
|
| 101 |
+
"end_timezone": "America/Los_Angeles",
|
| 102 |
+
"originalStartTime_date": None,
|
| 103 |
+
"originalStartTime_dateTime": base_date + timedelta(days=1, hours=1),
|
| 104 |
+
"originalStartTime_timeZone": "America/Los_Angeles",
|
| 105 |
+
"recurrence": None,
|
| 106 |
+
"status": "confirmed",
|
| 107 |
+
"visibility": "private",
|
| 108 |
+
"color_id": "9",
|
| 109 |
+
"eventType": "focusTime",
|
| 110 |
+
"focusTimeProperties": {
|
| 111 |
+
"autoDeclineMode": "declineNone",
|
| 112 |
+
"declineMessage": "I'm in focus time. Please reschedule or reach out via Slack for urgent matters.",
|
| 113 |
+
"chatStatus": "doNotDisturb"
|
| 114 |
+
},
|
| 115 |
+
"guestsCanInviteOthers": False,
|
| 116 |
+
"guestsCanModify": False,
|
| 117 |
+
"guestsCanSeeOtherGuests": False,
|
| 118 |
+
"outOfOfficeProperties": None,
|
| 119 |
+
"sequence": 0,
|
| 120 |
+
"source": None,
|
| 121 |
+
"iCalUID":"event-corrected-002@gmail.com"
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"event_id": "event-corrected-003",
|
| 125 |
+
"calendar_id": "carol-primary",
|
| 126 |
+
"user_id": "carol_designer",
|
| 127 |
+
"organizer_id": "carol_designer",
|
| 128 |
+
"organizer_email": "carol.white@techcorp.com",
|
| 129 |
+
"organizer_display_name": "Carol White",
|
| 130 |
+
"organizer_self": True,
|
| 131 |
+
"summary": "Annual Leave - Family Vacation",
|
| 132 |
+
"description": "Taking time off for family vacation. Will have limited access to email.",
|
| 133 |
+
"location": "Bali, Indonesia",
|
| 134 |
+
"start_datetime": base_date + timedelta(days=14),
|
| 135 |
+
"end_datetime": base_date + timedelta(days=21),
|
| 136 |
+
"start_timezone": "Asia/Makassar",
|
| 137 |
+
"end_timezone": "Asia/Makassar",
|
| 138 |
+
"originalStartTime_date": None,
|
| 139 |
+
"originalStartTime_dateTime": base_date + timedelta(days=14),
|
| 140 |
+
"originalStartTime_timeZone": "Asia/Makassar",
|
| 141 |
+
"recurrence": None,
|
| 142 |
+
"status": "confirmed",
|
| 143 |
+
"visibility": "public",
|
| 144 |
+
"color_id": "4",
|
| 145 |
+
"eventType": "outOfOffice",
|
| 146 |
+
"focusTimeProperties": None,
|
| 147 |
+
"guestsCanInviteOthers": False,
|
| 148 |
+
"guestsCanModify": False,
|
| 149 |
+
"guestsCanSeeOtherGuests": True,
|
| 150 |
+
"outOfOfficeProperties": {
|
| 151 |
+
"autoDeclineMode": "declineAllConflictingInvitations",
|
| 152 |
+
"declineMessage": "I'm currently on vacation and won't be available. For urgent design matters, please contact Sarah (sarah@techcorp.com). I'll respond to messages when I return.",
|
| 153 |
+
"autoDeclineEventTypes": [
|
| 154 |
+
"default",
|
| 155 |
+
"focusTime",
|
| 156 |
+
"workingLocation"
|
| 157 |
+
]
|
| 158 |
+
},
|
| 159 |
+
"sequence": 0,
|
| 160 |
+
"source": None,
|
| 161 |
+
"iCalUID":"event-corrected-003@gmail.com"
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"event_id": "event-corrected-004",
|
| 165 |
+
"calendar_id": "bob-primary",
|
| 166 |
+
"user_id": "bob_developer",
|
| 167 |
+
"organizer_id": "bob_developer",
|
| 168 |
+
"organizer_email": "bob.smith@techcorp.com",
|
| 169 |
+
"organizer_display_name": "Bob Smith",
|
| 170 |
+
"organizer_self": True,
|
| 171 |
+
"summary": "Office Day - Collaboration Sessions",
|
| 172 |
+
"description": "In office today for team collaboration and pair programming sessions.",
|
| 173 |
+
"location": None,
|
| 174 |
+
"start_datetime": base_date + timedelta(days=2),
|
| 175 |
+
"end_datetime": base_date + timedelta(days=2, hours=8),
|
| 176 |
+
"start_timezone": "America/Los_Angeles",
|
| 177 |
+
"end_timezone": "America/Los_Angeles",
|
| 178 |
+
"originalStartTime_date": None,
|
| 179 |
+
"originalStartTime_dateTime": base_date + timedelta(days=2),
|
| 180 |
+
"originalStartTime_timeZone": "America/Los_Angeles",
|
| 181 |
+
"recurrence": None,
|
| 182 |
+
"status": "confirmed",
|
| 183 |
+
"visibility": "public",
|
| 184 |
+
"color_id": "2",
|
| 185 |
+
"eventType": "workingLocation",
|
| 186 |
+
"focusTimeProperties": None,
|
| 187 |
+
"guestsCanInviteOthers": True,
|
| 188 |
+
"guestsCanModify": False,
|
| 189 |
+
"guestsCanSeeOtherGuests": True,
|
| 190 |
+
"outOfOfficeProperties": None,
|
| 191 |
+
"sequence": 0,
|
| 192 |
+
"source": None,
|
| 193 |
+
"iCalUID":"event-corrected-004@gmail.com"
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"event_id": "event-corrected-005",
|
| 197 |
+
"calendar_id": "dave-primary",
|
| 198 |
+
"user_id": "dave_sales",
|
| 199 |
+
"organizer_id": "dave_sales",
|
| 200 |
+
"organizer_email": "dave.brown@techcorp.com",
|
| 201 |
+
"organizer_display_name": "Dave Brown",
|
| 202 |
+
"organizer_self": True,
|
| 203 |
+
"summary": "Dave's Birthday",
|
| 204 |
+
"description": "Happy Birthday Dave!",
|
| 205 |
+
"location": None,
|
| 206 |
+
"start_datetime": base_date + timedelta(days=30),
|
| 207 |
+
"end_datetime": base_date + timedelta(days=30, hours=1),
|
| 208 |
+
"start_timezone": None,
|
| 209 |
+
"end_timezone": None,
|
| 210 |
+
"originalStartTime_date": (base_date + timedelta(days=30)).date(),
|
| 211 |
+
"originalStartTime_dateTime": None,
|
| 212 |
+
"originalStartTime_timeZone": "UTC",
|
| 213 |
+
"recurrence": None,
|
| 214 |
+
"status": "confirmed",
|
| 215 |
+
"visibility": "public",
|
| 216 |
+
"color_id": "6",
|
| 217 |
+
"eventType": "birthday",
|
| 218 |
+
"focusTimeProperties": None,
|
| 219 |
+
"guestsCanInviteOthers": True,
|
| 220 |
+
"guestsCanModify": False,
|
| 221 |
+
"guestsCanSeeOtherGuests": True,
|
| 222 |
+
"outOfOfficeProperties": None,
|
| 223 |
+
"sequence": 0,
|
| 224 |
+
"source": None,
|
| 225 |
+
"iCalUID":"event-corrected-005@gmail.com"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"event_id": "event-corrected-006",
|
| 229 |
+
"calendar_id": "dave-sales",
|
| 230 |
+
"user_id": "dave_sales",
|
| 231 |
+
"organizer_id": "dave_sales",
|
| 232 |
+
"organizer_email": "dave.brown@techcorp.com",
|
| 233 |
+
"organizer_display_name": "Dave Brown",
|
| 234 |
+
"organizer_self": True,
|
| 235 |
+
"recurring_event_id": "rec-event-001",
|
| 236 |
+
"summary": "Enterprise Client Demo - TechCorp Solutions",
|
| 237 |
+
"description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.",
|
| 238 |
+
"location": "MegaCorp Headquarters, 123 Business Ave, New York, NY",
|
| 239 |
+
"start_datetime": base_date + timedelta(days=3, hours=2),
|
| 240 |
+
"end_datetime": base_date + timedelta(days=3, hours=4),
|
| 241 |
+
"start_timezone": "America/New_York",
|
| 242 |
+
"end_timezone": "America/New_York",
|
| 243 |
+
"originalStartTime_date": None,
|
| 244 |
+
"originalStartTime_dateTime": base_date + timedelta(days=3, hours=2),
|
| 245 |
+
"originalStartTime_timeZone": "America/New_York",
|
| 246 |
+
"recurrence": ["RRULE:COUNT=2"],
|
| 247 |
+
"status": "confirmed",
|
| 248 |
+
"visibility": "default",
|
| 249 |
+
"color_id": "1",
|
| 250 |
+
"eventType": "default",
|
| 251 |
+
"focusTimeProperties": None,
|
| 252 |
+
"guestsCanInviteOthers": False,
|
| 253 |
+
"guestsCanModify": False,
|
| 254 |
+
"guestsCanSeeOtherGuests": True,
|
| 255 |
+
"outOfOfficeProperties": None,
|
| 256 |
+
"sequence": 2,
|
| 257 |
+
"source": {
|
| 258 |
+
"title": "CRM System - MegaCorp Deal",
|
| 259 |
+
"url": "https://crm.techcorp.com/deals/megacorp-2024"
|
| 260 |
+
},
|
| 261 |
+
"iCalUID":"event-icalid-001@gmail.com"
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"event_id": "event-corrected-007",
|
| 265 |
+
"calendar_id": "dave-sales",
|
| 266 |
+
"user_id": "dave_sales",
|
| 267 |
+
"organizer_id": "dave_sales",
|
| 268 |
+
"organizer_email": "dave.brown@techcorp.com",
|
| 269 |
+
"organizer_display_name": "Dave Brown",
|
| 270 |
+
"organizer_self": True,
|
| 271 |
+
"recurring_event_id": "rec-event-001",
|
| 272 |
+
"summary": "Enterprise Client Demo - TechCorp Solutions",
|
| 273 |
+
"description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.",
|
| 274 |
+
"location": "MegaCorp Headquarters, 123 Business Ave, New York, NY",
|
| 275 |
+
"start_datetime": base_date + timedelta(days=4, hours=2),
|
| 276 |
+
"end_datetime": base_date + timedelta(days=4, hours=4),
|
| 277 |
+
"start_timezone": "America/New_York",
|
| 278 |
+
"end_timezone": "America/New_York",
|
| 279 |
+
"originalStartTime_date": None,
|
| 280 |
+
"originalStartTime_dateTime": base_date + timedelta(days=4, hours=2),
|
| 281 |
+
"originalStartTime_timeZone": "America/New_York",
|
| 282 |
+
"recurrence": ["RRULE:COUNT=2"],
|
| 283 |
+
"status": "confirmed",
|
| 284 |
+
"visibility": "default",
|
| 285 |
+
"color_id": "1",
|
| 286 |
+
"eventType": "default",
|
| 287 |
+
"focusTimeProperties": None,
|
| 288 |
+
"guestsCanInviteOthers": False,
|
| 289 |
+
"guestsCanModify": False,
|
| 290 |
+
"guestsCanSeeOtherGuests": True,
|
| 291 |
+
"outOfOfficeProperties": None,
|
| 292 |
+
"sequence": 2,
|
| 293 |
+
"source": {
|
| 294 |
+
"title": "CRM System - MegaCorp Deal",
|
| 295 |
+
"url": "https://crm.techcorp.com/deals/megacorp-2024"
|
| 296 |
+
},
|
| 297 |
+
"iCalUID":"event-icalid-001@gmail.com"
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"event_id": "event-corrected-008",
|
| 301 |
+
"calendar_id": "dave-sales",
|
| 302 |
+
"user_id": "dave_sales",
|
| 303 |
+
"organizer_id": "dave_sales",
|
| 304 |
+
"organizer_email": "dave.brown@techcorp.com",
|
| 305 |
+
"organizer_display_name": "Dave Brown",
|
| 306 |
+
"organizer_self": True,
|
| 307 |
+
"recurring_event_id": "rec-event-001",
|
| 308 |
+
"summary": "Enterprise Client Demo - TechCorp Solutions",
|
| 309 |
+
"description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.",
|
| 310 |
+
"location": "MegaCorp Headquarters, 123 Business Ave, New York, NY",
|
| 311 |
+
"start_datetime": base_date + timedelta(days=5, hours=2),
|
| 312 |
+
"end_datetime": base_date + timedelta(days=5, hours=4),
|
| 313 |
+
"start_timezone": "America/New_York",
|
| 314 |
+
"end_timezone": "America/New_York",
|
| 315 |
+
"originalStartTime_date": None,
|
| 316 |
+
"originalStartTime_dateTime": base_date + timedelta(days=5, hours=2),
|
| 317 |
+
"originalStartTime_timeZone": "America/New_York",
|
| 318 |
+
"recurrence": ["RRULE:COUNT=2"],
|
| 319 |
+
"status": "confirmed",
|
| 320 |
+
"visibility": "default",
|
| 321 |
+
"color_id": "1",
|
| 322 |
+
"eventType": "default",
|
| 323 |
+
"focusTimeProperties": None,
|
| 324 |
+
"guestsCanInviteOthers": False,
|
| 325 |
+
"guestsCanModify": False,
|
| 326 |
+
"guestsCanSeeOtherGuests": True,
|
| 327 |
+
"outOfOfficeProperties": None,
|
| 328 |
+
"sequence": 2,
|
| 329 |
+
"source": {
|
| 330 |
+
"title": "CRM System - MegaCorp Deal",
|
| 331 |
+
"url": "https://crm.techcorp.com/deals/megacorp-2024"
|
| 332 |
+
},
|
| 333 |
+
"iCalUID":"event-icalid-001@gmail.com"
|
| 334 |
+
}
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
recurring_event_data = [
|
| 338 |
+
{
|
| 339 |
+
"recurring_event_id":"rec-event-001",
|
| 340 |
+
"original_recurrence":["RRULE:COUNT=2"]
|
| 341 |
+
}
|
| 342 |
+
]
|
| 343 |
+
|
| 344 |
+
# ConferenceData - separate table (correct relationship name)
|
| 345 |
+
conference_data = [
|
| 346 |
+
{
|
| 347 |
+
"id": "conf-corrected-001",
|
| 348 |
+
"event_id": "event-corrected-001",
|
| 349 |
+
"request_id": "req-sprint-planning-001",
|
| 350 |
+
"solution_type": "hangoutsMeet",
|
| 351 |
+
"status_code": "success",
|
| 352 |
+
"meeting_uri": "https://meet.google.com/abc-defg-hij",
|
| 353 |
+
"label": "Sprint Planning Meet"
|
| 354 |
+
},
|
| 355 |
+
{
|
| 356 |
+
"id": "conf-corrected-002",
|
| 357 |
+
"event_id": "event-corrected-006",
|
| 358 |
+
"request_id": "req-client-demo-001",
|
| 359 |
+
"solution_type": "hangoutsMeet",
|
| 360 |
+
"status_code": "success",
|
| 361 |
+
"meeting_uri": "https://meet.google.com/enterprise-demo-xyz",
|
| 362 |
+
"label": "Client Demo Backup"
|
| 363 |
+
}
|
| 364 |
+
]
|
| 365 |
+
|
| 366 |
+
# BirthdayProperties - separate table (correct relationship name)
|
| 367 |
+
birthday_properties = [
|
| 368 |
+
{
|
| 369 |
+
"id": "birthday-corrected-001",
|
| 370 |
+
"event_id": "event-corrected-005",
|
| 371 |
+
"type": "birthday"
|
| 372 |
+
}
|
| 373 |
+
]
|
| 374 |
+
|
| 375 |
+
# ExtendedProperties - separate table with scope enum (correct relationship name)
|
| 376 |
+
extended_properties = [
|
| 377 |
+
{
|
| 378 |
+
"id": "ext-corrected-001",
|
| 379 |
+
"event_id": "event-corrected-001",
|
| 380 |
+
"scope": "private",
|
| 381 |
+
"properties": {
|
| 382 |
+
"departmentBudget": "engineering",
|
| 383 |
+
"projectCode": "PROJ-2024-Q4"
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"id": "ext-corrected-002",
|
| 388 |
+
"event_id": "event-corrected-001",
|
| 389 |
+
"scope": "shared",
|
| 390 |
+
"properties": {
|
| 391 |
+
"meetingType": "sprint_planning",
|
| 392 |
+
"priority": "high"
|
| 393 |
+
}
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
"id": "ext-corrected-003",
|
| 397 |
+
"event_id": "event-corrected-002",
|
| 398 |
+
"scope": "private",
|
| 399 |
+
"properties": {
|
| 400 |
+
"taskType": "development",
|
| 401 |
+
"estimatedComplexity": "high"
|
| 402 |
+
}
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"id": "ext-corrected-004",
|
| 406 |
+
"event_id": "event-corrected-003",
|
| 407 |
+
"scope": "shared",
|
| 408 |
+
"properties": {
|
| 409 |
+
"backupContact": "sarah@techcorp.com",
|
| 410 |
+
"vacationType": "personal"
|
| 411 |
+
}
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"id": "ext-corrected-005",
|
| 415 |
+
"event_id": "event-corrected-004",
|
| 416 |
+
"scope": "private",
|
| 417 |
+
"properties": {
|
| 418 |
+
"commute_reminder": "Leave by 8:00 AM to avoid traffic"
|
| 419 |
+
}
|
| 420 |
+
},
|
| 421 |
+
{
|
| 422 |
+
"id": "ext-corrected-006",
|
| 423 |
+
"event_id": "event-corrected-006",
|
| 424 |
+
"scope": "private",
|
| 425 |
+
"properties": {
|
| 426 |
+
"dealValue": "$250000",
|
| 427 |
+
"clientPriority": "high",
|
| 428 |
+
"preparationTime": "2 hours"
|
| 429 |
+
}
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
"id": "ext-corrected-007",
|
| 433 |
+
"event_id": "event-corrected-006",
|
| 434 |
+
"scope": "shared",
|
| 435 |
+
"properties": {
|
| 436 |
+
"meetingType": "client_demo",
|
| 437 |
+
"department": "sales"
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
# Reminders - separate table with method enum (correct relationship name)
|
| 443 |
+
reminders_data = [
|
| 444 |
+
{
|
| 445 |
+
"id": "rem-corrected-001",
|
| 446 |
+
"event_id": "event-corrected-001",
|
| 447 |
+
"method": "email",
|
| 448 |
+
"minutes": 1440, # 1 day before
|
| 449 |
+
"use_default": False
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"id": "rem-corrected-002",
|
| 453 |
+
"event_id": "event-corrected-001",
|
| 454 |
+
"method": "popup",
|
| 455 |
+
"minutes": 30, # 30 minutes before
|
| 456 |
+
"use_default": False
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"id": "rem-corrected-003",
|
| 460 |
+
"event_id": "event-corrected-002",
|
| 461 |
+
"method": "popup",
|
| 462 |
+
"minutes": 15, # 15 minutes before
|
| 463 |
+
"use_default": False
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
"id": "rem-corrected-004",
|
| 467 |
+
"event_id": "event-corrected-003",
|
| 468 |
+
"method": "email",
|
| 469 |
+
"minutes": 10080, # 1 week before
|
| 470 |
+
"use_default": False
|
| 471 |
+
},
|
| 472 |
+
{
|
| 473 |
+
"id": "rem-corrected-005",
|
| 474 |
+
"event_id": "event-corrected-005",
|
| 475 |
+
"method": "popup",
|
| 476 |
+
"minutes": 10, # Day of reminder
|
| 477 |
+
"use_default": False
|
| 478 |
+
},
|
| 479 |
+
{
|
| 480 |
+
"id": "rem-corrected-006",
|
| 481 |
+
"event_id": "event-corrected-006",
|
| 482 |
+
"method": "email",
|
| 483 |
+
"minutes": 2880, # 2 days before
|
| 484 |
+
"use_default": False
|
| 485 |
+
},
|
| 486 |
+
{
|
| 487 |
+
"id": "rem-corrected-007",
|
| 488 |
+
"event_id": "event-corrected-006",
|
| 489 |
+
"method": "popup",
|
| 490 |
+
"minutes": 60, # 1 hour before
|
| 491 |
+
"use_default": False
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"id": "rem-corrected-008",
|
| 495 |
+
"event_id": "event-corrected-006",
|
| 496 |
+
"method": "popup",
|
| 497 |
+
"minutes": 15, # 15 minutes before
|
| 498 |
+
"use_default": False
|
| 499 |
+
}
|
| 500 |
+
]
|
| 501 |
+
|
| 502 |
+
# Attendees data - to match model exactly
|
| 503 |
+
attendees_data = [
|
| 504 |
+
# Sprint Planning attendees
|
| 505 |
+
{
|
| 506 |
+
"attendees_id": "att-corrected-001",
|
| 507 |
+
"event_id": "event-corrected-001",
|
| 508 |
+
"user_id": "alice_manager",
|
| 509 |
+
"comment": "Looking forward to planning Q4!",
|
| 510 |
+
"displayName": "Alice Johnson",
|
| 511 |
+
"additionalGuests": 0,
|
| 512 |
+
"optional": False,
|
| 513 |
+
"resource": False,
|
| 514 |
+
"responseStatus": "accepted"
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"attendees_id": "att-corrected-002",
|
| 518 |
+
"event_id": "event-corrected-001",
|
| 519 |
+
"user_id": "bob_developer",
|
| 520 |
+
"comment": None,
|
| 521 |
+
"displayName": "Bob Smith",
|
| 522 |
+
"additionalGuests": 0,
|
| 523 |
+
"optional": False,
|
| 524 |
+
"resource": False,
|
| 525 |
+
"responseStatus": "accepted"
|
| 526 |
+
},
|
| 527 |
+
{
|
| 528 |
+
"attendees_id": "att-corrected-003",
|
| 529 |
+
"event_id": "event-corrected-001",
|
| 530 |
+
"user_id": "carol_designer",
|
| 531 |
+
"comment": "Will join if no conflicts with user research session",
|
| 532 |
+
"displayName": "Carol White",
|
| 533 |
+
"additionalGuests": 0,
|
| 534 |
+
"optional": True,
|
| 535 |
+
"resource": False,
|
| 536 |
+
"responseStatus": "tentative"
|
| 537 |
+
},
|
| 538 |
+
{
|
| 539 |
+
"attendees_id": "att-corrected-004",
|
| 540 |
+
"event_id": "event-corrected-001",
|
| 541 |
+
"user_id": None, # Resource doesn't have user_id
|
| 542 |
+
"comment": None,
|
| 543 |
+
"displayName": "Conference Room A",
|
| 544 |
+
"additionalGuests": 0,
|
| 545 |
+
"optional": False,
|
| 546 |
+
"resource": True,
|
| 547 |
+
"responseStatus": "accepted"
|
| 548 |
+
},
|
| 549 |
+
|
| 550 |
+
# Client Demo attendees
|
| 551 |
+
{
|
| 552 |
+
"attendees_id": "att-corrected-005",
|
| 553 |
+
"event_id": "event-corrected-006",
|
| 554 |
+
"user_id": "dave_sales",
|
| 555 |
+
"comment": "Prepared demo materials and pricing",
|
| 556 |
+
"displayName": "Dave Brown",
|
| 557 |
+
"additionalGuests": 0,
|
| 558 |
+
"optional": False,
|
| 559 |
+
"resource": False,
|
| 560 |
+
"responseStatus": "accepted"
|
| 561 |
+
},
|
| 562 |
+
{
|
| 563 |
+
"attendees_id": "att-corrected-006",
|
| 564 |
+
"event_id": "event-corrected-006",
|
| 565 |
+
"user_id": None, # External attendee
|
| 566 |
+
"comment": "Bringing 2 technical team members",
|
| 567 |
+
"displayName": "John Doe",
|
| 568 |
+
"additionalGuests": 2,
|
| 569 |
+
"optional": False,
|
| 570 |
+
"resource": False,
|
| 571 |
+
"responseStatus": "accepted"
|
| 572 |
+
},
|
| 573 |
+
{
|
| 574 |
+
"attendees_id": "att-corrected-007",
|
| 575 |
+
"event_id": "event-corrected-006",
|
| 576 |
+
"user_id": "sarah_tech",
|
| 577 |
+
"comment": "Technical support for enterprise questions",
|
| 578 |
+
"displayName": "Sarah Chen",
|
| 579 |
+
"additionalGuests": 0,
|
| 580 |
+
"optional": True,
|
| 581 |
+
"resource": False,
|
| 582 |
+
"responseStatus": "accepted"
|
| 583 |
+
},
|
| 584 |
+
{
|
| 585 |
+
"attendees_id": "att-corrected-008",
|
| 586 |
+
"event_id": "event-corrected-006",
|
| 587 |
+
"user_id": "alice_manager",
|
| 588 |
+
"comment": None,
|
| 589 |
+
"displayName": "Alice Johnson",
|
| 590 |
+
"additionalGuests": 0,
|
| 591 |
+
"optional": True,
|
| 592 |
+
"resource": False,
|
| 593 |
+
"responseStatus": "needsAction"
|
| 594 |
+
}
|
| 595 |
+
]
|
| 596 |
+
|
| 597 |
+
# Attachments for events
|
| 598 |
+
attachments_data = [
|
| 599 |
+
{
|
| 600 |
+
"attachment_id": "attach-corrected-001",
|
| 601 |
+
"event_id": "event-corrected-001",
|
| 602 |
+
"file_url": "https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view"
|
| 603 |
+
},
|
| 604 |
+
{
|
| 605 |
+
"attachment_id": "attach-corrected-002",
|
| 606 |
+
"event_id": "event-corrected-001",
|
| 607 |
+
"file_url": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit"
|
| 608 |
+
},
|
| 609 |
+
{
|
| 610 |
+
"attachment_id": "attach-corrected-003",
|
| 611 |
+
"event_id": "event-corrected-006",
|
| 612 |
+
"file_url": "https://drive.google.com/file/d/enterprise-demo-deck-2024/view"
|
| 613 |
+
},
|
| 614 |
+
{
|
| 615 |
+
"attachment_id": "attach-corrected-004",
|
| 616 |
+
"event_id": "event-corrected-006",
|
| 617 |
+
"file_url": "https://drive.google.com/file/d/pricing-sheet-enterprise-2024/view"
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"attachment_id": "attach-corrected-005",
|
| 621 |
+
"event_id": "event-corrected-006",
|
| 622 |
+
"file_url": "https://docs.google.com/document/d/technical-specs-enterprise/edit"
|
| 623 |
+
}
|
| 624 |
+
]
|
| 625 |
+
|
| 626 |
+
# Working location properties - corrected structure with proper office location foreign key
|
| 627 |
+
working_location_data = [
|
| 628 |
+
{
|
| 629 |
+
"working_location_id": "wl-corrected-001",
|
| 630 |
+
"event_id": "event-corrected-004",
|
| 631 |
+
"type": "officeLocation",
|
| 632 |
+
"homeOffice": False,
|
| 633 |
+
"customLocationLabel": None,
|
| 634 |
+
"officeLocationId": "office-building-2-floor-3" # - foreign key to office_locations
|
| 635 |
+
}
|
| 636 |
+
]
|
| 637 |
+
|
| 638 |
+
return {
|
| 639 |
+
"office_locations": office_locations_data,
|
| 640 |
+
"events": events_data,
|
| 641 |
+
"recurring_events": recurring_event_data,
|
| 642 |
+
"conference_data": conference_data,
|
| 643 |
+
"birthday_properties": birthday_properties,
|
| 644 |
+
"extended_properties": extended_properties,
|
| 645 |
+
"reminders": reminders_data,
|
| 646 |
+
"attendees": attendees_data,
|
| 647 |
+
"attachments": attachments_data,
|
| 648 |
+
"working_location_properties": working_location_data,
|
| 649 |
+
"description": "Event seed data that exactly matches the updated event.py model structure with proper field names and relationships"
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def generate_enhanced_event_sql(sql_statements) -> str:
|
| 654 |
+
"""
|
| 655 |
+
Generate SQL INSERT statements for enhanced event seed data
|
| 656 |
+
"""
|
| 657 |
+
data = get_enhanced_event_seed_data()
|
| 658 |
+
|
| 659 |
+
# Office Locations
|
| 660 |
+
sql_statements.append("-- Office Locations")
|
| 661 |
+
sql_statements.append("INSERT INTO office_locations (")
|
| 662 |
+
sql_statements.append(" id, buildingId, deskId, floorId, floorSectionId, label")
|
| 663 |
+
sql_statements.append(") VALUES")
|
| 664 |
+
|
| 665 |
+
office_values = []
|
| 666 |
+
for office in data["office_locations"]:
|
| 667 |
+
building_id = "NULL" if not office.get("buildingId") else f"'{office['buildingId']}'"
|
| 668 |
+
desk_id = "NULL" if not office.get("deskId") else f"'{office['deskId']}'"
|
| 669 |
+
floor_id = "NULL" if not office.get("floorId") else f"'{office['floorId']}'"
|
| 670 |
+
floor_section_id = "NULL" if not office.get("floorSectionId") else f"'{office['floorSectionId']}'"
|
| 671 |
+
|
| 672 |
+
office_values.append(
|
| 673 |
+
f"('{office['id']}', {building_id}, {desk_id}, {floor_id}, {floor_section_id}, '{office['label']}')"
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
sql_statements.append(",\n".join(office_values) + ";")
|
| 677 |
+
sql_statements.append("")
|
| 678 |
+
|
| 679 |
+
# Recurring Events
|
| 680 |
+
sql_statements.append("-- Recurring Events")
|
| 681 |
+
sql_statements.append("INSERT INTO recurring_events (")
|
| 682 |
+
sql_statements.append(" recurring_event_id, original_recurrence")
|
| 683 |
+
sql_statements.append(") VALUES")
|
| 684 |
+
|
| 685 |
+
recurring_events = []
|
| 686 |
+
for rec_event in data["recurring_events"]:
|
| 687 |
+
recurring_event_id = "NULL" if not rec_event.get("recurring_event_id") else f"'{rec_event['recurring_event_id']}'"
|
| 688 |
+
original_recurrence = "NULL" if not rec_event.get("original_recurrence") else f"'{json.dumps(rec_event['original_recurrence']).replace(chr(39), chr(39)+chr(39))}'"
|
| 689 |
+
|
| 690 |
+
recurring_events.append(f"({recurring_event_id},{original_recurrence})")
|
| 691 |
+
|
| 692 |
+
sql_statements.append(",\n".join(recurring_events) + ";")
|
| 693 |
+
sql_statements.append("")
|
| 694 |
+
|
| 695 |
+
# Events
|
| 696 |
+
sql_statements.append("-- Events")
|
| 697 |
+
sql_statements.append("INSERT INTO events (")
|
| 698 |
+
sql_statements.append(" event_id, calendar_id, user_id, organizer_id, organizer_email, organizer_display_name, organizer_self,")
|
| 699 |
+
sql_statements.append(" recurring_event_id, summary, description, location,")
|
| 700 |
+
sql_statements.append(" start_datetime, end_datetime, start_timezone, end_timezone, originalStartTime_date, originalStartTime_dateTime, originalStartTime_timeZone, recurrence,")
|
| 701 |
+
sql_statements.append(" status, visibility, color_id, iCalUID, eventType, focusTimeProperties,")
|
| 702 |
+
sql_statements.append(" guestsCanInviteOthers, guestsCanModify, guestsCanSeeOtherGuests,")
|
| 703 |
+
sql_statements.append(" outOfOfficeProperties, sequence, source, created_at, updated_at")
|
| 704 |
+
sql_statements.append(") VALUES")
|
| 705 |
+
|
| 706 |
+
event_values = []
|
| 707 |
+
for event in data["events"]:
|
| 708 |
+
# Handle optional fields
|
| 709 |
+
description = "NULL" if not event.get("description") else f"'{event['description'].replace(chr(39), chr(39)+chr(39))}'"
|
| 710 |
+
location = "NULL" if not event.get("location") else f"'{event['location'].replace(chr(39), chr(39)+chr(39))}'"
|
| 711 |
+
start_tz = "NULL" if not event.get("start_timezone") else f"'{event['start_timezone']}'"
|
| 712 |
+
end_tz = "NULL" if not event.get("end_timezone") else f"'{event['end_timezone']}'"
|
| 713 |
+
|
| 714 |
+
# Handle originalStartTime fields
|
| 715 |
+
original_start_date = "NULL"
|
| 716 |
+
original_start_datetime = "NULL"
|
| 717 |
+
original_start_timezone = "NULL"
|
| 718 |
+
|
| 719 |
+
if event.get("originalStartTime_date"):
|
| 720 |
+
original_start_date = f"'{event['originalStartTime_date'].isoformat()}'"
|
| 721 |
+
if event.get("originalStartTime_dateTime"):
|
| 722 |
+
original_start_datetime = f"'{event['originalStartTime_dateTime'].isoformat()}'"
|
| 723 |
+
if event.get("originalStartTime_timeZone"):
|
| 724 |
+
original_start_timezone = f"'{event['originalStartTime_timeZone']}'"
|
| 725 |
+
|
| 726 |
+
# Handle recurrence field - it can be a list or None
|
| 727 |
+
recurring_event_id = "NULL" if not event.get("recurring_event_id") else event["recurring_event_id"]
|
| 728 |
+
|
| 729 |
+
recurrence = "NULL"
|
| 730 |
+
if event.get("recurrence"):
|
| 731 |
+
if isinstance(event['recurrence'], list):
|
| 732 |
+
recurrence = f"'{json.dumps(event['recurrence']).replace(chr(39), chr(39)+chr(39))}'"
|
| 733 |
+
else:
|
| 734 |
+
recurrence = f"'{event['recurrence'].replace(chr(39), chr(39)+chr(39))}'"
|
| 735 |
+
|
| 736 |
+
color_id = "NULL" if not event.get("color_id") else f"'{event['color_id']}'"
|
| 737 |
+
|
| 738 |
+
# Handle JSON fields - using
|
| 739 |
+
focus_props = "NULL"
|
| 740 |
+
if event.get("focusTimeProperties"):
|
| 741 |
+
focus_props = f"'{json.dumps(event['focusTimeProperties']).replace(chr(39), chr(39)+chr(39))}'"
|
| 742 |
+
|
| 743 |
+
ooo_props = "NULL"
|
| 744 |
+
if event.get("outOfOfficeProperties"):
|
| 745 |
+
ooo_props = f"'{json.dumps(event['outOfOfficeProperties']).replace(chr(39), chr(39)+chr(39))}'"
|
| 746 |
+
|
| 747 |
+
source = "NULL"
|
| 748 |
+
if event.get("source"):
|
| 749 |
+
source = f"'{json.dumps(event['source']).replace(chr(39), chr(39)+chr(39))}'"
|
| 750 |
+
|
| 751 |
+
# Handle organizer fields
|
| 752 |
+
organizer_id = "NULL" if not event.get("organizer_id") else f"'{event['organizer_id']}'"
|
| 753 |
+
organizer_email = "NULL" if not event.get("organizer_email") else f"'{event['organizer_email']}'"
|
| 754 |
+
organizer_display_name = "NULL" if not event.get("organizer_display_name") else f"'{event['organizer_display_name']}'"
|
| 755 |
+
organizer_self = 1 if event.get("organizer_self", False) else 0
|
| 756 |
+
|
| 757 |
+
event_values.append(
|
| 758 |
+
f"('{event['event_id']}', '{event['calendar_id']}', '{event['user_id']}', {organizer_id}, {organizer_email}, {organizer_display_name}, {organizer_self}, "
|
| 759 |
+
f"'{recurring_event_id}', '{event['summary'].replace(chr(39), chr(39)+chr(39))}', {description}, {location}, "
|
| 760 |
+
f"'{event['start_datetime'].isoformat()}', '{event['end_datetime'].isoformat()}', "
|
| 761 |
+
f"{start_tz}, {end_tz}, {original_start_date}, {original_start_datetime}, {original_start_timezone}, {recurrence}, "
|
| 762 |
+
f"'{event['status']}', '{event['visibility']}', {color_id}, '{event['iCalUID']}', '{event['eventType']}', {focus_props}, "
|
| 763 |
+
f"{1 if event['guestsCanInviteOthers'] else 0}, "
|
| 764 |
+
f"{1 if event['guestsCanModify'] else 0}, "
|
| 765 |
+
f"{1 if event['guestsCanSeeOtherGuests'] else 0}, "
|
| 766 |
+
f"{ooo_props}, {event['sequence']}, {source}, datetime('now'), datetime('now'))"
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
sql_statements.append(",\n".join(event_values) + ";")
|
| 770 |
+
sql_statements.append("")
|
| 771 |
+
|
| 772 |
+
# ConferenceData
|
| 773 |
+
if data["conference_data"]:
|
| 774 |
+
sql_statements.append("-- ConferenceData")
|
| 775 |
+
sql_statements.append("INSERT INTO conference_data (")
|
| 776 |
+
sql_statements.append(" id, event_id, request_id, solution_type, status_code, meeting_uri, label")
|
| 777 |
+
sql_statements.append(") VALUES")
|
| 778 |
+
|
| 779 |
+
conf_values = []
|
| 780 |
+
for conf in data["conference_data"]:
|
| 781 |
+
request_id = "NULL" if not conf.get("request_id") else f"'{conf['request_id']}'"
|
| 782 |
+
solution_type = "NULL" if not conf.get("solution_type") else f"'{conf['solution_type']}'"
|
| 783 |
+
status_code = "NULL" if not conf.get("status_code") else f"'{conf['status_code']}'"
|
| 784 |
+
meeting_uri = "NULL" if not conf.get("meeting_uri") else f"'{conf['meeting_uri']}'"
|
| 785 |
+
label = "NULL" if not conf.get("label") else f"'{conf['label']}'"
|
| 786 |
+
|
| 787 |
+
conf_values.append(
|
| 788 |
+
f"('{conf['id']}', '{conf['event_id']}', {request_id}, {solution_type}, "
|
| 789 |
+
f"{status_code}, {meeting_uri}, {label})"
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
sql_statements.append(",\n".join(conf_values) + ";")
|
| 793 |
+
sql_statements.append("")
|
| 794 |
+
|
| 795 |
+
# BirthdayProperties
|
| 796 |
+
if data["birthday_properties"]:
|
| 797 |
+
sql_statements.append("-- BirthdayProperties")
|
| 798 |
+
sql_statements.append("INSERT INTO birthday_properties (id, event_id, type) VALUES")
|
| 799 |
+
|
| 800 |
+
birthday_values = []
|
| 801 |
+
for birthday in data["birthday_properties"]:
|
| 802 |
+
birthday_values.append(f"('{birthday['id']}', '{birthday['event_id']}', '{birthday['type']}')")
|
| 803 |
+
|
| 804 |
+
sql_statements.append(",\n".join(birthday_values) + ";")
|
| 805 |
+
sql_statements.append("")
|
| 806 |
+
|
| 807 |
+
# ExtendedProperties
|
| 808 |
+
if data["extended_properties"]:
|
| 809 |
+
sql_statements.append("-- ExtendedProperties")
|
| 810 |
+
sql_statements.append("INSERT INTO extended_properties (id, event_id, scope, properties) VALUES")
|
| 811 |
+
|
| 812 |
+
ext_values = []
|
| 813 |
+
for ext in data["extended_properties"]:
|
| 814 |
+
properties = json.dumps(ext["properties"]).replace(chr(39), chr(39)+chr(39))
|
| 815 |
+
ext_values.append(
|
| 816 |
+
f"('{ext['id']}', '{ext['event_id']}', '{ext['scope']}', '{properties}')"
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
sql_statements.append(",\n".join(ext_values) + ";")
|
| 820 |
+
sql_statements.append("")
|
| 821 |
+
|
| 822 |
+
# Reminders
|
| 823 |
+
if data["reminders"]:
|
| 824 |
+
sql_statements.append("-- Reminders")
|
| 825 |
+
sql_statements.append("INSERT INTO reminders (id, event_id, method, minutes, use_default) VALUES")
|
| 826 |
+
|
| 827 |
+
reminder_values = []
|
| 828 |
+
for reminder in data["reminders"]:
|
| 829 |
+
reminder_values.append(
|
| 830 |
+
f"('{reminder['id']}', '{reminder['event_id']}', '{reminder['method']}', "
|
| 831 |
+
f"{reminder['minutes']}, {1 if reminder['use_default'] else 0})"
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
sql_statements.append(",\n".join(reminder_values) + ";")
|
| 835 |
+
sql_statements.append("")
|
| 836 |
+
|
| 837 |
+
# Attendees - with
|
| 838 |
+
sql_statements.append("-- Attendees")
|
| 839 |
+
sql_statements.append("INSERT INTO attendees (")
|
| 840 |
+
sql_statements.append(" attendees_id, event_id, user_id, comment, displayName,")
|
| 841 |
+
sql_statements.append(" additionalGuests, optional, resource, responseStatus")
|
| 842 |
+
sql_statements.append(") VALUES")
|
| 843 |
+
|
| 844 |
+
attendee_values = []
|
| 845 |
+
for attendee in data["attendees"]:
|
| 846 |
+
user_id = "NULL" if not attendee.get("user_id") else f"'{attendee['user_id']}'"
|
| 847 |
+
comment = "NULL" if not attendee.get("comment") else f"'{attendee['comment'].replace(chr(39), chr(39)+chr(39))}'"
|
| 848 |
+
display_name = "NULL" if not attendee.get("displayName") else f"'{attendee['displayName']}'"
|
| 849 |
+
|
| 850 |
+
attendee_values.append(
|
| 851 |
+
f"('{attendee['attendees_id']}', '{attendee['event_id']}', {user_id}, {comment}, {display_name}, "
|
| 852 |
+
f"{attendee['additionalGuests']}, {1 if attendee['optional'] else 0}, "
|
| 853 |
+
f"{1 if attendee['resource'] else 0}, '{attendee['responseStatus']}')"
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
sql_statements.append(",\n".join(attendee_values) + ";")
|
| 857 |
+
sql_statements.append("")
|
| 858 |
+
|
| 859 |
+
# Attachments
|
| 860 |
+
sql_statements.append("-- Attachments")
|
| 861 |
+
sql_statements.append("INSERT INTO attachments (attachment_id, event_id, file_url) VALUES")
|
| 862 |
+
|
| 863 |
+
attachment_values = []
|
| 864 |
+
for attachment in data["attachments"]:
|
| 865 |
+
attachment_values.append(
|
| 866 |
+
f"('{attachment['attachment_id']}', '{attachment['event_id']}', '{attachment['file_url']}')"
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
sql_statements.append(",\n".join(attachment_values) + ";")
|
| 870 |
+
sql_statements.append("")
|
| 871 |
+
|
| 872 |
+
# Working Location Properties
|
| 873 |
+
sql_statements.append("-- Working Location Properties")
|
| 874 |
+
sql_statements.append("INSERT INTO working_location_properties (")
|
| 875 |
+
sql_statements.append(" working_location_id, event_id, type, homeOffice, customLocationLabel, officeLocationId")
|
| 876 |
+
sql_statements.append(") VALUES")
|
| 877 |
+
|
| 878 |
+
wl_values = []
|
| 879 |
+
for wl in data["working_location_properties"]:
|
| 880 |
+
custom_label = "NULL" if not wl.get("customLocationLabel") else f"'{wl['customLocationLabel']}'"
|
| 881 |
+
office_location_id = "NULL" if not wl.get("officeLocationId") else f"'{wl['officeLocationId']}'"
|
| 882 |
+
|
| 883 |
+
wl_values.append(
|
| 884 |
+
f"('{wl['working_location_id']}', '{wl['event_id']}', '{wl['type']}', "
|
| 885 |
+
f"{1 if wl['homeOffice'] else 0}, {custom_label}, {office_location_id})"
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
sql_statements.append(",\n".join(wl_values) + ";")
|
| 889 |
+
sql_statements.append("")
|
| 890 |
+
|
| 891 |
+
return sql_statements
|
| 892 |
+
|
| 893 |
+
|
server/data/google_colors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Google Calendar API v3 Color Data
|
| 3 |
+
Exact color definitions from Google Calendar API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# Google Calendar exact color definitions based on official API
|
| 7 |
+
GOOGLE_CALENDAR_COLORS = [
|
| 8 |
+
# Calendar colors (1-24)
|
| 9 |
+
{"color_id": "1", "color_type": "calendar", "background": "#ac725e", "foreground": "#1d1d1d"},
|
| 10 |
+
{"color_id": "2", "color_type": "calendar", "background": "#d06b64", "foreground": "#1d1d1d"},
|
| 11 |
+
{"color_id": "3", "color_type": "calendar", "background": "#f83a22", "foreground": "#1d1d1d"},
|
| 12 |
+
{"color_id": "4", "color_type": "calendar", "background": "#fa57c4", "foreground": "#1d1d1d"},
|
| 13 |
+
{"color_id": "5", "color_type": "calendar", "background": "#9fc6e7", "foreground": "#1d1d1d"},
|
| 14 |
+
{"color_id": "6", "color_type": "calendar", "background": "#9a9cff", "foreground": "#1d1d1d"},
|
| 15 |
+
{"color_id": "7", "color_type": "calendar", "background": "#4986e7", "foreground": "#1d1d1d"},
|
| 16 |
+
{"color_id": "8", "color_type": "calendar", "background": "#9aa116", "foreground": "#1d1d1d"},
|
| 17 |
+
{"color_id": "9", "color_type": "calendar", "background": "#ef6c00", "foreground": "#1d1d1d"},
|
| 18 |
+
{"color_id": "10", "color_type": "calendar", "background": "#ff7537", "foreground": "#1d1d1d"},
|
| 19 |
+
{"color_id": "11", "color_type": "calendar", "background": "#42d692", "foreground": "#1d1d1d"},
|
| 20 |
+
{"color_id": "12", "color_type": "calendar", "background": "#16a765", "foreground": "#1d1d1d"},
|
| 21 |
+
{"color_id": "13", "color_type": "calendar", "background": "#7bd148", "foreground": "#1d1d1d"},
|
| 22 |
+
{"color_id": "14", "color_type": "calendar", "background": "#b3dc6c", "foreground": "#1d1d1d"},
|
| 23 |
+
{"color_id": "15", "color_type": "calendar", "background": "#fbe983", "foreground": "#1d1d1d"},
|
| 24 |
+
{"color_id": "16", "color_type": "calendar", "background": "#fad165", "foreground": "#1d1d1d"},
|
| 25 |
+
{"color_id": "17", "color_type": "calendar", "background": "#92e1c0", "foreground": "#1d1d1d"},
|
| 26 |
+
{"color_id": "18", "color_type": "calendar", "background": "#9fe1e7", "foreground": "#1d1d1d"},
|
| 27 |
+
{"color_id": "19", "color_type": "calendar", "background": "#9fc6e7", "foreground": "#1d1d1d"},
|
| 28 |
+
{"color_id": "20", "color_type": "calendar", "background": "#4986e7", "foreground": "#1d1d1d"},
|
| 29 |
+
{"color_id": "21", "color_type": "calendar", "background": "#9aa116", "foreground": "#1d1d1d"},
|
| 30 |
+
{"color_id": "22", "color_type": "calendar", "background": "#16a765", "foreground": "#1d1d1d"},
|
| 31 |
+
{"color_id": "23", "color_type": "calendar", "background": "#ff7537", "foreground": "#1d1d1d"},
|
| 32 |
+
{"color_id": "24", "color_type": "calendar", "background": "#ffad46", "foreground": "#1d1d1d"},
|
| 33 |
+
|
| 34 |
+
# Event colors (1-11)
|
| 35 |
+
{"color_id": "1", "color_type": "event", "background": "#a4bdfc", "foreground": "#1d1d1d"},
|
| 36 |
+
{"color_id": "2", "color_type": "event", "background": "#7ae7bf", "foreground": "#1d1d1d"},
|
| 37 |
+
{"color_id": "3", "color_type": "event", "background": "#dbadff", "foreground": "#1d1d1d"},
|
| 38 |
+
{"color_id": "4", "color_type": "event", "background": "#ff887c", "foreground": "#1d1d1d"},
|
| 39 |
+
{"color_id": "5", "color_type": "event", "background": "#fbd75b", "foreground": "#1d1d1d"},
|
| 40 |
+
{"color_id": "6", "color_type": "event", "background": "#ffb878", "foreground": "#1d1d1d"},
|
| 41 |
+
{"color_id": "7", "color_type": "event", "background": "#46d6db", "foreground": "#1d1d1d"},
|
| 42 |
+
{"color_id": "8", "color_type": "event", "background": "#e1e1e1", "foreground": "#1d1d1d"},
|
| 43 |
+
{"color_id": "9", "color_type": "event", "background": "#5484ed", "foreground": "#1d1d1d"},
|
| 44 |
+
{"color_id": "10", "color_type": "event", "background": "#51b749", "foreground": "#1d1d1d"},
|
| 45 |
+
{"color_id": "11", "color_type": "event", "background": "#dc2127", "foreground": "#1d1d1d"},
|
| 46 |
+
]
|
server/data/multi_user_sample.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Multi-User Sample Data for Calendar Application
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
from .enhanced_event_seed_data import generate_enhanced_event_sql
|
| 8 |
+
from .watch_channel_seed_data import get_watch_channel_sql
|
| 9 |
+
|
| 10 |
+
def get_multi_user_sample_data() -> Dict[str, Any]:
|
| 11 |
+
"""
|
| 12 |
+
Generate sample data for multiple users demonstrating multi-user scenarios
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
# Sample users
|
| 16 |
+
users = [
|
| 17 |
+
{
|
| 18 |
+
"user_id": "alice_manager",
|
| 19 |
+
"email": "alice.manager@techcorp.com",
|
| 20 |
+
"name": "Alice Johnson",
|
| 21 |
+
"given_name": "Alice",
|
| 22 |
+
"family_name": "Johnson",
|
| 23 |
+
"static_token": "ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2.qwErTyUIopASDfGhJkLzXcVbNm12_34-56",
|
| 24 |
+
"timezone": "America/New_York",
|
| 25 |
+
"role": "Project Manager"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"user_id": "bob_developer",
|
| 29 |
+
"email": "bob.smith@techcorp.com",
|
| 30 |
+
"name": "Bob Smith",
|
| 31 |
+
"given_name": "Bob",
|
| 32 |
+
"family_name": "Smith",
|
| 33 |
+
"static_token": "ya29.A0ARrdaM-Zx8Nw3Q4pVb6Ls9R1mT0cG2uF5yH7kJd8sA1Lq2.wErtYuIoPaSdFgHjKlZxCvBnM987_65-43",
|
| 34 |
+
"timezone": "America/Los_Angeles",
|
| 35 |
+
"role": "Senior Developer"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"user_id": "carol_designer",
|
| 39 |
+
"email": "carol.white@techcorp.com",
|
| 40 |
+
"name": "Carol White",
|
| 41 |
+
"given_name": "Carol",
|
| 42 |
+
"family_name": "White",
|
| 43 |
+
"static_token": "ya29.A0ARrdaM-b7Hc5Vn2Qm8R1sT4pL0xY9wK3uF6jZ2eRc1.QaWsEdRfTgHyJuIkOlPzXcVbNmKjHgf_21-098",
|
| 44 |
+
"timezone": "Europe/London",
|
| 45 |
+
"role": "UX Designer"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"user_id": "dave_sales",
|
| 49 |
+
"email": "dave.brown@techcorp.com",
|
| 50 |
+
"name": "Dave Brown",
|
| 51 |
+
"given_name": "Dave",
|
| 52 |
+
"family_name": "Brown",
|
| 53 |
+
"static_token": "ya29.A0ARrdaM-p3Lk9Vb6Qw2Zx8N1sT4mH7gF5yR0uJc2ePq.ZxCvBnMlKjHgFfDsaQwErTyUiOpAsDfGhJk_77-11",
|
| 54 |
+
"timezone": "America/Chicago",
|
| 55 |
+
"role": "Sales Director"
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
# Alice's calendars (Project Manager)
|
| 60 |
+
alice_calendars = [
|
| 61 |
+
{
|
| 62 |
+
"calendar_id": "alice-primary",
|
| 63 |
+
"user_id": "alice_manager",
|
| 64 |
+
"summary": "Alice Johnson",
|
| 65 |
+
"description": "Primary calendar for Alice Johnson - Project Manager",
|
| 66 |
+
"time_zone": "America/New_York",
|
| 67 |
+
"is_primary": True,
|
| 68 |
+
"color_id": "1"
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"calendar_id": "alice-projects",
|
| 72 |
+
"user_id": "alice_manager",
|
| 73 |
+
"summary": "Project Management",
|
| 74 |
+
"description": "Project meetings, deadlines, and milestones",
|
| 75 |
+
"time_zone": "America/New_York",
|
| 76 |
+
"is_primary": False,
|
| 77 |
+
"color_id": "7"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"calendar_id": "alice-team",
|
| 81 |
+
"user_id": "alice_manager",
|
| 82 |
+
"summary": "Team Coordination",
|
| 83 |
+
"description": "Team meetings, 1-on-1s, and team events",
|
| 84 |
+
"time_zone": "America/New_York",
|
| 85 |
+
"is_primary": False,
|
| 86 |
+
"color_id": "11"
|
| 87 |
+
}
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
# Bob's calendars (Developer)
|
| 91 |
+
bob_calendars = [
|
| 92 |
+
{
|
| 93 |
+
"calendar_id": "bob-primary",
|
| 94 |
+
"user_id": "bob_developer",
|
| 95 |
+
"summary": "Bob Smith",
|
| 96 |
+
"description": "Primary calendar for Bob Smith - Senior Developer",
|
| 97 |
+
"time_zone": "America/Los_Angeles",
|
| 98 |
+
"is_primary": True,
|
| 99 |
+
"color_id": "2"
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"calendar_id": "bob-development",
|
| 103 |
+
"user_id": "bob_developer",
|
| 104 |
+
"summary": "Development Schedule",
|
| 105 |
+
"description": "Sprint planning, code reviews, and development tasks",
|
| 106 |
+
"time_zone": "America/Los_Angeles",
|
| 107 |
+
"is_primary": False,
|
| 108 |
+
"color_id": "9"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"calendar_id": "bob-personal",
|
| 112 |
+
"user_id": "bob_developer",
|
| 113 |
+
"summary": "Personal Time",
|
| 114 |
+
"description": "Personal appointments and time off",
|
| 115 |
+
"time_zone": "America/Los_Angeles",
|
| 116 |
+
"is_primary": False,
|
| 117 |
+
"color_id": "14"
|
| 118 |
+
}
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
# Carol's calendars (Designer)
|
| 122 |
+
carol_calendars = [
|
| 123 |
+
{
|
| 124 |
+
"calendar_id": "carol-primary",
|
| 125 |
+
"user_id": "carol_designer",
|
| 126 |
+
"summary": "Carol White",
|
| 127 |
+
"description": "Primary calendar for Carol White - UX Designer",
|
| 128 |
+
"time_zone": "Europe/London",
|
| 129 |
+
"is_primary": True,
|
| 130 |
+
"color_id": "4"
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"calendar_id": "carol-design",
|
| 134 |
+
"user_id": "carol_designer",
|
| 135 |
+
"summary": "Design Work",
|
| 136 |
+
"description": "Design sessions, user research, and creative time",
|
| 137 |
+
"time_zone": "Europe/London",
|
| 138 |
+
"is_primary": False,
|
| 139 |
+
"color_id": "16"
|
| 140 |
+
}
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
# Dave's calendars (Sales)
|
| 144 |
+
dave_calendars = [
|
| 145 |
+
{
|
| 146 |
+
"calendar_id": "dave-primary",
|
| 147 |
+
"user_id": "dave_sales",
|
| 148 |
+
"summary": "Dave Brown",
|
| 149 |
+
"description": "Primary calendar for Dave Brown - Sales Director",
|
| 150 |
+
"time_zone": "America/Chicago",
|
| 151 |
+
"is_primary": True,
|
| 152 |
+
"color_id": "6"
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"calendar_id": "dave-sales",
|
| 156 |
+
"user_id": "dave_sales",
|
| 157 |
+
"summary": "Sales Activities",
|
| 158 |
+
"description": "Client meetings, sales calls, and deals",
|
| 159 |
+
"time_zone": "America/Chicago",
|
| 160 |
+
"is_primary": False,
|
| 161 |
+
"color_id": "23"
|
| 162 |
+
}
|
| 163 |
+
]
|
| 164 |
+
|
| 165 |
+
# Sample events for each user
|
| 166 |
+
base_date = datetime.now().replace(hour=9, minute=0, second=0, microsecond=0)
|
| 167 |
+
|
| 168 |
+
alice_events = [
|
| 169 |
+
{
|
| 170 |
+
"event_id": "alice-event-1",
|
| 171 |
+
"calendar_id": "alice-projects",
|
| 172 |
+
"user_id": "alice_manager",
|
| 173 |
+
"summary": "Sprint Planning Meeting",
|
| 174 |
+
"description": "Plan upcoming sprint with development team",
|
| 175 |
+
"location": "Conference Room A",
|
| 176 |
+
"start_datetime": base_date,
|
| 177 |
+
"end_datetime": base_date + timedelta(hours=1),
|
| 178 |
+
"status": "confirmed"
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"event_id": "alice-event-2",
|
| 182 |
+
"calendar_id": "alice-team",
|
| 183 |
+
"user_id": "alice_manager",
|
| 184 |
+
"summary": "1-on-1 with Bob",
|
| 185 |
+
"description": "Weekly check-in with Bob Smith",
|
| 186 |
+
"location": "Alice's Office",
|
| 187 |
+
"start_datetime": base_date + timedelta(days=1, hours=2),
|
| 188 |
+
"end_datetime": base_date + timedelta(days=1, hours=2, minutes=30),
|
| 189 |
+
"status": "confirmed"
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"event_id": "alice-event-3",
|
| 193 |
+
"calendar_id": "alice-primary",
|
| 194 |
+
"user_id": "alice_manager",
|
| 195 |
+
"summary": "Board Meeting",
|
| 196 |
+
"description": "Monthly board meeting presentation",
|
| 197 |
+
"location": "Executive Conference Room",
|
| 198 |
+
"start_datetime": base_date + timedelta(days=7),
|
| 199 |
+
"end_datetime": base_date + timedelta(days=7, hours=2),
|
| 200 |
+
"status": "confirmed"
|
| 201 |
+
}
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
bob_events = [
|
| 205 |
+
{
|
| 206 |
+
"event_id": "bob-event-1",
|
| 207 |
+
"calendar_id": "bob-development",
|
| 208 |
+
"user_id": "bob_developer",
|
| 209 |
+
"summary": "Code Review Session",
|
| 210 |
+
"description": "Review pull requests from junior developers",
|
| 211 |
+
"location": "Development Room",
|
| 212 |
+
"start_datetime": base_date + timedelta(hours=3),
|
| 213 |
+
"end_datetime": base_date + timedelta(hours=4),
|
| 214 |
+
"status": "confirmed"
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
"event_id": "bob-event-2",
|
| 218 |
+
"calendar_id": "bob-primary",
|
| 219 |
+
"user_id": "bob_developer",
|
| 220 |
+
"summary": "Architecture Discussion",
|
| 221 |
+
"description": "Discuss system architecture for new feature",
|
| 222 |
+
"location": "Video Call",
|
| 223 |
+
"start_datetime": base_date + timedelta(days=2),
|
| 224 |
+
"end_datetime": base_date + timedelta(days=2, hours=1, minutes=30),
|
| 225 |
+
"status": "confirmed"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"event_id": "bob-event-3",
|
| 229 |
+
"calendar_id": "bob-personal",
|
| 230 |
+
"user_id": "bob_developer",
|
| 231 |
+
"summary": "Dentist Appointment",
|
| 232 |
+
"description": "Annual dental checkup",
|
| 233 |
+
"location": "Downtown Dental",
|
| 234 |
+
"start_datetime": base_date + timedelta(days=5, hours=5),
|
| 235 |
+
"end_datetime": base_date + timedelta(days=5, hours=6),
|
| 236 |
+
"status": "confirmed"
|
| 237 |
+
}
|
| 238 |
+
]
|
| 239 |
+
|
| 240 |
+
carol_events = [
|
| 241 |
+
{
|
| 242 |
+
"event_id": "carol-event-1",
|
| 243 |
+
"calendar_id": "carol-design",
|
| 244 |
+
"user_id": "carol_designer",
|
| 245 |
+
"summary": "User Research Session",
|
| 246 |
+
"description": "Interview users about new feature requirements",
|
| 247 |
+
"location": "Research Lab",
|
| 248 |
+
"start_datetime": base_date + timedelta(hours=1),
|
| 249 |
+
"end_datetime": base_date + timedelta(hours=3),
|
| 250 |
+
"status": "confirmed"
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"event_id": "carol-event-2",
|
| 254 |
+
"calendar_id": "carol-primary",
|
| 255 |
+
"user_id": "carol_designer",
|
| 256 |
+
"summary": "Design Review",
|
| 257 |
+
"description": "Present mockups to stakeholders",
|
| 258 |
+
"location": "Design Studio",
|
| 259 |
+
"start_datetime": base_date + timedelta(days=3, hours=2),
|
| 260 |
+
"end_datetime": base_date + timedelta(days=3, hours=3, minutes=30),
|
| 261 |
+
"status": "confirmed"
|
| 262 |
+
}
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
dave_events = [
|
| 266 |
+
{
|
| 267 |
+
"event_id": "dave-event-1",
|
| 268 |
+
"calendar_id": "dave-sales",
|
| 269 |
+
"user_id": "dave_sales",
|
| 270 |
+
"summary": "Client Demo",
|
| 271 |
+
"description": "Product demonstration for potential enterprise client",
|
| 272 |
+
"location": "Client Office",
|
| 273 |
+
"start_datetime": base_date + timedelta(days=1),
|
| 274 |
+
"end_datetime": base_date + timedelta(days=1, hours=2),
|
| 275 |
+
"status": "confirmed"
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"event_id": "dave-event-2",
|
| 279 |
+
"calendar_id": "dave-primary",
|
| 280 |
+
"user_id": "dave_sales",
|
| 281 |
+
"summary": "Sales Team Meeting",
|
| 282 |
+
"description": "Weekly sales team sync and pipeline review",
|
| 283 |
+
"location": "Sales Conference Room",
|
| 284 |
+
"start_datetime": base_date + timedelta(days=4),
|
| 285 |
+
"end_datetime": base_date + timedelta(days=4, hours=1),
|
| 286 |
+
"status": "confirmed"
|
| 287 |
+
}
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
settings = [
|
| 291 |
+
{"id": "alice_timezone", "user_id": "alice_manager", "value": "America/New_York"},
|
| 292 |
+
{"id": "bob_timezone", "user_id": "bob_developer", "value": "America/Los_Angeles"},
|
| 293 |
+
{"id": "carol_timezone", "user_id": "carol_designer", "value": "Europe/London"},
|
| 294 |
+
{"id": "dave_timezone", "user_id": "dave_sales", "value": "America/Chicago"}
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
scopes = [
|
| 298 |
+
{"id": "scope-alice", "type": "user", "value": "alice.manager@techcorp.com"},
|
| 299 |
+
{"id": "scope-bob", "type": "user", "value": "bob.smith@techcorp.com"},
|
| 300 |
+
{"id": "scope-carol", "type": "user", "value": "carol.white@techcorp.com"},
|
| 301 |
+
{"id": "scope-dave", "type": "user", "value": "dave.brown@techcorp.com"},
|
| 302 |
+
{"id": "scope-group", "type": "group", "value": "product-team@techcorp.com"},
|
| 303 |
+
{"id": "scope-domain", "type": "domain", "value": "techcorp.com"},
|
| 304 |
+
{"id": "scope-public", "type": "default", "value":"public"}
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
acls = [
|
| 308 |
+
# Alice's calendar ACLs (owner of her calendars)
|
| 309 |
+
{
|
| 310 |
+
"id": "acl-alice-primary",
|
| 311 |
+
"calendar_id": "alice-primary",
|
| 312 |
+
"user_id": "alice_manager",
|
| 313 |
+
"role": "owner",
|
| 314 |
+
"scope_id": "scope-alice",
|
| 315 |
+
"etag": "etag-alice-primary"
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"id": "acl-alice-projects",
|
| 319 |
+
"calendar_id": "alice-projects",
|
| 320 |
+
"user_id": "alice_manager",
|
| 321 |
+
"role": "owner",
|
| 322 |
+
"scope_id": "scope-alice",
|
| 323 |
+
"etag": "etag-alice-projects"
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"id": "acl-alice-team",
|
| 327 |
+
"calendar_id": "alice-team",
|
| 328 |
+
"user_id": "alice_manager",
|
| 329 |
+
"role": "owner",
|
| 330 |
+
"scope_id": "scope-alice",
|
| 331 |
+
"etag": "etag-alice-team"
|
| 332 |
+
},
|
| 333 |
+
# Bob's calendar ACLs (owner of his calendars)
|
| 334 |
+
{
|
| 335 |
+
"id": "acl-bob-primary",
|
| 336 |
+
"calendar_id": "bob-primary",
|
| 337 |
+
"user_id": "bob_developer",
|
| 338 |
+
"role": "owner",
|
| 339 |
+
"scope_id": "scope-bob",
|
| 340 |
+
"etag": "etag-bob-primary"
|
| 341 |
+
},
|
| 342 |
+
{
|
| 343 |
+
"id": "acl-bob-development",
|
| 344 |
+
"calendar_id": "bob-development",
|
| 345 |
+
"user_id": "bob_developer",
|
| 346 |
+
"role": "owner",
|
| 347 |
+
"scope_id": "scope-bob",
|
| 348 |
+
"etag": "etag-bob-development"
|
| 349 |
+
},
|
| 350 |
+
{
|
| 351 |
+
"id": "acl-bob-personal",
|
| 352 |
+
"calendar_id": "bob-personal",
|
| 353 |
+
"user_id": "bob_developer",
|
| 354 |
+
"role": "owner",
|
| 355 |
+
"scope_id": "scope-bob",
|
| 356 |
+
"etag": "etag-bob-personal"
|
| 357 |
+
},
|
| 358 |
+
# Carol's calendar ACLs (owner of her calendars)
|
| 359 |
+
{
|
| 360 |
+
"id": "acl-carol-primary",
|
| 361 |
+
"calendar_id": "carol-primary",
|
| 362 |
+
"user_id": "carol_designer",
|
| 363 |
+
"role": "owner",
|
| 364 |
+
"scope_id": "scope-carol",
|
| 365 |
+
"etag": "etag-carol-primary"
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"id": "acl-carol-design",
|
| 369 |
+
"calendar_id": "carol-design",
|
| 370 |
+
"user_id": "carol_designer",
|
| 371 |
+
"role": "owner",
|
| 372 |
+
"scope_id": "scope-carol",
|
| 373 |
+
"etag": "etag-carol-design"
|
| 374 |
+
},
|
| 375 |
+
# Dave's calendar ACLs (owner of his calendars)
|
| 376 |
+
{
|
| 377 |
+
"id": "acl-dave-primary",
|
| 378 |
+
"calendar_id": "dave-primary",
|
| 379 |
+
"user_id": "dave_sales",
|
| 380 |
+
"role": "owner",
|
| 381 |
+
"scope_id": "scope-dave",
|
| 382 |
+
"etag": "etag-dave-primary"
|
| 383 |
+
},
|
| 384 |
+
{
|
| 385 |
+
"id": "acl-dave-sales",
|
| 386 |
+
"calendar_id": "dave-sales",
|
| 387 |
+
"user_id": "dave_sales",
|
| 388 |
+
"role": "owner",
|
| 389 |
+
"scope_id": "scope-dave",
|
| 390 |
+
"etag": "etag-dave-sales"
|
| 391 |
+
},
|
| 392 |
+
# Shared access examples
|
| 393 |
+
{
|
| 394 |
+
"id": "acl-shared-1",
|
| 395 |
+
"calendar_id": "alice-projects",
|
| 396 |
+
"user_id": "alice_manager",
|
| 397 |
+
"role": "writer",
|
| 398 |
+
"scope_id": "scope-bob",
|
| 399 |
+
"etag": "etag-shared-1"
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"id": "acl-shared-2",
|
| 403 |
+
"calendar_id": "alice-projects",
|
| 404 |
+
"user_id": "alice_manager",
|
| 405 |
+
"role": "reader",
|
| 406 |
+
"scope_id": "scope-carol",
|
| 407 |
+
"etag": "etag-shared-2"
|
| 408 |
+
}
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
return {
|
| 412 |
+
"users": users,
|
| 413 |
+
"calendars": alice_calendars + bob_calendars + carol_calendars + dave_calendars,
|
| 414 |
+
"events": alice_events + bob_events + carol_events + dave_events,
|
| 415 |
+
"settings": settings,
|
| 416 |
+
"scopes": scopes,
|
| 417 |
+
"acls": acls,
|
| 418 |
+
"description": "Multi-user sample data with 4 users (Alice-PM, Bob-Dev, Carol-Design, Dave-Sales) demonstrating isolated data per user"
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def get_multi_user_sql(database_name: str = "multi_user_calendar") -> str:
|
| 423 |
+
"""
|
| 424 |
+
Generate SQL statements for multi-user sample data
|
| 425 |
+
"""
|
| 426 |
+
data = get_multi_user_sample_data()
|
| 427 |
+
|
| 428 |
+
sql_statements = []
|
| 429 |
+
|
| 430 |
+
# Header
|
| 431 |
+
sql_statements.append(f"-- Multi-User Calendar Sample Data for {database_name}")
|
| 432 |
+
sql_statements.append(f"-- Generated on: {datetime.now().isoformat()}")
|
| 433 |
+
sql_statements.append("-- Contains sample data for 4 users with isolated calendars and events")
|
| 434 |
+
sql_statements.append("")
|
| 435 |
+
|
| 436 |
+
# Users
|
| 437 |
+
sql_statements.append("-- Users")
|
| 438 |
+
sql_statements.append("INSERT INTO users (user_id, email, name, given_name, family_name, static_token, timezone, is_active, is_verified, created_at, updated_at) VALUES")
|
| 439 |
+
|
| 440 |
+
user_values = []
|
| 441 |
+
for user in data["users"]:
|
| 442 |
+
# Escape single quotes by doubling them
|
| 443 |
+
name = user['name'].replace("'", "''")
|
| 444 |
+
given_name = user.get('given_name', '').replace("'", "''")
|
| 445 |
+
family_name = user.get('family_name', '').replace("'", "''")
|
| 446 |
+
|
| 447 |
+
user_values.append(
|
| 448 |
+
f"('{user['user_id']}', '{user['email']}', '{name}', "
|
| 449 |
+
f"'{given_name}', '{family_name}', "
|
| 450 |
+
f"'{user['static_token']}', '{user['timezone']}', 1, 1, "
|
| 451 |
+
f"datetime('now'), datetime('now'))"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
sql_statements.append(",\n".join(user_values) + ";")
|
| 455 |
+
sql_statements.append("")
|
| 456 |
+
|
| 457 |
+
# Calendars
|
| 458 |
+
sql_statements.append("-- Calendars")
|
| 459 |
+
sql_statements.append("INSERT INTO calendars (calendar_id, user_id, summary, description, time_zone, is_primary, color_id, hidden, selected, deleted, created_at, updated_at) VALUES")
|
| 460 |
+
|
| 461 |
+
calendar_values = []
|
| 462 |
+
for calendar in data["calendars"]:
|
| 463 |
+
# Escape single quotes by doubling them
|
| 464 |
+
summary = calendar['summary'].replace("'", "''")
|
| 465 |
+
description = calendar.get('description', '').replace("'", "''")
|
| 466 |
+
|
| 467 |
+
calendar_values.append(
|
| 468 |
+
f"('{calendar['calendar_id']}', '{calendar['user_id']}', '{summary}', "
|
| 469 |
+
f"'{description}', '{calendar['time_zone']}', "
|
| 470 |
+
f"{1 if calendar.get('is_primary') else 0}, '{calendar.get('color_id', '1')}', "
|
| 471 |
+
f"0, 1, 0, datetime('now'), datetime('now'))"
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
sql_statements.append(",\n".join(calendar_values) + ";")
|
| 475 |
+
sql_statements.append("")
|
| 476 |
+
|
| 477 |
+
# Events
|
| 478 |
+
sql_statements = generate_enhanced_event_sql(sql_statements)
|
| 479 |
+
|
| 480 |
+
# Colors (shared across users)
|
| 481 |
+
sql_statements.append("-- Google Calendar Colors (shared across all users)")
|
| 482 |
+
from data.google_colors import GOOGLE_CALENDAR_COLORS
|
| 483 |
+
|
| 484 |
+
sql_statements.append("INSERT INTO colors (color_id, color_type, background, foreground, created_at, updated_at) VALUES")
|
| 485 |
+
|
| 486 |
+
color_values = []
|
| 487 |
+
for color in GOOGLE_CALENDAR_COLORS:
|
| 488 |
+
color_values.append(
|
| 489 |
+
f"('{color['color_id']}', '{color['color_type'].upper()}', '{color['background']}', "
|
| 490 |
+
f"'{color['foreground']}', datetime('now'), datetime('now'))"
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
sql_statements.append(",\n".join(color_values) + ";")
|
| 494 |
+
sql_statements.append("")
|
| 495 |
+
|
| 496 |
+
# Settings
|
| 497 |
+
sql_statements.append("-- Calendar Settings")
|
| 498 |
+
sql_statements.append("INSERT INTO settings (id, user_id, value) VALUES")
|
| 499 |
+
setting_values = []
|
| 500 |
+
for setting in data["settings"]:
|
| 501 |
+
setting_values.append(
|
| 502 |
+
f"('{setting['id']}', '{setting['user_id']}', '{setting['value']}')"
|
| 503 |
+
)
|
| 504 |
+
sql_statements.append(",\n".join(setting_values) + ";\n")
|
| 505 |
+
sql_statements.append("")
|
| 506 |
+
|
| 507 |
+
# Scopes
|
| 508 |
+
sql_statements.append("-- ACL Scopes")
|
| 509 |
+
sql_statements.append("INSERT INTO scopes (id, type, value) VALUES")
|
| 510 |
+
scope_values = []
|
| 511 |
+
for scope in data["scopes"]:
|
| 512 |
+
scope_values.append(
|
| 513 |
+
f"('{scope['id']}', '{scope['type']}', '{scope['value']}')"
|
| 514 |
+
)
|
| 515 |
+
sql_statements.append(",\n".join(scope_values) + ";\n")
|
| 516 |
+
sql_statements.append("")
|
| 517 |
+
|
| 518 |
+
# ACLs
|
| 519 |
+
sql_statements.append("-- Access Control Rules")
|
| 520 |
+
sql_statements.append("INSERT INTO acls (id, calendar_id, user_id, role, scope_id, etag) VALUES")
|
| 521 |
+
acl_values = []
|
| 522 |
+
for acl in data["acls"]:
|
| 523 |
+
acl_values.append(
|
| 524 |
+
f"('{acl['id']}', '{acl['calendar_id']}', '{acl['user_id']}', "
|
| 525 |
+
f"'{acl['role']}', '{acl['scope_id']}', '{acl['etag']}')"
|
| 526 |
+
)
|
| 527 |
+
sql_statements.append(",\n".join(acl_values) + ";\n")
|
| 528 |
+
sql_statements.append("")
|
| 529 |
+
|
| 530 |
+
sql_statements = get_watch_channel_sql(sql_statements)
|
| 531 |
+
|
| 532 |
+
return "\n".join(sql_statements)
|
server/data/watch_channel_seed_data.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Watch Channel Seed Data for Calendar Application
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_watch_channel_sample_data() -> Dict[str, Any]:
|
| 11 |
+
"""
|
| 12 |
+
Generate sample watch channel data for multi-user scenarios
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
# Base time for calculations
|
| 16 |
+
now = datetime.utcnow()
|
| 17 |
+
|
| 18 |
+
# Sample watch channels demonstrating various scenarios
|
| 19 |
+
watch_channels = [
|
| 20 |
+
# Alice's active watch channels
|
| 21 |
+
{
|
| 22 |
+
"id": "watch-alice-projects-001",
|
| 23 |
+
"resource_id": "acl-alice-projects",
|
| 24 |
+
"resource_uri": "/calendars/alice-projects/acl",
|
| 25 |
+
"resource_type": "acl",
|
| 26 |
+
"calendar_id": "alice-projects",
|
| 27 |
+
"user_id": "alice_manager",
|
| 28 |
+
"webhook_address": "https://techcorp.com/webhooks/alice/acl-notifications",
|
| 29 |
+
"webhook_token": "alice_token_abc123",
|
| 30 |
+
"webhook_type": "web_hook",
|
| 31 |
+
"params": json.dumps({"ttl": 14200}),
|
| 32 |
+
"created_at": now - timedelta(days=5),
|
| 33 |
+
"expires_at": now + timedelta(seconds=14200),
|
| 34 |
+
"last_notification_at": now - timedelta(hours=2),
|
| 35 |
+
"is_active": "true",
|
| 36 |
+
"notification_count": 12
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "watch-alice-team-002",
|
| 40 |
+
"resource_id": "acl-alice-team",
|
| 41 |
+
"resource_uri": "/calendars/alice-team/acl",
|
| 42 |
+
"resource_type": "acl",
|
| 43 |
+
"calendar_id": "alice-team",
|
| 44 |
+
"user_id": "alice_manager",
|
| 45 |
+
"webhook_address": "https://api.techcorp.com/calendar/notifications/team-acl",
|
| 46 |
+
"webhook_token": "team_secure_token_xyz789",
|
| 47 |
+
"webhook_type": "web_hook",
|
| 48 |
+
"params": json.dumps({"ttl": 11200}),
|
| 49 |
+
"created_at": now - timedelta(days=2),
|
| 50 |
+
"expires_at": now + timedelta(seconds=11200),
|
| 51 |
+
"last_notification_at": now - timedelta(hours=6),
|
| 52 |
+
"is_active": "true",
|
| 53 |
+
"notification_count": 5
|
| 54 |
+
},
|
| 55 |
+
|
| 56 |
+
# Bob's watch channels
|
| 57 |
+
{
|
| 58 |
+
"id": "watch-bob-dev-001",
|
| 59 |
+
"resource_id": "acl-bob-development",
|
| 60 |
+
"resource_uri": "/calendars/bob-development/acl",
|
| 61 |
+
"resource_type": "acl",
|
| 62 |
+
"calendar_id": "bob-development",
|
| 63 |
+
"user_id": "bob_developer",
|
| 64 |
+
"webhook_address": "https://hooks.slack.com/services/T123/B456/dev-calendar-notifications",
|
| 65 |
+
"webhook_token": None, # No token for Slack webhook
|
| 66 |
+
"webhook_type": "web_hook",
|
| 67 |
+
"params": json.dumps({"ttl": 9700}),
|
| 68 |
+
"created_at": now - timedelta(days=10),
|
| 69 |
+
"expires_at": now + timedelta(seconds=9700),
|
| 70 |
+
"last_notification_at": now - timedelta(days=1),
|
| 71 |
+
"is_active": "true",
|
| 72 |
+
"notification_count": 23
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": "watch-bob-personal-002",
|
| 76 |
+
"resource_id": "acl-bob-personal",
|
| 77 |
+
"resource_uri": "/calendars/bob-personal/acl",
|
| 78 |
+
"resource_type": "acl",
|
| 79 |
+
"calendar_id": "bob-personal",
|
| 80 |
+
"user_id": "bob_developer",
|
| 81 |
+
"webhook_address": "https://api.example.com/personal/calendar/webhook",
|
| 82 |
+
"webhook_token": "personal_webhook_token_def456",
|
| 83 |
+
"webhook_type": "web_hook",
|
| 84 |
+
"params": json.dumps({"ttl": 24390}),
|
| 85 |
+
"created_at": now - timedelta(days=1),
|
| 86 |
+
"expires_at": now + timedelta(seconds=24390),
|
| 87 |
+
"last_notification_at": None, # No notifications sent yet
|
| 88 |
+
"is_active": "true",
|
| 89 |
+
"notification_count": 0
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
# Carol's watch channels
|
| 93 |
+
{
|
| 94 |
+
"id": "watch-carol-design-001",
|
| 95 |
+
"resource_id": "acl-carol-design",
|
| 96 |
+
"resource_uri": "/calendars/carol-design/acl",
|
| 97 |
+
"resource_type": "acl",
|
| 98 |
+
"calendar_id": "carol-design",
|
| 99 |
+
"user_id": "carol_designer",
|
| 100 |
+
"webhook_address": "https://design-tools.techcorp.com/calendar/acl-updates",
|
| 101 |
+
"webhook_token": "design_team_token_ghi789",
|
| 102 |
+
"webhook_type": "web_hook",
|
| 103 |
+
"params": json.dumps({"ttl": 13200}),
|
| 104 |
+
"created_at": now - timedelta(days=7),
|
| 105 |
+
"expires_at": now + timedelta(seconds=13200),
|
| 106 |
+
"last_notification_at": now - timedelta(hours=12),
|
| 107 |
+
"is_active": "true",
|
| 108 |
+
"notification_count": 8
|
| 109 |
+
},
|
| 110 |
+
|
| 111 |
+
# Dave's watch channels
|
| 112 |
+
{
|
| 113 |
+
"id": "watch-dave-sales-001",
|
| 114 |
+
"resource_id": "acl-dave-sales",
|
| 115 |
+
"resource_uri": "/calendars/dave-sales/acl",
|
| 116 |
+
"resource_type": "acl",
|
| 117 |
+
"calendar_id": "dave-sales",
|
| 118 |
+
"user_id": "dave_sales",
|
| 119 |
+
"webhook_address": "https://crm.techcorp.com/calendar/sales-acl-sync",
|
| 120 |
+
"webhook_token": "sales_crm_token_jkl012",
|
| 121 |
+
"webhook_type": "web_hook",
|
| 122 |
+
"params": json.dumps({"ttl": 15600}),
|
| 123 |
+
"created_at": now - timedelta(days=14),
|
| 124 |
+
"expires_at": now + timedelta(seconds=15600),
|
| 125 |
+
"last_notification_at": now - timedelta(hours=4),
|
| 126 |
+
"is_active": "true",
|
| 127 |
+
"notification_count": 31
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
# Expired/Inactive watch channels (for testing cleanup)
|
| 131 |
+
{
|
| 132 |
+
"id": "watch-alice-expired-001",
|
| 133 |
+
"resource_id": "acl-alice-primary",
|
| 134 |
+
"resource_uri": "/calendars/alice-primary/acl",
|
| 135 |
+
"resource_type": "acl",
|
| 136 |
+
"calendar_id": "alice-primary",
|
| 137 |
+
"user_id": "alice_manager",
|
| 138 |
+
"webhook_address": "https://old-system.techcorp.com/webhooks/acl",
|
| 139 |
+
"webhook_token": "expired_token_mno345",
|
| 140 |
+
"webhook_type": "web_hook",
|
| 141 |
+
"params": json.dumps({"ttl": 2592000}),
|
| 142 |
+
"created_at": now - timedelta(days=35),
|
| 143 |
+
"expires_at": (now - timedelta(days=35)) + timedelta(seconds=2592000), # Expired 5 days ago
|
| 144 |
+
"last_notification_at": now - timedelta(days=6),
|
| 145 |
+
"is_active": "true", # Not yet cleaned up
|
| 146 |
+
"notification_count": 47
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
"id": "watch-bob-stopped-001",
|
| 150 |
+
"resource_id": "acl-bob-primary",
|
| 151 |
+
"resource_uri": "/calendars/bob-primary/acl",
|
| 152 |
+
"resource_type": "acl",
|
| 153 |
+
"calendar_id": "bob-primary",
|
| 154 |
+
"user_id": "bob_developer",
|
| 155 |
+
"webhook_address": "https://temp-webhook.example.com/test",
|
| 156 |
+
"webhook_token": "temp_token_pqr678",
|
| 157 |
+
"webhook_type": "web_hook",
|
| 158 |
+
"params": json.dumps({"ttl": 12000}),
|
| 159 |
+
"created_at": now - timedelta(days=8),
|
| 160 |
+
"expires_at": now + timedelta(seconds=12000),
|
| 161 |
+
"last_notification_at": now - timedelta(days=3),
|
| 162 |
+
"is_active": "false", # Manually stopped
|
| 163 |
+
"notification_count": 15
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
# Cross-calendar watch example (someone watching another user's shared calendar)
|
| 167 |
+
{
|
| 168 |
+
"id": "watch-bob-alice-shared-001",
|
| 169 |
+
"resource_id": "acl-alice-projects",
|
| 170 |
+
"resource_uri": "/calendars/alice-projects/acl",
|
| 171 |
+
"resource_type": "acl",
|
| 172 |
+
"calendar_id": "alice-projects", # Alice's calendar
|
| 173 |
+
"user_id": "bob_developer", # But Bob is watching it
|
| 174 |
+
"webhook_address": "https://api.example.com/shared-calendar/acl-watch",
|
| 175 |
+
"webhook_token": "shared_access_token_stu901",
|
| 176 |
+
"webhook_type": "web_hook",
|
| 177 |
+
"params": json.dumps({"ttl": 16800}),
|
| 178 |
+
"created_at": now - timedelta(days=3),
|
| 179 |
+
"expires_at": now + timedelta(seconds=16800),
|
| 180 |
+
"last_notification_at": now - timedelta(hours=18),
|
| 181 |
+
"is_active": "true",
|
| 182 |
+
"notification_count": 3
|
| 183 |
+
}
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"watch_channels": watch_channels,
|
| 188 |
+
"description": "Sample watch channel data demonstrating various ACL notification scenarios across multiple users"
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def get_watch_channel_sql(sql_statements) -> str:
|
| 193 |
+
"""
|
| 194 |
+
Generate SQL statements for watch channel sample data
|
| 195 |
+
"""
|
| 196 |
+
data = get_watch_channel_sample_data()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# Header
|
| 200 |
+
sql_statements.append(f"-- Generated on: {datetime.now().isoformat()}")
|
| 201 |
+
sql_statements.append("-- Contains sample watch channel data for ACL notifications")
|
| 202 |
+
sql_statements.append("")
|
| 203 |
+
|
| 204 |
+
# Watch Channels
|
| 205 |
+
sql_statements.append("-- Watch Channels for ACL Notifications")
|
| 206 |
+
sql_statements.append("INSERT INTO watch_channels (")
|
| 207 |
+
sql_statements.append(" id, resource_id, resource_uri, resource_type, calendar_id, user_id,")
|
| 208 |
+
sql_statements.append(" webhook_address, webhook_token, webhook_type, params,")
|
| 209 |
+
sql_statements.append(" created_at, expires_at, last_notification_at, is_active, notification_count")
|
| 210 |
+
sql_statements.append(") VALUES")
|
| 211 |
+
|
| 212 |
+
channel_values = []
|
| 213 |
+
for channel in data["watch_channels"]:
|
| 214 |
+
# Handle nullable fields
|
| 215 |
+
webhook_token = f"'{channel['webhook_token']}'" if channel['webhook_token'] else "NULL"
|
| 216 |
+
params = f"'{channel['params']}'" if channel['params'] else "NULL"
|
| 217 |
+
last_notification = f"'{channel['last_notification_at'].isoformat()}'" if channel['last_notification_at'] else "NULL"
|
| 218 |
+
expires_at = f"'{channel['expires_at'].isoformat()}'" if channel['expires_at'] else "NULL"
|
| 219 |
+
|
| 220 |
+
# Escape single quotes in webhook addresses
|
| 221 |
+
webhook_address = channel['webhook_address'].replace("'", "''")
|
| 222 |
+
|
| 223 |
+
channel_values.append(
|
| 224 |
+
f"('{channel['id']}', '{channel['resource_id']}', '{channel['resource_uri']}', "
|
| 225 |
+
f"'{channel['resource_type']}', '{channel['calendar_id']}', '{channel['user_id']}', "
|
| 226 |
+
f"'{webhook_address}', {webhook_token}, '{channel['webhook_type']}', {params}, "
|
| 227 |
+
f"'{channel['created_at'].isoformat()}', {expires_at}, {last_notification}, "
|
| 228 |
+
f"'{channel['is_active']}', {channel['notification_count']})"
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
sql_statements.append(",\n".join(channel_values) + ";")
|
| 232 |
+
sql_statements.append("")
|
| 233 |
+
|
| 234 |
+
# Add some comments about the data
|
| 235 |
+
sql_statements.append("-- Watch Channel Data Summary:")
|
| 236 |
+
sql_statements.append("-- - Alice: 2 active channels + 1 expired")
|
| 237 |
+
sql_statements.append("-- - Bob: 2 active channels + 1 stopped + 1 shared calendar watch")
|
| 238 |
+
sql_statements.append("-- - Carol: 1 active channel")
|
| 239 |
+
sql_statements.append("-- - Dave: 1 active channel")
|
| 240 |
+
sql_statements.append("-- - Total: 6 active, 1 expired, 1 manually stopped")
|
| 241 |
+
sql_statements.append("-- - Demonstrates various webhook URLs (Slack, CRM, internal APIs)")
|
| 242 |
+
sql_statements.append("-- - Shows different notification patterns and usage levels")
|
| 243 |
+
sql_statements.append("")
|
| 244 |
+
|
| 245 |
+
return sql_statements
|
server/database/__init__.py
ADDED
|
File without changes
|
server/database/base_manager.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Base Manager - Common CRUD operations for all Calendar services
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import sqlite3
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, Optional, List, Any
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BaseManager:
|
| 14 |
+
"""Base manager for common database operations"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, db_path: str):
|
| 17 |
+
self.db_path = db_path
|
| 18 |
+
|
| 19 |
+
def execute_query(self, query: str, params: tuple = ()) -> List[Dict]:
|
| 20 |
+
"""Execute a SELECT query and return results"""
|
| 21 |
+
conn = sqlite3.connect(self.db_path)
|
| 22 |
+
conn.row_factory = sqlite3.Row
|
| 23 |
+
try:
|
| 24 |
+
cursor = conn.execute(query, params)
|
| 25 |
+
return [dict(row) for row in cursor.fetchall()]
|
| 26 |
+
finally:
|
| 27 |
+
conn.close()
|
| 28 |
+
|
| 29 |
+
def execute_insert(self, query: str, params: tuple = ()) -> int:
|
| 30 |
+
"""Execute an INSERT query and return the last row ID"""
|
| 31 |
+
conn = sqlite3.connect(self.db_path)
|
| 32 |
+
try:
|
| 33 |
+
cursor = conn.execute(query, params)
|
| 34 |
+
conn.commit()
|
| 35 |
+
return cursor.lastrowid
|
| 36 |
+
except sqlite3.IntegrityError as e:
|
| 37 |
+
logger.error(f"Database integrity error: {e}")
|
| 38 |
+
raise ValueError(f"Database constraint violation: {e}")
|
| 39 |
+
finally:
|
| 40 |
+
conn.close()
|
| 41 |
+
|
| 42 |
+
def execute_update(self, query: str, params: tuple = ()) -> int:
|
| 43 |
+
"""Execute an UPDATE query and return the number of affected rows"""
|
| 44 |
+
conn = sqlite3.connect(self.db_path)
|
| 45 |
+
try:
|
| 46 |
+
cursor = conn.execute(query, params)
|
| 47 |
+
conn.commit()
|
| 48 |
+
return cursor.rowcount
|
| 49 |
+
finally:
|
| 50 |
+
conn.close()
|
| 51 |
+
|
| 52 |
+
def execute_delete(self, query: str, params: tuple = ()) -> int:
|
| 53 |
+
"""Execute a DELETE query and return the number of affected rows"""
|
| 54 |
+
conn = sqlite3.connect(self.db_path)
|
| 55 |
+
try:
|
| 56 |
+
cursor = conn.execute(query, params)
|
| 57 |
+
conn.commit()
|
| 58 |
+
return cursor.rowcount
|
| 59 |
+
finally:
|
| 60 |
+
conn.close()
|
| 61 |
+
|
| 62 |
+
def get_by_id(self, table: str, record_id: int) -> Optional[Dict]:
|
| 63 |
+
"""Get a record by ID from any table"""
|
| 64 |
+
query = f"SELECT * FROM {table} WHERE id = ?"
|
| 65 |
+
results = self.execute_query(query, (record_id,))
|
| 66 |
+
return results[0] if results else None
|
| 67 |
+
|
| 68 |
+
def get_all(self, table: str, limit: int = 100, offset: int = 0, order_by: str = "id DESC") -> List[Dict]:
|
| 69 |
+
"""Get all records from a table with pagination"""
|
| 70 |
+
query = f"SELECT * FROM {table} ORDER BY {order_by} LIMIT ? OFFSET ?"
|
| 71 |
+
return self.execute_query(query, (limit, offset))
|
| 72 |
+
|
| 73 |
+
def count_records(self, table: str, where_clause: str = "", params: tuple = ()) -> int:
|
| 74 |
+
"""Count records in a table"""
|
| 75 |
+
query = f"SELECT COUNT(*) as count FROM {table}"
|
| 76 |
+
if where_clause:
|
| 77 |
+
query += f" WHERE {where_clause}"
|
| 78 |
+
result = self.execute_query(query, params)
|
| 79 |
+
return result[0]["count"] if result else 0
|
| 80 |
+
|
| 81 |
+
def update_record(self, table: str, record_id: int, updates: Dict[str, Any]) -> bool:
|
| 82 |
+
"""Update a record with given field values"""
|
| 83 |
+
if not updates:
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
# Add updated_at timestamp if the table has this column
|
| 87 |
+
updates["updated_at"] = datetime.now().isoformat()
|
| 88 |
+
|
| 89 |
+
set_clauses = []
|
| 90 |
+
params = []
|
| 91 |
+
|
| 92 |
+
for field, value in updates.items():
|
| 93 |
+
set_clauses.append(f"{field} = ?")
|
| 94 |
+
params.append(value)
|
| 95 |
+
|
| 96 |
+
params.append(record_id)
|
| 97 |
+
|
| 98 |
+
query = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE id = ?"
|
| 99 |
+
affected_rows = self.execute_update(query, tuple(params))
|
| 100 |
+
return affected_rows > 0
|
| 101 |
+
|
| 102 |
+
def delete_record(self, table: str, record_id: int) -> bool:
|
| 103 |
+
"""Delete a record by ID"""
|
| 104 |
+
query = f"DELETE FROM {table} WHERE id = ?"
|
| 105 |
+
affected_rows = self.execute_delete(query, (record_id,))
|
| 106 |
+
return affected_rows > 0
|
| 107 |
+
|
| 108 |
+
def table_exists(self, table_name: str) -> bool:
|
| 109 |
+
"""Check if a table exists"""
|
| 110 |
+
query = "SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
| 111 |
+
result = self.execute_query(query, (table_name,))
|
| 112 |
+
return len(result) > 0
|
server/database/managers/__init__.py
ADDED
|
File without changes
|
server/database/managers/acl_manager.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from sqlalchemy import and_, or_, desc
|
| 3 |
+
from database.models.acl import ACLs, Scope, AclRole
|
| 4 |
+
from database.models.watch_channel import WatchChannel
|
| 5 |
+
from schemas.acl import ACLRuleInput, PatchACLRuleInput, Channel, ACLListResponse, ACLRule, ScopeInput, ScopeOutput
|
| 6 |
+
from services.notification_service import get_notification_service
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Dict, Any, Optional
|
| 10 |
+
import logging
|
| 11 |
+
import json
|
| 12 |
+
import uuid
|
| 13 |
+
import base64
|
| 14 |
+
from database.models.calendar import Calendar
|
| 15 |
+
from database.models.user import User
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ACLManager:
|
| 21 |
+
def __init__(self, db: Session, user_id: str):
|
| 22 |
+
self.db = db
|
| 23 |
+
self.user_id = user_id
|
| 24 |
+
|
| 25 |
+
def validate_calendar_id(self, calendar_id, user_id):
|
| 26 |
+
calendar = self.db.query(Calendar).filter(
|
| 27 |
+
Calendar.calendar_id == calendar_id,
|
| 28 |
+
Calendar.user_id == user_id,
|
| 29 |
+
Calendar.deleted == False
|
| 30 |
+
).first()
|
| 31 |
+
if not calendar:
|
| 32 |
+
return False
|
| 33 |
+
return True
|
| 34 |
+
|
| 35 |
+
def list_rules(self, calendar_id: str, max_results: int = 100,
|
| 36 |
+
page_token: Optional[str] = None, show_deleted: bool = False,
|
| 37 |
+
sync_token: Optional[str] = None) -> ACLListResponse:
|
| 38 |
+
"""
|
| 39 |
+
List ACL rules for a calendar with pagination and filtering support.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
calendar_id: The calendar ID
|
| 43 |
+
max_results: Maximum number of entries per page (1-250, default 100)
|
| 44 |
+
page_token: Token for pagination continuation
|
| 45 |
+
show_deleted: Whether to include deleted ACLs (role == "none")
|
| 46 |
+
sync_token: Token for incremental synchronization
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
ACLListResponse with items and pagination tokens
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
# Handle sync token for incremental synchronization
|
| 53 |
+
if sync_token:
|
| 54 |
+
return self._handle_sync_request(calendar_id, sync_token, max_results, show_deleted)
|
| 55 |
+
|
| 56 |
+
# Base query
|
| 57 |
+
query = self.db.query(ACLs).filter(
|
| 58 |
+
ACLs.calendar_id == calendar_id,
|
| 59 |
+
ACLs.user_id == self.user_id
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Filter deleted ACLs if not requested
|
| 63 |
+
if not show_deleted:
|
| 64 |
+
query = query.filter(ACLs.role != AclRole.none)
|
| 65 |
+
|
| 66 |
+
# Order by created_at for consistent pagination
|
| 67 |
+
query = query.order_by(ACLs.created_at, ACLs.id)
|
| 68 |
+
|
| 69 |
+
# Handle pagination
|
| 70 |
+
offset = 0
|
| 71 |
+
if page_token:
|
| 72 |
+
try:
|
| 73 |
+
offset = int(base64.b64decode(page_token).decode('utf-8'))
|
| 74 |
+
except (ValueError, TypeError):
|
| 75 |
+
raise ValueError("Invalid pageToken")
|
| 76 |
+
|
| 77 |
+
# Get one extra item to determine if there's a next page
|
| 78 |
+
items = query.offset(offset).limit(max_results + 1).all()
|
| 79 |
+
|
| 80 |
+
# Determine if there's a next page
|
| 81 |
+
has_next_page = len(items) > max_results
|
| 82 |
+
if has_next_page:
|
| 83 |
+
items = items[:max_results] # Remove the extra item
|
| 84 |
+
next_page_token = base64.b64encode(str(offset + max_results).encode('utf-8')).decode('utf-8')
|
| 85 |
+
else:
|
| 86 |
+
next_page_token = None
|
| 87 |
+
|
| 88 |
+
# Generate next sync token
|
| 89 |
+
latest_updated = self.db.query(ACLs.updated_at).filter(
|
| 90 |
+
ACLs.calendar_id == calendar_id,
|
| 91 |
+
ACLs.user_id == self.user_id
|
| 92 |
+
).order_by(desc(ACLs.updated_at)).first()
|
| 93 |
+
|
| 94 |
+
next_sync_token = None
|
| 95 |
+
if latest_updated and latest_updated[0]:
|
| 96 |
+
sync_data = {
|
| 97 |
+
'calendar_id': calendar_id,
|
| 98 |
+
'timestamp': latest_updated[0].isoformat()
|
| 99 |
+
}
|
| 100 |
+
next_sync_token = base64.b64encode(json.dumps(sync_data).encode('utf-8')).decode('utf-8')
|
| 101 |
+
|
| 102 |
+
# Generate collection etag
|
| 103 |
+
etag = f'"{uuid4()}"'
|
| 104 |
+
|
| 105 |
+
# Convert database models to schema models
|
| 106 |
+
acl_rules = []
|
| 107 |
+
for item in items:
|
| 108 |
+
if item.scope.type == "default":
|
| 109 |
+
scope = ScopeOutput(type=item.scope.type)
|
| 110 |
+
else:
|
| 111 |
+
scope = ScopeOutput(
|
| 112 |
+
type=item.scope.type,
|
| 113 |
+
value=item.scope.value
|
| 114 |
+
)
|
| 115 |
+
acl_rule = ACLRule(
|
| 116 |
+
id=item.id,
|
| 117 |
+
calendar_id=item.calendar_id,
|
| 118 |
+
user_id=item.user_id,
|
| 119 |
+
role=item.role,
|
| 120 |
+
etag=item.etag,
|
| 121 |
+
scope=scope
|
| 122 |
+
)
|
| 123 |
+
acl_rules.append(acl_rule)
|
| 124 |
+
|
| 125 |
+
return ACLListResponse(
|
| 126 |
+
etag=etag,
|
| 127 |
+
items=acl_rules,
|
| 128 |
+
nextPageToken=next_page_token,
|
| 129 |
+
nextSyncToken=next_sync_token
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(f"Error listing ACL rules: {e}")
|
| 134 |
+
raise
|
| 135 |
+
|
| 136 |
+
def _handle_sync_request(self, calendar_id: str, sync_token: str,
|
| 137 |
+
max_results: int, show_deleted: bool) -> ACLListResponse:
|
| 138 |
+
"""
|
| 139 |
+
Handle incremental synchronization request.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
calendar_id: The calendar ID
|
| 143 |
+
sync_token: The sync token from previous request
|
| 144 |
+
max_results: Maximum number of entries per page
|
| 145 |
+
show_deleted: Whether to include deleted ACLs
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
ACLListResponse with changes since the sync token
|
| 149 |
+
"""
|
| 150 |
+
try:
|
| 151 |
+
# Decode sync token
|
| 152 |
+
sync_data = json.loads(base64.b64decode(sync_token).decode('utf-8'))
|
| 153 |
+
last_sync_time = datetime.fromisoformat(sync_data['timestamp'])
|
| 154 |
+
|
| 155 |
+
# Verify calendar ID matches
|
| 156 |
+
if sync_data.get('calendar_id') != calendar_id:
|
| 157 |
+
raise ValueError("Sync token calendar ID mismatch")
|
| 158 |
+
|
| 159 |
+
# Check if sync token is too old (expired)
|
| 160 |
+
if (datetime.utcnow() - last_sync_time).days > 7: # 7 days expiration
|
| 161 |
+
from fastapi import HTTPException
|
| 162 |
+
raise HTTPException(status_code=410, detail="Sync token expired")
|
| 163 |
+
|
| 164 |
+
# Query for changes since last sync
|
| 165 |
+
query = self.db.query(ACLs).filter(
|
| 166 |
+
ACLs.calendar_id == calendar_id,
|
| 167 |
+
ACLs.user_id == self.user_id,
|
| 168 |
+
ACLs.updated_at > last_sync_time
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Always include deleted ACLs in sync requests (Google API behavior)
|
| 172 |
+
# Order by updated_at for consistent results
|
| 173 |
+
query = query.order_by(ACLs.updated_at, ACLs.id)
|
| 174 |
+
|
| 175 |
+
# Apply limit
|
| 176 |
+
items = query.limit(max_results + 1).all()
|
| 177 |
+
|
| 178 |
+
# Determine if there's more data
|
| 179 |
+
has_more = len(items) > max_results
|
| 180 |
+
if has_more:
|
| 181 |
+
items = items[:max_results]
|
| 182 |
+
# For sync requests, we don't use page tokens, just return what we have
|
| 183 |
+
# Client should make another sync request with updated sync token
|
| 184 |
+
|
| 185 |
+
# Generate new sync token based on latest item
|
| 186 |
+
next_sync_token = None
|
| 187 |
+
if items:
|
| 188 |
+
latest_time = max(item.updated_at for item in items)
|
| 189 |
+
sync_data = {
|
| 190 |
+
'calendar_id': calendar_id,
|
| 191 |
+
'timestamp': latest_time.isoformat()
|
| 192 |
+
}
|
| 193 |
+
next_sync_token = base64.b64encode(json.dumps(sync_data).encode('utf-8')).decode('utf-8')
|
| 194 |
+
else:
|
| 195 |
+
# No changes, return same sync token
|
| 196 |
+
next_sync_token = sync_token
|
| 197 |
+
|
| 198 |
+
# Generate collection etag
|
| 199 |
+
etag = f'"{uuid4()}"'
|
| 200 |
+
|
| 201 |
+
# Convert database models to schema models
|
| 202 |
+
acl_rules = []
|
| 203 |
+
for item in items:
|
| 204 |
+
scope = ScopeInput(
|
| 205 |
+
type=item.scope.type,
|
| 206 |
+
value=item.scope.value
|
| 207 |
+
)
|
| 208 |
+
acl_rule = ACLRule(
|
| 209 |
+
id=item.id,
|
| 210 |
+
calendar_id=item.calendar_id,
|
| 211 |
+
user_id=item.user_id,
|
| 212 |
+
role=item.role,
|
| 213 |
+
etag=item.etag,
|
| 214 |
+
scope=scope
|
| 215 |
+
)
|
| 216 |
+
acl_rules.append(acl_rule)
|
| 217 |
+
|
| 218 |
+
return ACLListResponse(
|
| 219 |
+
etag=etag,
|
| 220 |
+
items=acl_rules,
|
| 221 |
+
nextPageToken=None, # No page tokens in sync mode
|
| 222 |
+
nextSyncToken=next_sync_token
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
except json.JSONDecodeError:
|
| 226 |
+
raise ValueError("Invalid sync token format")
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"Error handling sync request: {e}")
|
| 229 |
+
raise
|
| 230 |
+
|
| 231 |
+
def get_rule(self, calendar_id: str, rule_id: str):
|
| 232 |
+
"""
|
| 233 |
+
Retrieve a specific ACL rule by calendar ID and rule ID (must be owned).
|
| 234 |
+
"""
|
| 235 |
+
return self.db.query(ACLs).filter_by(
|
| 236 |
+
id=rule_id,
|
| 237 |
+
calendar_id=calendar_id,
|
| 238 |
+
user_id=self.user_id
|
| 239 |
+
).first()
|
| 240 |
+
|
| 241 |
+
def insert_rule(self, calendar_id: str, rule: ACLRuleInput, send_notifications: bool = True):
|
| 242 |
+
"""
|
| 243 |
+
Insert a new ACL rule after validating scope existence.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
calendar_id: The calendar ID
|
| 247 |
+
rule: The ACL rule input data
|
| 248 |
+
send_notifications: Whether to send notifications about the calendar sharing change (default: True)
|
| 249 |
+
|
| 250 |
+
Returns the inserted ACL rule.
|
| 251 |
+
"""
|
| 252 |
+
# Look up scope (must exist)
|
| 253 |
+
if rule.scope.type == "default":
|
| 254 |
+
scope = self.db.query(Scope).filter(Scope.type == rule.scope.type).first()
|
| 255 |
+
else:
|
| 256 |
+
if rule.scope.value is None:
|
| 257 |
+
scope = self.db.query(Scope).filter(Scope.type == rule.scope.type).first()
|
| 258 |
+
else:
|
| 259 |
+
scope = (
|
| 260 |
+
self.db.query(Scope)
|
| 261 |
+
.filter(Scope.type == rule.scope.type, Scope.value == rule.scope.value)
|
| 262 |
+
.first()
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
if not scope:
|
| 266 |
+
raise ValueError(f"Scope ({rule.scope.type}, {rule.scope.value}) not found")
|
| 267 |
+
|
| 268 |
+
# Create ACL rule
|
| 269 |
+
rule_id = f"{uuid4()}"
|
| 270 |
+
db_rule = ACLs(
|
| 271 |
+
id=rule_id,
|
| 272 |
+
calendar_id=calendar_id,
|
| 273 |
+
user_id=self.user_id,
|
| 274 |
+
role=rule.role,
|
| 275 |
+
scope_id=scope.id,
|
| 276 |
+
etag=f'"{uuid4()}"',
|
| 277 |
+
created_at=datetime.utcnow(),
|
| 278 |
+
updated_at=datetime.utcnow()
|
| 279 |
+
)
|
| 280 |
+
self.db.add(db_rule)
|
| 281 |
+
self.db.commit()
|
| 282 |
+
self.db.refresh(db_rule)
|
| 283 |
+
|
| 284 |
+
# Send notification for ACL rule insertion if notifications are enabled
|
| 285 |
+
if send_notifications:
|
| 286 |
+
self._send_acl_notification(calendar_id, "insert", {
|
| 287 |
+
"id": db_rule.id,
|
| 288 |
+
"calendar_id": db_rule.calendar_id,
|
| 289 |
+
"user_id": db_rule.user_id,
|
| 290 |
+
"role": db_rule.role.value,
|
| 291 |
+
"scope": scope.as_dict(),
|
| 292 |
+
"etag": db_rule.etag
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
response = {
|
| 296 |
+
"kind": "calendar#aclRule",
|
| 297 |
+
"etag": db_rule.etag,
|
| 298 |
+
"id": db_rule.id,
|
| 299 |
+
"scope":{},
|
| 300 |
+
"role": db_rule.role.value
|
| 301 |
+
}
|
| 302 |
+
scope_dict = scope.as_dict()
|
| 303 |
+
response["scope"]["type"] = scope_dict.get("type")
|
| 304 |
+
if scope_dict.get("value") != "public":
|
| 305 |
+
response["scope"]["value"] = scope_dict.get("value")
|
| 306 |
+
return response
|
| 307 |
+
|
| 308 |
+
def update_rule(self, calendar_id: str, rule_id: str, rule: ACLRuleInput, send_notifications: bool = True):
|
| 309 |
+
"""
|
| 310 |
+
Fully replace an existing ACL rule's role and scope.
|
| 311 |
+
|
| 312 |
+
Returns the updated rule or None if not found.
|
| 313 |
+
"""
|
| 314 |
+
db_rule = self.get_rule(calendar_id, rule_id)
|
| 315 |
+
|
| 316 |
+
if not db_rule:
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
if rule.role is not None:
|
| 320 |
+
db_rule.role = rule.role
|
| 321 |
+
# Update scope
|
| 322 |
+
if rule.scope is not None:
|
| 323 |
+
if db_rule.scope:
|
| 324 |
+
db_rule.scope.type = rule.scope.type
|
| 325 |
+
if rule.scope.value is not None and rule.scope.type != "default":
|
| 326 |
+
if rule.scope.type in ["user", "group"]:
|
| 327 |
+
# Validate whether value contains valid email address
|
| 328 |
+
user = self.db.query(User).filter(User.email == rule.scope.value, User.is_active == True).first()
|
| 329 |
+
if user is None:
|
| 330 |
+
raise ValueError("Invalid data in 'value field'. Please enter an existing email id in 'value' field")
|
| 331 |
+
db_rule.scope.value = rule.scope.value
|
| 332 |
+
else:
|
| 333 |
+
raise ValueError("ACL rule has no associated scope object to update.")
|
| 334 |
+
db_rule.updated_at = datetime.utcnow()
|
| 335 |
+
self.db.commit()
|
| 336 |
+
self.db.refresh(db_rule)
|
| 337 |
+
|
| 338 |
+
# Send notification for ACL rule update if notifications are enabled
|
| 339 |
+
if send_notifications:
|
| 340 |
+
self._send_acl_notification(calendar_id, "update", {
|
| 341 |
+
"id": db_rule.id,
|
| 342 |
+
"calendar_id": db_rule.calendar_id,
|
| 343 |
+
"user_id": db_rule.user_id,
|
| 344 |
+
"role": db_rule.role.value,
|
| 345 |
+
"scope": db_rule.scope.as_dict(),
|
| 346 |
+
"etag": db_rule.etag
|
| 347 |
+
})
|
| 348 |
+
|
| 349 |
+
response = {
|
| 350 |
+
"kind": "calendar#aclRule",
|
| 351 |
+
"etag": db_rule.etag,
|
| 352 |
+
"id": db_rule.id,
|
| 353 |
+
"scope":{},
|
| 354 |
+
"role": db_rule.role.value
|
| 355 |
+
}
|
| 356 |
+
scope_dict = db_rule.scope.as_dict()
|
| 357 |
+
response["scope"]["type"] = scope_dict.get("type")
|
| 358 |
+
if scope_dict.get("value") != "public":
|
| 359 |
+
response["scope"]["value"] = scope_dict.get("value")
|
| 360 |
+
return response
|
| 361 |
+
|
| 362 |
+
def patch_rule(self, calendar_id: str, rule_id: str, rule: PatchACLRuleInput, send_notifications: bool = True):
|
| 363 |
+
"""
|
| 364 |
+
Partially update an ACL rule's role or scope if provided.
|
| 365 |
+
|
| 366 |
+
Returns the updated rule or None if not found.
|
| 367 |
+
"""
|
| 368 |
+
db_rule = self.get_rule(calendar_id, rule_id)
|
| 369 |
+
if not db_rule:
|
| 370 |
+
return None
|
| 371 |
+
|
| 372 |
+
if rule.role is not None:
|
| 373 |
+
db_rule.role = rule.role
|
| 374 |
+
|
| 375 |
+
if rule.scope is not None:
|
| 376 |
+
# Patch the related Scope object, not as a dict
|
| 377 |
+
if db_rule.scope:
|
| 378 |
+
db_rule.scope.type = rule.scope.type
|
| 379 |
+
if rule.scope.value is not None and rule.scope.type != "default":
|
| 380 |
+
if rule.scope.type in ["user", "group"]:
|
| 381 |
+
# Validate whether value contains valid email address
|
| 382 |
+
user = self.db.query(User).filter(User.email == rule.scope.value, User.is_active == True).first()
|
| 383 |
+
if user is None:
|
| 384 |
+
raise ValueError("Invalid data in 'value field'. Please enter an existing email id in 'value' field")
|
| 385 |
+
db_rule.scope.value = rule.scope.value
|
| 386 |
+
else:
|
| 387 |
+
raise ValueError("ACL rule has no associated scope object to patch.")
|
| 388 |
+
|
| 389 |
+
db_rule.updated_at = datetime.utcnow()
|
| 390 |
+
self.db.commit()
|
| 391 |
+
self.db.refresh(db_rule)
|
| 392 |
+
|
| 393 |
+
# Send notification for ACL rule patch if notifications are enabled
|
| 394 |
+
if send_notifications:
|
| 395 |
+
self._send_acl_notification(calendar_id, "update", {
|
| 396 |
+
"id": db_rule.id,
|
| 397 |
+
"calendar_id": db_rule.calendar_id,
|
| 398 |
+
"user_id": db_rule.user_id,
|
| 399 |
+
"role": db_rule.role.value,
|
| 400 |
+
"scope": db_rule.scope.as_dict(),
|
| 401 |
+
"etag": db_rule.etag
|
| 402 |
+
})
|
| 403 |
+
|
| 404 |
+
response = {
|
| 405 |
+
"kind": "calendar#aclRule",
|
| 406 |
+
"etag": db_rule.etag,
|
| 407 |
+
"id": db_rule.id,
|
| 408 |
+
"scope":{},
|
| 409 |
+
"role": db_rule.role.value
|
| 410 |
+
}
|
| 411 |
+
scope_dict = db_rule.scope.as_dict()
|
| 412 |
+
response["scope"]["type"] = scope_dict.get("type")
|
| 413 |
+
if scope_dict.get("value") != "public":
|
| 414 |
+
response["scope"]["value"] = scope_dict.get("value")
|
| 415 |
+
return response
|
| 416 |
+
|
| 417 |
+
def delete_rule(self, calendar_id: str, rule_id: str) -> bool:
|
| 418 |
+
"""
|
| 419 |
+
Delete an ACL rule by ID and calendar ID. Only the calendar owner can delete ACLs.
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
True if deleted, False if rule not found.
|
| 423 |
+
Raises:
|
| 424 |
+
Exception if DB operation fails.
|
| 425 |
+
"""
|
| 426 |
+
session = self.db
|
| 427 |
+
try:
|
| 428 |
+
from database.models import Calendar # to resolve join dependency
|
| 429 |
+
rule = session.query(ACLs).join(Calendar).filter(
|
| 430 |
+
ACLs.id == rule_id,
|
| 431 |
+
ACLs.calendar_id == calendar_id,
|
| 432 |
+
Calendar.user_id == self.user_id
|
| 433 |
+
).first()
|
| 434 |
+
|
| 435 |
+
if not rule:
|
| 436 |
+
return False
|
| 437 |
+
|
| 438 |
+
# Capture rule data before deletion for notification
|
| 439 |
+
rule_data = {
|
| 440 |
+
"id": rule.id,
|
| 441 |
+
"calendar_id": rule.calendar_id,
|
| 442 |
+
"user_id": rule.user_id,
|
| 443 |
+
"role": rule.role.value,
|
| 444 |
+
"scope": rule.scope.as_dict() if rule.scope else {},
|
| 445 |
+
"etag": rule.etag
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
session.delete(rule)
|
| 449 |
+
session.commit()
|
| 450 |
+
|
| 451 |
+
# Send notification for ACL rule deletion
|
| 452 |
+
self._send_acl_notification(calendar_id, "delete", rule_data)
|
| 453 |
+
|
| 454 |
+
return True
|
| 455 |
+
|
| 456 |
+
except Exception as e:
|
| 457 |
+
session.rollback()
|
| 458 |
+
raise
|
| 459 |
+
|
| 460 |
+
def watch_acl(
|
| 461 |
+
self,
|
| 462 |
+
user_id:str,
|
| 463 |
+
calendar_id: str,
|
| 464 |
+
watch_request: Dict[str, Any]
|
| 465 |
+
) -> Channel:
|
| 466 |
+
"""
|
| 467 |
+
Set up watch notifications for ACL changes.
|
| 468 |
+
|
| 469 |
+
POST /calendars/{calendarId}/acl/watch
|
| 470 |
+
"""
|
| 471 |
+
try:
|
| 472 |
+
session = self.db
|
| 473 |
+
|
| 474 |
+
# Generate unique resource ID for events watch
|
| 475 |
+
resource_id = f"acl-{calendar_id}-{uuid.uuid4().hex[:8]}"
|
| 476 |
+
resource_uri = f"/calendars/{calendar_id}/acl"
|
| 477 |
+
# Validate that the user has access to the calendar
|
| 478 |
+
|
| 479 |
+
# Default expiration: 24 hours from now
|
| 480 |
+
expires_at = datetime.utcnow() + timedelta(hours=24)
|
| 481 |
+
|
| 482 |
+
# Verify calendar belongs to user
|
| 483 |
+
calendar = session.query(Calendar).filter(
|
| 484 |
+
Calendar.calendar_id == calendar_id,
|
| 485 |
+
Calendar.user_id == user_id
|
| 486 |
+
).first()
|
| 487 |
+
if not calendar:
|
| 488 |
+
raise ValueError(f"Calendar {calendar_id} not found for user {user_id}")
|
| 489 |
+
|
| 490 |
+
if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first():
|
| 491 |
+
raise ValueError(f"Channel with Id {watch_request.id} already exists")
|
| 492 |
+
|
| 493 |
+
# Create watch channel record
|
| 494 |
+
watch_channel = WatchChannel(
|
| 495 |
+
id=watch_request.id,
|
| 496 |
+
resource_id=resource_id,
|
| 497 |
+
resource_uri=resource_uri,
|
| 498 |
+
resource_type="acl",
|
| 499 |
+
calendar_id=calendar_id,
|
| 500 |
+
user_id=user_id,
|
| 501 |
+
webhook_address=watch_request.address,
|
| 502 |
+
webhook_token=watch_request.token,
|
| 503 |
+
webhook_type=watch_request.type,
|
| 504 |
+
params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None,
|
| 505 |
+
created_at=datetime.utcnow(),
|
| 506 |
+
expires_at=expires_at,
|
| 507 |
+
is_active="true",
|
| 508 |
+
notification_count=0
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
# Save to database
|
| 512 |
+
session.add(watch_channel)
|
| 513 |
+
session.commit()
|
| 514 |
+
|
| 515 |
+
logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}")
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# Create response channel
|
| 520 |
+
channel = Channel(
|
| 521 |
+
id=watch_channel.id,
|
| 522 |
+
resourceId=resource_id,
|
| 523 |
+
resourceUri=watch_channel.resource_uri,
|
| 524 |
+
token=watch_channel.webhook_token,
|
| 525 |
+
expiration=watch_channel.expires_at.isoformat() + "Z" if watch_channel.expires_at else None
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
logger.info(f"Set up watch channel {watch_channel.id} for ACL changes in calendar {calendar_id}")
|
| 529 |
+
return channel
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
logger.error(f"Error setting up ACL watch for calendar {calendar_id}: {e}")
|
| 533 |
+
self.db.rollback()
|
| 534 |
+
raise
|
| 535 |
+
|
| 536 |
+
def cleanup_expired_channels(self) -> int:
|
| 537 |
+
"""
|
| 538 |
+
Clean up expired watch channels for this user
|
| 539 |
+
|
| 540 |
+
Returns:
|
| 541 |
+
Number of channels cleaned up
|
| 542 |
+
"""
|
| 543 |
+
try:
|
| 544 |
+
current_time = datetime.utcnow()
|
| 545 |
+
|
| 546 |
+
# Find expired channels for this user
|
| 547 |
+
expired_channels = self.db.query(WatchChannel).filter(
|
| 548 |
+
WatchChannel.user_id == self.user_id,
|
| 549 |
+
WatchChannel.expires_at < current_time,
|
| 550 |
+
WatchChannel.is_active == "true"
|
| 551 |
+
).all()
|
| 552 |
+
|
| 553 |
+
cleanup_count = 0
|
| 554 |
+
for channel in expired_channels:
|
| 555 |
+
channel.is_active = "false"
|
| 556 |
+
cleanup_count += 1
|
| 557 |
+
|
| 558 |
+
if cleanup_count > 0:
|
| 559 |
+
self.db.commit()
|
| 560 |
+
logger.info(f"Cleaned up {cleanup_count} expired watch channels for user {self.user_id}")
|
| 561 |
+
|
| 562 |
+
return cleanup_count
|
| 563 |
+
|
| 564 |
+
except Exception as e:
|
| 565 |
+
logger.error(f"Error cleaning up expired channels: {e}")
|
| 566 |
+
self.db.rollback()
|
| 567 |
+
return 0
|
| 568 |
+
|
| 569 |
+
def _send_acl_notification(self, calendar_id: str, change_type: str, acl_data: Dict[str, Any]):
|
| 570 |
+
"""
|
| 571 |
+
Send ACL change notification to all active watch channels for the calendar
|
| 572 |
+
|
| 573 |
+
Args:
|
| 574 |
+
calendar_id: The calendar ID
|
| 575 |
+
change_type: Type of change ("insert", "update", "delete")
|
| 576 |
+
acl_data: The ACL rule data
|
| 577 |
+
"""
|
| 578 |
+
try:
|
| 579 |
+
notification_service = get_notification_service(self.db)
|
| 580 |
+
notifications_sent = notification_service.notify_acl_change(
|
| 581 |
+
calendar_id,
|
| 582 |
+
change_type,
|
| 583 |
+
acl_data
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
if notifications_sent > 0:
|
| 587 |
+
logger.debug(f"Sent {notifications_sent} notifications for ACL {change_type} in calendar {calendar_id}")
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
logger.error(f"Error sending ACL notification: {e}")
|
| 591 |
+
# Don't raise the exception as notification failure shouldn't break the main operation
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def get_acl_manager(db: Session, user_id: str) -> ACLManager:
|
| 595 |
+
return ACLManager(db, user_id)
|
server/database/managers/calendar_list_manager.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calendar List Manager - Database operations for calendar list management using SQLAlchemy
|
| 3 |
+
Manages user-specific calendar settings and access in a database-per-user architecture
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
import json
|
| 9 |
+
import base64
|
| 10 |
+
from typing import Dict, List, Optional, Tuple
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
from sqlalchemy.exc import IntegrityError
|
| 14 |
+
from database.models import Calendar, Event, User
|
| 15 |
+
from database.models.acl import ACLs, Scope
|
| 16 |
+
from database.models.watch_channel import WatchChannel
|
| 17 |
+
from database.session_utils import get_session, init_database
|
| 18 |
+
from schemas.calendar_list import WatchRequest
|
| 19 |
+
from datetime import timedelta
|
| 20 |
+
from schemas.settings import Channel
|
| 21 |
+
from fastapi import HTTPException, status
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Allowed values aligning with Google Calendar API v3
|
| 26 |
+
ALLOWED_REMINDER_METHODS = {"email", "popup"}
|
| 27 |
+
ALLOWED_NOTIFICATION_METHODS = {"email"}
|
| 28 |
+
ALLOWED_NOTIFICATION_TYPES = {
|
| 29 |
+
"eventCreation",
|
| 30 |
+
"eventChange",
|
| 31 |
+
"eventCancellation",
|
| 32 |
+
"eventResponse",
|
| 33 |
+
"agenda",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class CalendarListManager:
|
| 38 |
+
"""Manager for calendar list database operations using SQLAlchemy"""
|
| 39 |
+
|
| 40 |
+
def __init__(self, database_id: str):
|
| 41 |
+
self.database_id = database_id
|
| 42 |
+
# Initialize database on first use
|
| 43 |
+
init_database(database_id)
|
| 44 |
+
|
| 45 |
+
def list_calendar_entries(
|
| 46 |
+
self,
|
| 47 |
+
user_id: str,
|
| 48 |
+
max_results: Optional[int] = None,
|
| 49 |
+
min_access_role: Optional[str] = None,
|
| 50 |
+
show_deleted: Optional[bool] = False,
|
| 51 |
+
show_hidden: Optional[bool] = False,
|
| 52 |
+
page_token: Optional[str] = None,
|
| 53 |
+
sync_token: Optional[str] = None
|
| 54 |
+
) -> Tuple[List[Dict], Optional[str], Optional[str]]:
|
| 55 |
+
"""List all calendar entries in user's calendar list
|
| 56 |
+
|
| 57 |
+
Notes:
|
| 58 |
+
- If max_results is None, return all (subject to other filters)
|
| 59 |
+
- If max_results is 0, return an empty list (explicit zero results)
|
| 60 |
+
- If max_results > 0, limit the results accordingly
|
| 61 |
+
- min_access_role filters calendars based on user's minimum access role
|
| 62 |
+
- page_token specifies which result page to return
|
| 63 |
+
- sync_token enables incremental synchronization
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
- Tuple of (calendar_entries, next_page_token, next_sync_token)
|
| 67 |
+
"""
|
| 68 |
+
session = get_session(self.database_id)
|
| 69 |
+
try:
|
| 70 |
+
# Handle sync token for incremental synchronization
|
| 71 |
+
sync_timestamp = None
|
| 72 |
+
if sync_token:
|
| 73 |
+
try:
|
| 74 |
+
sync_timestamp = self._decode_sync_token(sync_token)
|
| 75 |
+
except (ValueError, TypeError) as e:
|
| 76 |
+
logger.error(f"Invalid sync token: {sync_token}, error: {e}")
|
| 77 |
+
raise ValueError(f"Invalid sync token. Please perform full synchronization.")
|
| 78 |
+
|
| 79 |
+
# Parse page token to get offset
|
| 80 |
+
offset = 0
|
| 81 |
+
if page_token:
|
| 82 |
+
try:
|
| 83 |
+
offset = self._decode_page_token(page_token)
|
| 84 |
+
except (ValueError, TypeError) as e:
|
| 85 |
+
logger.warning(f"Invalid page token: {page_token}, error: {e}")
|
| 86 |
+
offset = 0
|
| 87 |
+
|
| 88 |
+
query = session.query(Calendar).filter(Calendar.user_id == user_id)
|
| 89 |
+
|
| 90 |
+
# Apply sync token filtering for incremental sync
|
| 91 |
+
if sync_token and sync_timestamp:
|
| 92 |
+
# For incremental sync, return entries modified since sync timestamp
|
| 93 |
+
query = query.filter(Calendar.updated_at > sync_timestamp)
|
| 94 |
+
# Include deleted and hidden entries when using sync token
|
| 95 |
+
show_deleted = True
|
| 96 |
+
show_hidden = True
|
| 97 |
+
else:
|
| 98 |
+
# Filter deleted calendars unless specifically requested
|
| 99 |
+
if not show_deleted:
|
| 100 |
+
query = query.filter(Calendar.deleted.is_(False))
|
| 101 |
+
|
| 102 |
+
# Order consistently for pagination (by calendar_id for deterministic results)
|
| 103 |
+
query = query.order_by(Calendar.calendar_id)
|
| 104 |
+
|
| 105 |
+
# Apply offset for pagination
|
| 106 |
+
if offset > 0:
|
| 107 |
+
query = query.offset(offset)
|
| 108 |
+
|
| 109 |
+
# Apply limit if specified (support 0 to return empty set)
|
| 110 |
+
# For pagination, we fetch max_results + 1 to determine if there are more pages
|
| 111 |
+
fetch_limit = None
|
| 112 |
+
if max_results is not None:
|
| 113 |
+
# Negative values are treated as 0 (empty set)
|
| 114 |
+
limit_value = max(0, int(max_results))
|
| 115 |
+
if limit_value == 0:
|
| 116 |
+
return [], None, None
|
| 117 |
+
fetch_limit = limit_value + 1 # Fetch one extra to check for next page
|
| 118 |
+
query = query.limit(fetch_limit)
|
| 119 |
+
|
| 120 |
+
calendars = query.all()
|
| 121 |
+
|
| 122 |
+
# Define access role hierarchy for filtering
|
| 123 |
+
role_hierarchy = {
|
| 124 |
+
"freeBusyReader": 1,
|
| 125 |
+
"reader": 2,
|
| 126 |
+
"writer": 3,
|
| 127 |
+
"owner": 4
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
min_role_level = role_hierarchy.get(min_access_role, 0) if min_access_role else 0
|
| 131 |
+
|
| 132 |
+
result = []
|
| 133 |
+
for calendar in calendars:
|
| 134 |
+
# Check if user has sufficient access role if min_access_role is specified
|
| 135 |
+
if min_access_role:
|
| 136 |
+
user_access_role = self._get_user_access_role(user_id, calendar)
|
| 137 |
+
user_role_level = role_hierarchy.get(user_access_role, 0)
|
| 138 |
+
|
| 139 |
+
# Skip calendars where user doesn't meet minimum access role
|
| 140 |
+
if user_role_level < min_role_level:
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
entry = self._format_calendar_list_entry(calendar, show_hidden, user_id)
|
| 144 |
+
if entry:
|
| 145 |
+
result.append(entry)
|
| 146 |
+
|
| 147 |
+
# Determine if there are more pages
|
| 148 |
+
next_page_token = None
|
| 149 |
+
if max_results is not None and len(result) > max_results:
|
| 150 |
+
# Remove the extra item and generate next page token
|
| 151 |
+
result = result[:max_results]
|
| 152 |
+
next_offset = offset + max_results
|
| 153 |
+
next_page_token = self._encode_page_token(next_offset)
|
| 154 |
+
|
| 155 |
+
# Generate next sync token for incremental sync
|
| 156 |
+
next_sync_token = None
|
| 157 |
+
if len(result) > 0:
|
| 158 |
+
# Generate sync token based on current timestamp
|
| 159 |
+
next_sync_token = self._encode_sync_token(datetime.utcnow())
|
| 160 |
+
|
| 161 |
+
return result, next_page_token, next_sync_token
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Error listing calendar entries: {e}")
|
| 165 |
+
raise
|
| 166 |
+
finally:
|
| 167 |
+
session.close()
|
| 168 |
+
|
| 169 |
+
def get_calendar_entry(self, user_id: str, calendar_id: str) -> Optional[Dict]:
|
| 170 |
+
"""Get a specific calendar entry from user's calendar list"""
|
| 171 |
+
session = get_session(self.database_id)
|
| 172 |
+
try:
|
| 173 |
+
calendar = session.query(Calendar).filter(
|
| 174 |
+
Calendar.calendar_id == calendar_id,
|
| 175 |
+
Calendar.user_id == user_id
|
| 176 |
+
).first()
|
| 177 |
+
|
| 178 |
+
if not calendar or calendar.deleted:
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id)
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.error(f"Error getting calendar entry {calendar_id}: {e}")
|
| 185 |
+
raise
|
| 186 |
+
finally:
|
| 187 |
+
session.close()
|
| 188 |
+
|
| 189 |
+
def insert_calendar_entry(self, user_id: str, calendar_id: str, entry_data: Dict) -> Optional[Dict]:
|
| 190 |
+
"""Insert an existing calendar into user's calendar list"""
|
| 191 |
+
session = get_session(self.database_id)
|
| 192 |
+
try:
|
| 193 |
+
# Check if calendar exists
|
| 194 |
+
calendar = session.query(Calendar).filter(
|
| 195 |
+
Calendar.calendar_id == calendar_id,
|
| 196 |
+
Calendar.user_id == user_id
|
| 197 |
+
).first()
|
| 198 |
+
|
| 199 |
+
if not calendar:
|
| 200 |
+
# If calendar doesn't exist, we could create it or return None
|
| 201 |
+
# For this implementation, we'll return None (calendar must exist first)
|
| 202 |
+
return None
|
| 203 |
+
|
| 204 |
+
# If calendar is soft-deleted from list, restore it on insert (Google API semantics)
|
| 205 |
+
if calendar.deleted:
|
| 206 |
+
calendar.deleted = False
|
| 207 |
+
|
| 208 |
+
# Update calendar with list-specific settings
|
| 209 |
+
if "summaryOverride" in entry_data:
|
| 210 |
+
calendar.summary_override = entry_data["summaryOverride"]
|
| 211 |
+
if "colorId" in entry_data:
|
| 212 |
+
calendar.color_id = entry_data["colorId"]
|
| 213 |
+
if "backgroundColor" in entry_data:
|
| 214 |
+
calendar.background_color = entry_data["backgroundColor"]
|
| 215 |
+
if "foregroundColor" in entry_data:
|
| 216 |
+
calendar.foreground_color = entry_data["foregroundColor"]
|
| 217 |
+
if "hidden" in entry_data:
|
| 218 |
+
calendar.hidden = entry_data["hidden"]
|
| 219 |
+
if "selected" in entry_data:
|
| 220 |
+
calendar.selected = entry_data["selected"]
|
| 221 |
+
if "defaultReminders" in entry_data:
|
| 222 |
+
calendar.default_reminders = json.dumps(entry_data["defaultReminders"]) if entry_data["defaultReminders"] else None
|
| 223 |
+
if "notificationSettings" in entry_data:
|
| 224 |
+
calendar.notification_settings = json.dumps(entry_data["notificationSettings"]) if entry_data["notificationSettings"] else None
|
| 225 |
+
|
| 226 |
+
session.commit()
|
| 227 |
+
|
| 228 |
+
return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id)
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
session.rollback()
|
| 232 |
+
logger.error(f"Error inserting calendar entry {calendar_id}: {e}")
|
| 233 |
+
raise
|
| 234 |
+
finally:
|
| 235 |
+
session.close()
|
| 236 |
+
|
| 237 |
+
def update_calendar_entry(self, user_id: str, calendar_id: str, entry_data: Dict, is_patch: bool = True) -> Optional[Dict]:
|
| 238 |
+
"""Update a calendar entry in user's calendar list"""
|
| 239 |
+
session = get_session(self.database_id)
|
| 240 |
+
try:
|
| 241 |
+
calendar = session.query(Calendar).filter(
|
| 242 |
+
Calendar.calendar_id == calendar_id,
|
| 243 |
+
Calendar.user_id == user_id
|
| 244 |
+
).first()
|
| 245 |
+
|
| 246 |
+
if not calendar or calendar.deleted:
|
| 247 |
+
return None
|
| 248 |
+
|
| 249 |
+
# Update fields based on entry data
|
| 250 |
+
if "summaryOverride" in entry_data:
|
| 251 |
+
calendar.summary_override = entry_data["summaryOverride"]
|
| 252 |
+
elif not is_patch:
|
| 253 |
+
# For PUT requests, clear the field if not provided
|
| 254 |
+
calendar.summary_override = None
|
| 255 |
+
|
| 256 |
+
if "colorId" in entry_data:
|
| 257 |
+
calendar.color_id = entry_data["colorId"]
|
| 258 |
+
elif not is_patch:
|
| 259 |
+
calendar.color_id = None
|
| 260 |
+
|
| 261 |
+
if "backgroundColor" in entry_data:
|
| 262 |
+
calendar.background_color = entry_data["backgroundColor"]
|
| 263 |
+
elif not is_patch:
|
| 264 |
+
calendar.background_color = None
|
| 265 |
+
|
| 266 |
+
if "foregroundColor" in entry_data:
|
| 267 |
+
calendar.foreground_color = entry_data["foregroundColor"]
|
| 268 |
+
elif not is_patch:
|
| 269 |
+
calendar.foreground_color = None
|
| 270 |
+
|
| 271 |
+
if "hidden" in entry_data:
|
| 272 |
+
calendar.hidden = entry_data["hidden"]
|
| 273 |
+
elif not is_patch:
|
| 274 |
+
# For PUT (full update), set default for NOT NULL fields when not provided
|
| 275 |
+
calendar.hidden = False
|
| 276 |
+
|
| 277 |
+
if "selected" in entry_data:
|
| 278 |
+
calendar.selected = entry_data["selected"]
|
| 279 |
+
elif not is_patch:
|
| 280 |
+
# For PUT (full update), set default for NOT NULL fields when not provided
|
| 281 |
+
calendar.selected = True
|
| 282 |
+
|
| 283 |
+
if "defaultReminders" in entry_data:
|
| 284 |
+
calendar.default_reminders = json.dumps(entry_data["defaultReminders"]) if entry_data["defaultReminders"] else None
|
| 285 |
+
elif not is_patch:
|
| 286 |
+
calendar.default_reminders = None
|
| 287 |
+
|
| 288 |
+
if "notificationSettings" in entry_data:
|
| 289 |
+
calendar.notification_settings = json.dumps(entry_data["notificationSettings"]) if entry_data["notificationSettings"] else None
|
| 290 |
+
elif not is_patch:
|
| 291 |
+
calendar.notification_settings = None
|
| 292 |
+
|
| 293 |
+
if "conferenceProperties" in entry_data:
|
| 294 |
+
calendar.conference_properties = json.dumps(entry_data["conferenceProperties"]) if entry_data["conferenceProperties"] else None
|
| 295 |
+
|
| 296 |
+
session.commit()
|
| 297 |
+
|
| 298 |
+
return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id)
|
| 299 |
+
|
| 300 |
+
except Exception as e:
|
| 301 |
+
session.rollback()
|
| 302 |
+
logger.error(f"Error updating calendar entry {calendar_id}: {e}")
|
| 303 |
+
raise
|
| 304 |
+
finally:
|
| 305 |
+
session.close()
|
| 306 |
+
|
| 307 |
+
def delete_calendar_entry(self, user_id: str, calendar_id: str) -> bool:
|
| 308 |
+
"""Remove a calendar from user's calendar list"""
|
| 309 |
+
session = get_session(self.database_id)
|
| 310 |
+
try:
|
| 311 |
+
calendar = session.query(Calendar).filter(
|
| 312 |
+
Calendar.calendar_id == calendar_id,
|
| 313 |
+
Calendar.user_id == user_id
|
| 314 |
+
).first()
|
| 315 |
+
|
| 316 |
+
if not calendar or calendar.deleted:
|
| 317 |
+
return False
|
| 318 |
+
|
| 319 |
+
# For primary calendar, we can't remove it from the list
|
| 320 |
+
if calendar.is_primary:
|
| 321 |
+
raise ValueError("Cannot remove primary calendar from calendar list")
|
| 322 |
+
|
| 323 |
+
# Mark as deleted (soft delete for calendar list)
|
| 324 |
+
calendar.deleted = True
|
| 325 |
+
session.commit()
|
| 326 |
+
return True
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
session.rollback()
|
| 330 |
+
logger.error(f"Error deleting calendar entry {calendar_id}: {e}")
|
| 331 |
+
raise
|
| 332 |
+
finally:
|
| 333 |
+
session.close()
|
| 334 |
+
|
| 335 |
+
def _has_acl_role(self, user_id: str, calendar: Calendar, allowed_roles: List[str]) -> bool:
|
| 336 |
+
"""Check if user has required ACL permissions for calendar operations"""
|
| 337 |
+
session = get_session(self.database_id)
|
| 338 |
+
try:
|
| 339 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 340 |
+
if not user:
|
| 341 |
+
logger.warning(f"No user found with ID: {user_id}")
|
| 342 |
+
return False
|
| 343 |
+
|
| 344 |
+
acls = (
|
| 345 |
+
session.query(ACLs)
|
| 346 |
+
.join(Scope, ACLs.scope_id == Scope.id)
|
| 347 |
+
.filter(
|
| 348 |
+
ACLs.calendar_id == calendar.calendar_id,
|
| 349 |
+
Scope.type == "user",
|
| 350 |
+
Scope.value == user.email
|
| 351 |
+
)
|
| 352 |
+
.all()
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
if not acls:
|
| 356 |
+
logger.warning(f"No ACL found for user {user.email} on calendar {calendar.calendar_id}")
|
| 357 |
+
return False
|
| 358 |
+
|
| 359 |
+
for acl in acls:
|
| 360 |
+
if acl.role.value in allowed_roles:
|
| 361 |
+
return True
|
| 362 |
+
|
| 363 |
+
logger.warning(f"User {user.email} has ACLs but lacks required roles: {allowed_roles}")
|
| 364 |
+
return False
|
| 365 |
+
finally:
|
| 366 |
+
session.close()
|
| 367 |
+
|
| 368 |
+
def check_calendar_acl_permissions(self, user_id: str, calendar_id: str, allowed_roles: List[str]) -> Calendar:
|
| 369 |
+
"""Check if user has required ACL permissions for calendar operations and return calendar"""
|
| 370 |
+
session = get_session(self.database_id)
|
| 371 |
+
try:
|
| 372 |
+
calendar = session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first()
|
| 373 |
+
if not calendar:
|
| 374 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist")
|
| 375 |
+
|
| 376 |
+
if not self._has_acl_role(user_id, calendar, allowed_roles):
|
| 377 |
+
raise PermissionError(f"User '{user_id}' lacks required roles: {allowed_roles}")
|
| 378 |
+
|
| 379 |
+
return calendar
|
| 380 |
+
finally:
|
| 381 |
+
session.close()
|
| 382 |
+
|
| 383 |
+
def watch_calendar_list(self, watch_request: WatchRequest, user_id: str) -> Dict:
|
| 384 |
+
"""Set up watch notifications for calendar list changes"""
|
| 385 |
+
|
| 386 |
+
session = get_session(self.database_id)
|
| 387 |
+
try:
|
| 388 |
+
# Generate unique resource ID for settings watch
|
| 389 |
+
resource_id = f"calendarList-{user_id}-{uuid.uuid4().hex[:8]}"
|
| 390 |
+
resource_uri = "/calendars/me/calendarList"
|
| 391 |
+
|
| 392 |
+
# Calculate expiration time (max 24 hours from now if not specified)
|
| 393 |
+
now = datetime.utcnow()
|
| 394 |
+
expires_at = now + timedelta(hours=24)
|
| 395 |
+
|
| 396 |
+
if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first():
|
| 397 |
+
raise ValueError(f"Channel with Id {watch_request.id} already exists")
|
| 398 |
+
# Create watch channel record
|
| 399 |
+
watch_channel = WatchChannel(
|
| 400 |
+
id=watch_request.id,
|
| 401 |
+
resource_id=resource_id,
|
| 402 |
+
resource_uri=resource_uri,
|
| 403 |
+
resource_type="calendar_list",
|
| 404 |
+
calendar_id="",
|
| 405 |
+
user_id=user_id,
|
| 406 |
+
webhook_address=watch_request.address,
|
| 407 |
+
webhook_token=watch_request.token,
|
| 408 |
+
webhook_type=watch_request.type,
|
| 409 |
+
params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None,
|
| 410 |
+
created_at=now,
|
| 411 |
+
expires_at=expires_at,
|
| 412 |
+
is_active="true",
|
| 413 |
+
notification_count=0
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
# Save to database
|
| 417 |
+
session.add(watch_channel)
|
| 418 |
+
session.commit()
|
| 419 |
+
|
| 420 |
+
logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}")
|
| 421 |
+
|
| 422 |
+
# Return channel response
|
| 423 |
+
return {
|
| 424 |
+
"kind":"api#channel",
|
| 425 |
+
"id":watch_channel.id,
|
| 426 |
+
"resourceId":resource_id,
|
| 427 |
+
"resourceUri":resource_uri,
|
| 428 |
+
"token":watch_channel.webhook_token,
|
| 429 |
+
"expiration": expires_at.isoformat() + "Z" if expires_at else None
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
except Exception as e:
|
| 433 |
+
session.rollback()
|
| 434 |
+
logger.error(f"Error creating settings watch channel: {e}")
|
| 435 |
+
raise
|
| 436 |
+
finally:
|
| 437 |
+
session.close()
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _get_user_access_role(self, user_id: str, calendar: Calendar) -> str:
|
| 441 |
+
"""Get the user's actual access role for a calendar based on ACL entries"""
|
| 442 |
+
session = get_session(self.database_id)
|
| 443 |
+
try:
|
| 444 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 445 |
+
if not user:
|
| 446 |
+
return "none"
|
| 447 |
+
|
| 448 |
+
acls = (
|
| 449 |
+
session.query(ACLs)
|
| 450 |
+
.join(Scope, ACLs.scope_id == Scope.id)
|
| 451 |
+
.filter(
|
| 452 |
+
ACLs.calendar_id == calendar.calendar_id,
|
| 453 |
+
Scope.type == "user",
|
| 454 |
+
Scope.value == user.email
|
| 455 |
+
)
|
| 456 |
+
.all()
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
if not acls:
|
| 460 |
+
return "none"
|
| 461 |
+
|
| 462 |
+
# Return the highest permission level found
|
| 463 |
+
role_hierarchy = {"none": 0, "freeBusyReader": 1, "reader": 2, "writer": 3, "owner": 4}
|
| 464 |
+
highest_role = "none"
|
| 465 |
+
highest_weight = 0
|
| 466 |
+
|
| 467 |
+
for acl in acls:
|
| 468 |
+
role_weight = role_hierarchy.get(acl.role.value, 0)
|
| 469 |
+
if role_weight > highest_weight:
|
| 470 |
+
highest_weight = role_weight
|
| 471 |
+
highest_role = acl.role.value
|
| 472 |
+
|
| 473 |
+
return highest_role
|
| 474 |
+
finally:
|
| 475 |
+
session.close()
|
| 476 |
+
|
| 477 |
+
def _format_calendar_list_entry(self, calendar: Calendar, show_hidden: bool = False, user_id: str = None) -> Optional[Dict]:
|
| 478 |
+
"""Format calendar model for calendar list API response"""
|
| 479 |
+
# Skip hidden calendars unless explicitly requested
|
| 480 |
+
if calendar.hidden and not show_hidden:
|
| 481 |
+
return None
|
| 482 |
+
|
| 483 |
+
# Determine access role from actual ACL data
|
| 484 |
+
access_role = "owner" if calendar.is_primary else "writer" # Default fallback
|
| 485 |
+
if user_id:
|
| 486 |
+
access_role = self._get_user_access_role(user_id, calendar)
|
| 487 |
+
|
| 488 |
+
formatted = {
|
| 489 |
+
"kind": "calendar#calendarListEntry",
|
| 490 |
+
"etag": f"etag-list-{calendar.calendar_id}-{calendar.updated_at.isoformat() if calendar.updated_at else ''}",
|
| 491 |
+
"id": calendar.calendar_id,
|
| 492 |
+
"summary": calendar.summary_override or calendar.summary,
|
| 493 |
+
"description": calendar.description,
|
| 494 |
+
"location": calendar.location,
|
| 495 |
+
"timeZone": calendar.time_zone,
|
| 496 |
+
"accessRole": access_role,
|
| 497 |
+
"primary": calendar.is_primary,
|
| 498 |
+
"hidden": calendar.hidden or False,
|
| 499 |
+
"selected": calendar.selected if calendar.selected is not None else True,
|
| 500 |
+
"deleted": calendar.deleted or False
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
# Add optional fields if present
|
| 504 |
+
if calendar.summary_override:
|
| 505 |
+
formatted["summaryOverride"] = calendar.summary_override
|
| 506 |
+
|
| 507 |
+
if calendar.color_id:
|
| 508 |
+
formatted["colorId"] = calendar.color_id
|
| 509 |
+
|
| 510 |
+
if calendar.background_color:
|
| 511 |
+
formatted["backgroundColor"] = calendar.background_color
|
| 512 |
+
|
| 513 |
+
if calendar.foreground_color:
|
| 514 |
+
formatted["foregroundColor"] = calendar.foreground_color
|
| 515 |
+
|
| 516 |
+
if calendar.default_reminders:
|
| 517 |
+
try:
|
| 518 |
+
raw_items = json.loads(calendar.default_reminders)
|
| 519 |
+
sanitized: List[Dict] = []
|
| 520 |
+
if isinstance(raw_items, list):
|
| 521 |
+
for item in raw_items:
|
| 522 |
+
if not isinstance(item, dict):
|
| 523 |
+
continue
|
| 524 |
+
method = item.get("method")
|
| 525 |
+
minutes = item.get("minutes")
|
| 526 |
+
if (
|
| 527 |
+
method in ALLOWED_REMINDER_METHODS
|
| 528 |
+
and isinstance(minutes, int)
|
| 529 |
+
and minutes >= 0
|
| 530 |
+
):
|
| 531 |
+
sanitized.append({"method": method, "minutes": minutes})
|
| 532 |
+
if sanitized:
|
| 533 |
+
formatted["defaultReminders"] = sanitized
|
| 534 |
+
except Exception:
|
| 535 |
+
# Ignore malformed stored data
|
| 536 |
+
pass
|
| 537 |
+
|
| 538 |
+
if calendar.notification_settings:
|
| 539 |
+
try:
|
| 540 |
+
raw = json.loads(calendar.notification_settings)
|
| 541 |
+
if isinstance(raw, dict):
|
| 542 |
+
notifs = raw.get("notifications")
|
| 543 |
+
sanitized_notifs: List[Dict] = []
|
| 544 |
+
if isinstance(notifs, list):
|
| 545 |
+
for n in notifs:
|
| 546 |
+
if not isinstance(n, dict):
|
| 547 |
+
continue
|
| 548 |
+
m = n.get("method")
|
| 549 |
+
t = n.get("type")
|
| 550 |
+
if m in ALLOWED_NOTIFICATION_METHODS and t in ALLOWED_NOTIFICATION_TYPES:
|
| 551 |
+
sanitized_notifs.append({"method": m, "type": t})
|
| 552 |
+
if sanitized_notifs:
|
| 553 |
+
formatted["notificationSettings"] = {"notifications": sanitized_notifs}
|
| 554 |
+
except Exception:
|
| 555 |
+
# Ignore malformed stored data
|
| 556 |
+
pass
|
| 557 |
+
|
| 558 |
+
# Add conference properties
|
| 559 |
+
if calendar.conference_properties:
|
| 560 |
+
try:
|
| 561 |
+
# conference_properties is stored as JSON string, parse it
|
| 562 |
+
conf_props = json.loads(calendar.conference_properties)
|
| 563 |
+
formatted["conferenceProperties"] = conf_props
|
| 564 |
+
except Exception:
|
| 565 |
+
# If parsing fails, provide default
|
| 566 |
+
formatted["conferenceProperties"] = {
|
| 567 |
+
"allowedConferenceSolutionTypes": []
|
| 568 |
+
}
|
| 569 |
+
else:
|
| 570 |
+
formatted["conferenceProperties"] = {
|
| 571 |
+
"allowedConferenceSolutionTypes": []
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
return formatted
|
| 575 |
+
|
| 576 |
+
def _encode_page_token(self, offset: int) -> str:
|
| 577 |
+
"""Encode offset as a page token"""
|
| 578 |
+
try:
|
| 579 |
+
token_data = str(offset)
|
| 580 |
+
return base64.b64encode(token_data.encode('utf-8')).decode('utf-8')
|
| 581 |
+
except Exception as e:
|
| 582 |
+
logger.error(f"Error encoding page token: {e}")
|
| 583 |
+
return ""
|
| 584 |
+
|
| 585 |
+
def _decode_page_token(self, token: str) -> int:
|
| 586 |
+
"""Decode page token to get offset"""
|
| 587 |
+
try:
|
| 588 |
+
# Handle legacy case where raw numbers might be passed
|
| 589 |
+
if token.isdigit():
|
| 590 |
+
logger.warning(f"Raw numeric page token received: {token}. This should be a base64-encoded token.")
|
| 591 |
+
return int(token)
|
| 592 |
+
|
| 593 |
+
# Add padding if needed for base64 decoding
|
| 594 |
+
missing_padding = len(token) % 4
|
| 595 |
+
if missing_padding:
|
| 596 |
+
token += '=' * (4 - missing_padding)
|
| 597 |
+
|
| 598 |
+
decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8')
|
| 599 |
+
return int(decoded)
|
| 600 |
+
except Exception as e:
|
| 601 |
+
logger.error(f"Error decoding page token: {e}")
|
| 602 |
+
raise ValueError(f"Invalid page token: {token}. Page tokens should only be generated by the API.")
|
| 603 |
+
|
| 604 |
+
def _encode_sync_token(self, timestamp: datetime) -> str:
|
| 605 |
+
"""Encode timestamp as a sync token"""
|
| 606 |
+
try:
|
| 607 |
+
# Use ISO format timestamp for sync token
|
| 608 |
+
token_data = timestamp.isoformat()
|
| 609 |
+
return base64.b64encode(token_data.encode('utf-8')).decode('utf-8')
|
| 610 |
+
except Exception as e:
|
| 611 |
+
logger.error(f"Error encoding sync token: {e}")
|
| 612 |
+
return ""
|
| 613 |
+
|
| 614 |
+
def _decode_sync_token(self, token: str) -> datetime:
|
| 615 |
+
"""Decode sync token to get timestamp"""
|
| 616 |
+
try:
|
| 617 |
+
# Add padding if needed for base64 decoding
|
| 618 |
+
missing_padding = len(token) % 4
|
| 619 |
+
if missing_padding:
|
| 620 |
+
token += '=' * (4 - missing_padding)
|
| 621 |
+
|
| 622 |
+
decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8')
|
| 623 |
+
return datetime.fromisoformat(decoded)
|
| 624 |
+
except Exception as e:
|
| 625 |
+
logger.error(f"Error decoding sync token: {e}")
|
| 626 |
+
raise ValueError(f"Invalid sync token. Token may have expired or is malformed.")
|
server/database/managers/calendar_manager.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calendar Manager - Database operations for calendar management using SQLAlchemy
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from database.models import Calendar, User
|
| 7 |
+
from database.models.acl import ACLs, Scope
|
| 8 |
+
from database.session_utils import get_session, init_database
|
| 9 |
+
import json, uuid, logging
|
| 10 |
+
from typing import Dict, List, Optional
|
| 11 |
+
from fastapi import HTTPException, status
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# Allowed conference solution types per Google Calendar API v3 spec
|
| 16 |
+
ALLOWED_CONFERENCE_SOLUTION_TYPES = {
|
| 17 |
+
"eventHangout", "eventNamedHangout", "hangoutsMeet"
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class CalendarManager:
|
| 21 |
+
"""Manager for calendar database operations using SQLAlchemy"""
|
| 22 |
+
def __init__(self, database_id: str):
|
| 23 |
+
# Initialize database on first use
|
| 24 |
+
self.database_id = database_id
|
| 25 |
+
self.session = self.get_session()
|
| 26 |
+
|
| 27 |
+
def get_session(self):
|
| 28 |
+
from database.session_manager import CalendarSessionManager
|
| 29 |
+
return CalendarSessionManager().get_session(self.database_id)
|
| 30 |
+
|
| 31 |
+
def _has_acl_role(self, user_id: str, calendar: Calendar, allowed_roles: List[str]) -> bool:
|
| 32 |
+
user = self.session.query(User).filter(User.user_id == user_id).first()
|
| 33 |
+
if not user:
|
| 34 |
+
logger.warning(f"No user found with ID: {user_id}")
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
acls = (
|
| 38 |
+
self.session.query(ACLs)
|
| 39 |
+
.join(Scope, ACLs.scope_id == Scope.id)
|
| 40 |
+
.filter(
|
| 41 |
+
ACLs.calendar_id == calendar.calendar_id,
|
| 42 |
+
Scope.type == "user",
|
| 43 |
+
Scope.value == user.email
|
| 44 |
+
)
|
| 45 |
+
.all()
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
if not acls:
|
| 49 |
+
logger.warning(f"No ACL found for user {user.email} on calendar {calendar.calendar_id}")
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
for acl in acls:
|
| 53 |
+
if acl.role.value in allowed_roles:
|
| 54 |
+
return True
|
| 55 |
+
|
| 56 |
+
logger.warning(f"User {user.email} has ACLs but lacks required roles: {allowed_roles}")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
def create_calendar(self, user_id: str, calendar_data: dict):
|
| 60 |
+
"""Create a new calendar"""
|
| 61 |
+
conf_types = calendar_data.get("conferenceProperties", {}).get("allowedConferenceSolutionTypes")
|
| 62 |
+
if conf_types:
|
| 63 |
+
for ctype in conf_types:
|
| 64 |
+
if ctype not in ALLOWED_CONFERENCE_SOLUTION_TYPES:
|
| 65 |
+
raise ValueError(f"Invalid conference solution type: {ctype}")
|
| 66 |
+
# This endpoint strictly creates secondary calendars per Google Calendar API semantics.
|
| 67 |
+
# Do not auto-promote to primary here.
|
| 68 |
+
mapped_data = {
|
| 69 |
+
"calendar_id": str(uuid.uuid4()), # Generate unique calendar ID
|
| 70 |
+
"user_id": user_id,
|
| 71 |
+
"summary": calendar_data.get("summary"),
|
| 72 |
+
"description": calendar_data.get("description"),
|
| 73 |
+
"location": calendar_data.get("location"),
|
| 74 |
+
"time_zone": calendar_data.get("timeZone", "UTC"),
|
| 75 |
+
"conference_properties": json.dumps(calendar_data.get("conferenceProperties", {})),
|
| 76 |
+
"is_primary": False
|
| 77 |
+
}
|
| 78 |
+
# Create Calendar model instance
|
| 79 |
+
new_calendar = Calendar(**mapped_data)
|
| 80 |
+
self.session.add(new_calendar)
|
| 81 |
+
self.session.commit()
|
| 82 |
+
self.session.refresh(new_calendar)
|
| 83 |
+
|
| 84 |
+
# Automatically create ACL entry for the calendar owner
|
| 85 |
+
self._create_owner_acl(user_id, new_calendar.calendar_id)
|
| 86 |
+
|
| 87 |
+
# Return the created calendar
|
| 88 |
+
return new_calendar
|
| 89 |
+
|
| 90 |
+
def _create_owner_acl(self, user_id: str, calendar_id: str):
|
| 91 |
+
"""Create ACL entries with owner and writer roles for the calendar creator"""
|
| 92 |
+
from database.models.acl import AclRole, ScopeType
|
| 93 |
+
|
| 94 |
+
# Get the user to access their email
|
| 95 |
+
user = self.session.query(User).filter(User.user_id == user_id).first()
|
| 96 |
+
if not user:
|
| 97 |
+
raise ValueError(f"User with ID {user_id} not found")
|
| 98 |
+
|
| 99 |
+
# Create or get the scope for this user
|
| 100 |
+
scope = self.session.query(Scope).filter(
|
| 101 |
+
Scope.type == ScopeType.user,
|
| 102 |
+
Scope.value == user.email
|
| 103 |
+
).first()
|
| 104 |
+
|
| 105 |
+
if not scope:
|
| 106 |
+
scope = Scope(
|
| 107 |
+
id=str(uuid.uuid4()),
|
| 108 |
+
type=ScopeType.user,
|
| 109 |
+
value=user.email
|
| 110 |
+
)
|
| 111 |
+
self.session.add(scope)
|
| 112 |
+
self.session.flush() # Flush to get the scope ID
|
| 113 |
+
|
| 114 |
+
# Create the owner ACL entry
|
| 115 |
+
owner_acl = ACLs(
|
| 116 |
+
id=str(uuid.uuid4()),
|
| 117 |
+
calendar_id=calendar_id,
|
| 118 |
+
user_id=user_id,
|
| 119 |
+
scope_id=scope.id,
|
| 120 |
+
role=AclRole.owner,
|
| 121 |
+
etag=f'"{uuid.uuid4()}"'
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Create the writer ACL entry
|
| 125 |
+
writer_acl = ACLs(
|
| 126 |
+
id=str(uuid.uuid4()),
|
| 127 |
+
calendar_id=calendar_id,
|
| 128 |
+
user_id=user_id,
|
| 129 |
+
scope_id=scope.id,
|
| 130 |
+
role=AclRole.writer,
|
| 131 |
+
etag=f'"{uuid.uuid4()}"'
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
self.session.add(owner_acl)
|
| 135 |
+
self.session.add(writer_acl)
|
| 136 |
+
self.session.commit()
|
| 137 |
+
|
| 138 |
+
logger.info(f"Created owner and writer ACL entries for user {user.email} on calendar {calendar_id}")
|
| 139 |
+
|
| 140 |
+
def get_calendar_by_id(self, user_id: str, calendar_id: str, allowed_roles: List[str] = None):
|
| 141 |
+
"""Get a calendar by ID for a specific user"""
|
| 142 |
+
calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first()
|
| 143 |
+
if not calendar:
|
| 144 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist")
|
| 145 |
+
calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id, Calendar.user_id == user_id).first()
|
| 146 |
+
if not calendar:
|
| 147 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist for user {user_id}")
|
| 148 |
+
if allowed_roles and not self._has_acl_role(user_id, calendar, allowed_roles):
|
| 149 |
+
raise PermissionError(f"User '{user_id}' lacks required roles: {allowed_roles}")
|
| 150 |
+
return calendar
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def update_calendar(self, user_id: str, calendar_id: str, update_data: dict):
|
| 154 |
+
"""Update a calendar (cannot modify primary status)"""
|
| 155 |
+
calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first()
|
| 156 |
+
if not calendar:
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
# Prevent changing primary status via update
|
| 160 |
+
if "is_primary" in update_data:
|
| 161 |
+
raise ValueError("Cannot modify primary calendar status")
|
| 162 |
+
|
| 163 |
+
# Update fields
|
| 164 |
+
if "summary" in update_data:
|
| 165 |
+
calendar.summary = update_data["summary"]
|
| 166 |
+
|
| 167 |
+
if "description" in update_data:
|
| 168 |
+
calendar.description = update_data["description"]
|
| 169 |
+
|
| 170 |
+
if "location" in update_data:
|
| 171 |
+
calendar.location = update_data["location"]
|
| 172 |
+
|
| 173 |
+
if "timeZone" in update_data and update_data["timeZone"] is not None:
|
| 174 |
+
calendar.time_zone = update_data["timeZone"]
|
| 175 |
+
|
| 176 |
+
if "conferenceProperties" in update_data:
|
| 177 |
+
self._validate_conference_properties(update_data.get("conferenceProperties"))
|
| 178 |
+
calendar.conference_properties = (
|
| 179 |
+
json.dumps(update_data["conferenceProperties"])
|
| 180 |
+
if update_data["conferenceProperties"] else None
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
calendar.updated_at = datetime.now(timezone.utc)
|
| 184 |
+
|
| 185 |
+
self.session.commit()
|
| 186 |
+
self.session.refresh(calendar)
|
| 187 |
+
return calendar
|
| 188 |
+
|
| 189 |
+
def delete_calendar(self, user_id: str, calendar_id: str):
|
| 190 |
+
"""Delete a calendar (cannot delete primary calendar)"""
|
| 191 |
+
calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first()
|
| 192 |
+
if not calendar:
|
| 193 |
+
return False
|
| 194 |
+
if calendar.is_primary:
|
| 195 |
+
raise ValueError("Cannot delete primary calendar.")
|
| 196 |
+
if not self._has_acl_role(user_id, calendar, ["owner"]):
|
| 197 |
+
raise PermissionError("Only owners can delete the calendar")
|
| 198 |
+
self.session.delete(calendar)
|
| 199 |
+
self.session.commit()
|
| 200 |
+
return True
|
| 201 |
+
|
| 202 |
+
def clear_calendar(self, user_id: str, calendar_id: str):
|
| 203 |
+
"""Clear all events from a calendar and return the number of events deleted"""
|
| 204 |
+
calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first()
|
| 205 |
+
if not calendar:
|
| 206 |
+
return 0
|
| 207 |
+
if not self._has_acl_role(user_id, calendar, ["owner", "writer"]):
|
| 208 |
+
raise PermissionError("User does not have permission to clear this calendar")
|
| 209 |
+
if not calendar.is_primary:
|
| 210 |
+
raise ValueError("Can only clear primary calendars")
|
| 211 |
+
count = len(calendar.events)
|
| 212 |
+
for event in calendar.events:
|
| 213 |
+
self.session.delete(event)
|
| 214 |
+
self.session.commit()
|
| 215 |
+
return count
|
| 216 |
+
|
| 217 |
+
def list_calendars(self, user_id: str):
|
| 218 |
+
"""List all calendars for a user"""
|
| 219 |
+
user = self.session.query(User).filter(User.user_id == user_id).first()
|
| 220 |
+
if not user:
|
| 221 |
+
return []
|
| 222 |
+
owned = self.session.query(Calendar).filter(Calendar.user_id == user_id)
|
| 223 |
+
shared = (
|
| 224 |
+
self.session.query(Calendar)
|
| 225 |
+
.join(ACLs, Calendar.calendar_id == ACLs.calendar_id)
|
| 226 |
+
.join(Scope, ACLs.scope_id == Scope.id)
|
| 227 |
+
.filter(Scope.type == "user", Scope.value == user.email)
|
| 228 |
+
)
|
| 229 |
+
return owned.union(shared).all()
|
| 230 |
+
|
| 231 |
+
def get_primary_calendar(self, user_id: str):
|
| 232 |
+
return self.session.query(Calendar).filter_by(user_id=user_id, is_primary=True).first()
|
| 233 |
+
|
| 234 |
+
def _format_calendar_response(self, calendar: Calendar) -> Dict:
|
| 235 |
+
"""Format calendar model for API response"""
|
| 236 |
+
formatted = {
|
| 237 |
+
"kind": "calendar#calendar",
|
| 238 |
+
"etag": f"etag-{calendar.calendar_id}-{calendar.updated_at.isoformat() if calendar.updated_at else ''}",
|
| 239 |
+
"id": calendar.calendar_id,
|
| 240 |
+
"summary": calendar.summary,
|
| 241 |
+
"timeZone": calendar.time_zone
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
# Add optional fields if present
|
| 245 |
+
if calendar.description:
|
| 246 |
+
formatted["description"] = calendar.description
|
| 247 |
+
|
| 248 |
+
if calendar.location:
|
| 249 |
+
formatted["location"] = calendar.location
|
| 250 |
+
if calendar.conference_properties:
|
| 251 |
+
try:
|
| 252 |
+
formatted["conferenceProperties"] = json.loads(calendar.conference_properties)
|
| 253 |
+
except:
|
| 254 |
+
pass
|
| 255 |
+
|
| 256 |
+
return formatted
|
| 257 |
+
|
| 258 |
+
def ensure_primary_calendar_constraint(self, user_id: str) -> bool:
|
| 259 |
+
"""Ensure user has exactly one primary calendar"""
|
| 260 |
+
session = get_session(self.database_id)
|
| 261 |
+
try:
|
| 262 |
+
# Get all primary calendars for user
|
| 263 |
+
primary_calendars = session.query(Calendar).filter(
|
| 264 |
+
Calendar.user_id == user_id,
|
| 265 |
+
Calendar.is_primary == True
|
| 266 |
+
).all()
|
| 267 |
+
|
| 268 |
+
# Case 1: No primary calendar - make first calendar primary
|
| 269 |
+
if not primary_calendars:
|
| 270 |
+
first_calendar = session.query(Calendar).filter(
|
| 271 |
+
Calendar.user_id == user_id
|
| 272 |
+
).order_by(Calendar.created_at.asc()).first()
|
| 273 |
+
|
| 274 |
+
if first_calendar:
|
| 275 |
+
first_calendar.is_primary = True
|
| 276 |
+
session.commit()
|
| 277 |
+
logger.info(f"Made calendar {first_calendar.calendar_id} primary for user {user_id}")
|
| 278 |
+
return True
|
| 279 |
+
else:
|
| 280 |
+
# User has no calendars - this is valid
|
| 281 |
+
return True
|
| 282 |
+
|
| 283 |
+
# Case 2: Multiple primary calendars - keep oldest, make others secondary
|
| 284 |
+
elif len(primary_calendars) > 1:
|
| 285 |
+
# Sort by creation date, keep the oldest as primary
|
| 286 |
+
primary_calendars.sort(key=lambda c: c.created_at)
|
| 287 |
+
primary_calendar = primary_calendars[0]
|
| 288 |
+
|
| 289 |
+
# Make all others secondary
|
| 290 |
+
for calendar in primary_calendars[1:]:
|
| 291 |
+
calendar.is_primary = False
|
| 292 |
+
logger.warning(f"Made calendar {calendar.calendar_id} secondary for user {user_id}")
|
| 293 |
+
|
| 294 |
+
session.commit()
|
| 295 |
+
logger.info(f"Ensured single primary calendar {primary_calendar.calendar_id} for user {user_id}")
|
| 296 |
+
return True
|
| 297 |
+
|
| 298 |
+
# Case 3: Exactly one primary calendar - already correct
|
| 299 |
+
return True
|
| 300 |
+
|
| 301 |
+
except Exception as e:
|
| 302 |
+
session.rollback()
|
| 303 |
+
logger.error(f"Error ensuring primary calendar constraint for user {user_id}: {e}")
|
| 304 |
+
raise
|
| 305 |
+
finally:
|
| 306 |
+
session.close()
|
| 307 |
+
|
| 308 |
+
def _validate_conference_properties(self, conference_properties: Optional[Dict]) -> None:
|
| 309 |
+
"""Validate conferenceProperties.allowedConferenceSolutionTypes values.
|
| 310 |
+
|
| 311 |
+
Raises ValueError if any provided value is not allowed per API spec.
|
| 312 |
+
"""
|
| 313 |
+
if not conference_properties:
|
| 314 |
+
return
|
| 315 |
+
allowed_list = conference_properties.get("allowedConferenceSolutionTypes")
|
| 316 |
+
if allowed_list is None:
|
| 317 |
+
return
|
| 318 |
+
if not isinstance(allowed_list, list):
|
| 319 |
+
raise ValueError("conferenceProperties.allowedConferenceSolutionTypes must be a list if provided")
|
| 320 |
+
invalid = [item for item in allowed_list if item not in ALLOWED_CONFERENCE_SOLUTION_TYPES and item not in [None, ""]]
|
| 321 |
+
if invalid:
|
| 322 |
+
allowed_sorted = sorted(ALLOWED_CONFERENCE_SOLUTION_TYPES)
|
| 323 |
+
raise ValueError(
|
| 324 |
+
"Invalid values for conferenceProperties.allowedConferenceSolutionTypes: "
|
| 325 |
+
f"{invalid}. Allowed values are: {allowed_sorted}"
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
def validate_primary_calendar_integrity(self, user_id: str) -> Dict[str, any]:
|
| 329 |
+
"""Validate primary calendar integrity for a user"""
|
| 330 |
+
session = get_session(self.database_id)
|
| 331 |
+
try:
|
| 332 |
+
primary_calendars = session.query(Calendar).filter(
|
| 333 |
+
Calendar.user_id == user_id,
|
| 334 |
+
Calendar.is_primary == True
|
| 335 |
+
).all()
|
| 336 |
+
|
| 337 |
+
total_calendars = session.query(Calendar).filter(
|
| 338 |
+
Calendar.user_id == user_id
|
| 339 |
+
).count()
|
| 340 |
+
|
| 341 |
+
result = {
|
| 342 |
+
"user_id": user_id,
|
| 343 |
+
"total_calendars": total_calendars,
|
| 344 |
+
"primary_calendars_count": len(primary_calendars),
|
| 345 |
+
"is_valid": len(primary_calendars) == 1 if total_calendars > 0 else True,
|
| 346 |
+
"primary_calendar_ids": [c.calendar_id for c in primary_calendars]
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
return result
|
| 350 |
+
|
| 351 |
+
except Exception as e:
|
| 352 |
+
logger.error(f"Error validating primary calendar integrity for user {user_id}: {e}")
|
| 353 |
+
raise
|
| 354 |
+
finally:
|
| 355 |
+
session.close()
|
| 356 |
+
|
| 357 |
+
def _ensure_user_has_primary_calendar(self, user_id: str, session: Session = None) -> None:
|
| 358 |
+
"""Private method to ensure user has exactly one primary calendar
|
| 359 |
+
|
| 360 |
+
This enforces the business rule: If user has calendars, exactly one must be primary
|
| 361 |
+
"""
|
| 362 |
+
close_session = session is None
|
| 363 |
+
if session is None:
|
| 364 |
+
session = get_session(self.database_id)
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
# Get all calendars for user
|
| 368 |
+
all_calendars = session.query(Calendar).filter(
|
| 369 |
+
Calendar.user_id == user_id
|
| 370 |
+
).order_by(Calendar.created_at.asc()).all()
|
| 371 |
+
|
| 372 |
+
if not all_calendars:
|
| 373 |
+
# User has no calendars - nothing to enforce
|
| 374 |
+
return
|
| 375 |
+
|
| 376 |
+
# Get primary calendars
|
| 377 |
+
primary_calendars = [c for c in all_calendars if c.is_primary]
|
| 378 |
+
|
| 379 |
+
if len(primary_calendars) == 0:
|
| 380 |
+
# No primary calendar - make the oldest one primary
|
| 381 |
+
oldest_calendar = all_calendars[0]
|
| 382 |
+
oldest_calendar.is_primary = True
|
| 383 |
+
logger.info(f"Made calendar {oldest_calendar.calendar_id} primary for user {user_id} (no primary existed)")
|
| 384 |
+
|
| 385 |
+
elif len(primary_calendars) > 1:
|
| 386 |
+
# Multiple primary calendars - keep oldest, make others secondary
|
| 387 |
+
primary_calendars.sort(key=lambda c: c.created_at)
|
| 388 |
+
primary_calendar = primary_calendars[0]
|
| 389 |
+
|
| 390 |
+
for calendar in primary_calendars[1:]:
|
| 391 |
+
calendar.is_primary = False
|
| 392 |
+
logger.warning(f"Made calendar {calendar.calendar_id} secondary for user {user_id} (multiple primaries existed)")
|
| 393 |
+
|
| 394 |
+
logger.info(f"Kept calendar {primary_calendar.calendar_id} as primary for user {user_id}")
|
| 395 |
+
|
| 396 |
+
# If exactly one primary exists, no action needed
|
| 397 |
+
|
| 398 |
+
except Exception as e:
|
| 399 |
+
logger.error(f"Error ensuring primary calendar for user {user_id}: {e}")
|
| 400 |
+
raise
|
| 401 |
+
finally:
|
| 402 |
+
if close_session:
|
| 403 |
+
session.close()
|
| 404 |
+
|
| 405 |
+
def enforce_primary_calendar_constraint_for_all_users(self) -> Dict[str, int]:
|
| 406 |
+
"""Enforce primary calendar constraint for all users in the database
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
Dict with statistics about fixes applied
|
| 410 |
+
"""
|
| 411 |
+
session = get_session(self.database_id)
|
| 412 |
+
try:
|
| 413 |
+
# Get all unique user IDs
|
| 414 |
+
user_ids = session.query(Calendar.user_id).distinct().all()
|
| 415 |
+
user_ids = [uid[0] for uid in user_ids]
|
| 416 |
+
|
| 417 |
+
stats = {
|
| 418 |
+
"users_processed": 0,
|
| 419 |
+
"users_fixed": 0,
|
| 420 |
+
"calendars_made_primary": 0,
|
| 421 |
+
"calendars_made_secondary": 0
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
for user_id in user_ids:
|
| 425 |
+
stats["users_processed"] += 1
|
| 426 |
+
|
| 427 |
+
# Check if user needs fixing
|
| 428 |
+
validation_result = self.validate_primary_calendar_integrity(user_id)
|
| 429 |
+
|
| 430 |
+
if not validation_result["is_valid"]:
|
| 431 |
+
stats["users_fixed"] += 1
|
| 432 |
+
logger.info(f"Fixing primary calendar constraint for user {user_id}")
|
| 433 |
+
|
| 434 |
+
# Fix the constraint
|
| 435 |
+
self._ensure_user_has_primary_calendar(user_id, session)
|
| 436 |
+
|
| 437 |
+
session.commit()
|
| 438 |
+
return stats
|
| 439 |
+
|
| 440 |
+
except Exception as e:
|
| 441 |
+
session.rollback()
|
| 442 |
+
logger.error(f"Error enforcing primary calendar constraints: {e}")
|
| 443 |
+
raise
|
| 444 |
+
finally:
|
| 445 |
+
session.close()
|
server/database/managers/color_manager.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Color database manager with Google Calendar API v3 compatible operations
|
| 3 |
+
Handles color definitions for calendars and events
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Optional, Dict, Any
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from sqlalchemy import and_, func
|
| 10 |
+
|
| 11 |
+
from database.session_utils import get_session, init_database
|
| 12 |
+
from database.models.color import Color, ColorType
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ColorManager:
|
| 18 |
+
"""Color manager for database operations"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, database_id: str):
|
| 21 |
+
self.database_id = database_id
|
| 22 |
+
# Initialize database on first use
|
| 23 |
+
init_database(database_id)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_colors_response(self) -> Dict[str, Any]:
|
| 27 |
+
"""
|
| 28 |
+
Get the complete colors response in Google Calendar API v3 format
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Dict containing calendar and event colors with metadata
|
| 32 |
+
"""
|
| 33 |
+
session = get_session(self.database_id)
|
| 34 |
+
try:
|
| 35 |
+
# Get all colors from database
|
| 36 |
+
colors = session.query(Color).all()
|
| 37 |
+
|
| 38 |
+
# Get last updated timestamp
|
| 39 |
+
last_updated = session.query(func.max(Color.updated_at)).scalar()
|
| 40 |
+
if not last_updated:
|
| 41 |
+
last_updated = datetime.now(timezone.utc)
|
| 42 |
+
|
| 43 |
+
# Organize colors by type
|
| 44 |
+
calendar_colors = {}
|
| 45 |
+
event_colors = {}
|
| 46 |
+
|
| 47 |
+
for color in colors:
|
| 48 |
+
color_dict = color.to_dict()
|
| 49 |
+
if color.color_type == ColorType.CALENDAR:
|
| 50 |
+
calendar_colors[color.color_id] = color_dict
|
| 51 |
+
elif color.color_type == ColorType.EVENT:
|
| 52 |
+
event_colors[color.color_id] = color_dict
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"kind": "calendar#colors",
|
| 56 |
+
"updated": last_updated.isoformat().replace('+00:00', '.000Z'),
|
| 57 |
+
"calendar": calendar_colors,
|
| 58 |
+
"event": event_colors
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Error getting colors response: {e}")
|
| 63 |
+
raise
|
| 64 |
+
finally:
|
| 65 |
+
session.close()
|
| 66 |
+
|
| 67 |
+
def get_color_by_id(self, color_type: str, color_id: str) -> Optional[Dict[str, str]]:
|
| 68 |
+
"""
|
| 69 |
+
Get a specific color by type and ID
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
color_type: Either 'calendar' or 'event'
|
| 73 |
+
color_id: The color ID (e.g., '1', '2', etc.)
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Dict containing background and foreground colors, or None if not found
|
| 77 |
+
"""
|
| 78 |
+
session = get_session(self.database_id)
|
| 79 |
+
try:
|
| 80 |
+
# Convert string type to enum
|
| 81 |
+
if color_type == "calendar":
|
| 82 |
+
type_enum = ColorType.CALENDAR
|
| 83 |
+
elif color_type == "event":
|
| 84 |
+
type_enum = ColorType.EVENT
|
| 85 |
+
else:
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
color = session.query(Color).filter(
|
| 89 |
+
and_(
|
| 90 |
+
Color.color_type == type_enum,
|
| 91 |
+
Color.color_id == color_id
|
| 92 |
+
)
|
| 93 |
+
).first()
|
| 94 |
+
|
| 95 |
+
if color:
|
| 96 |
+
return color.to_dict()
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error getting color {color_type}:{color_id}: {e}")
|
| 101 |
+
return None
|
| 102 |
+
finally:
|
| 103 |
+
session.close()
|
| 104 |
+
|
| 105 |
+
def validate_color_id(self, color_type: str, color_id: str) -> bool:
|
| 106 |
+
"""
|
| 107 |
+
Validate if a color ID exists for the given type
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
color_type: Either 'calendar' or 'event'
|
| 111 |
+
color_id: The color ID to validate
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
True if color ID exists, False otherwise
|
| 115 |
+
"""
|
| 116 |
+
return self.get_color_by_id(color_type, color_id) is not None
|
| 117 |
+
|
| 118 |
+
def get_all_colors_by_type(self, color_type: str) -> Dict[str, Dict[str, str]]:
|
| 119 |
+
"""
|
| 120 |
+
Get all colors of a specific type
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
color_type: Either 'calendar' or 'event'
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Dict mapping color IDs to their color definitions
|
| 127 |
+
"""
|
| 128 |
+
session = get_session(self.database_id)
|
| 129 |
+
try:
|
| 130 |
+
# Convert string type to enum
|
| 131 |
+
if color_type == "calendar":
|
| 132 |
+
type_enum = ColorType.CALENDAR
|
| 133 |
+
elif color_type == "event":
|
| 134 |
+
type_enum = ColorType.EVENT
|
| 135 |
+
else:
|
| 136 |
+
return {}
|
| 137 |
+
|
| 138 |
+
colors = session.query(Color).filter(Color.color_type == type_enum).all()
|
| 139 |
+
|
| 140 |
+
result = {}
|
| 141 |
+
for color in colors:
|
| 142 |
+
result[color.color_id] = color.to_dict()
|
| 143 |
+
|
| 144 |
+
return result
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Error getting all colors for type {color_type}: {e}")
|
| 148 |
+
return {}
|
| 149 |
+
finally:
|
| 150 |
+
session.close()
|
| 151 |
+
|
| 152 |
+
def load_sample_colors(self, color_data: List[Dict[str, Any]]) -> int:
|
| 153 |
+
"""
|
| 154 |
+
Load sample color data into the database
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
color_data: List of color dictionaries with color_id, color_type, background, foreground
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
Number of colors loaded
|
| 161 |
+
"""
|
| 162 |
+
session = get_session(self.database_id)
|
| 163 |
+
try:
|
| 164 |
+
loaded_count = 0
|
| 165 |
+
|
| 166 |
+
for color_info in color_data:
|
| 167 |
+
# Check if color already exists
|
| 168 |
+
existing = session.query(Color).filter(
|
| 169 |
+
and_(
|
| 170 |
+
Color.color_id == color_info["color_id"],
|
| 171 |
+
Color.color_type == ColorType(color_info["color_type"])
|
| 172 |
+
)
|
| 173 |
+
).first()
|
| 174 |
+
|
| 175 |
+
if not existing:
|
| 176 |
+
# Create new color
|
| 177 |
+
color = Color(
|
| 178 |
+
color_id=color_info["color_id"],
|
| 179 |
+
color_type=ColorType(color_info["color_type"]),
|
| 180 |
+
background=color_info["background"],
|
| 181 |
+
foreground=color_info["foreground"]
|
| 182 |
+
)
|
| 183 |
+
session.add(color)
|
| 184 |
+
loaded_count += 1
|
| 185 |
+
else:
|
| 186 |
+
# Update existing color
|
| 187 |
+
existing.background = color_info["background"]
|
| 188 |
+
existing.foreground = color_info["foreground"]
|
| 189 |
+
existing.updated_at = datetime.now(timezone.utc)
|
| 190 |
+
loaded_count += 1
|
| 191 |
+
|
| 192 |
+
session.commit()
|
| 193 |
+
logger.info(f"Loaded {loaded_count} colors into database")
|
| 194 |
+
return loaded_count
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
session.rollback()
|
| 198 |
+
logger.error(f"Error loading sample colors: {e}")
|
| 199 |
+
raise
|
| 200 |
+
finally:
|
| 201 |
+
session.close()
|
| 202 |
+
|
| 203 |
+
def clear_all_colors(self) -> int:
|
| 204 |
+
"""
|
| 205 |
+
Clear all colors from the database
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Number of colors deleted
|
| 209 |
+
"""
|
| 210 |
+
session = get_session(self.database_id)
|
| 211 |
+
try:
|
| 212 |
+
count = session.query(Color).count()
|
| 213 |
+
session.query(Color).delete()
|
| 214 |
+
session.commit()
|
| 215 |
+
|
| 216 |
+
logger.info(f"Cleared {count} colors from database")
|
| 217 |
+
return count
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
session.rollback()
|
| 221 |
+
logger.error(f"Error clearing colors: {e}")
|
| 222 |
+
raise
|
| 223 |
+
finally:
|
| 224 |
+
session.close()
|
server/database/managers/event_manager.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
server/database/managers/freebusy_manager.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FreeBusy database manager with Google Calendar API v3 compatible operations
|
| 3 |
+
Handles FreeBusy query operations with database-per-user architecture
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Optional, Dict, Any
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from sqlalchemy.orm import sessionmaker
|
| 10 |
+
from sqlalchemy import and_, or_, desc, asc
|
| 11 |
+
from dateutil import parser
|
| 12 |
+
|
| 13 |
+
from database.session_utils import get_session, init_database
|
| 14 |
+
from database.models.event import Event
|
| 15 |
+
from database.models.calendar import Calendar
|
| 16 |
+
from database.models.user import User
|
| 17 |
+
from schemas.freebusy import (
|
| 18 |
+
FreeBusyQueryRequest,
|
| 19 |
+
FreeBusyQueryResponse,
|
| 20 |
+
FreeBusyCalendarResult as FreeBusyCalendarResultSchema,
|
| 21 |
+
FreeBusyError,
|
| 22 |
+
TimePeriod,
|
| 23 |
+
FreeBusyQueryValidation,
|
| 24 |
+
FreeBusyEventOverlap
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class FreeBusyManager:
|
| 31 |
+
"""FreeBusy manager for database operations"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, database_id: str):
|
| 34 |
+
self.database_id = database_id
|
| 35 |
+
# Initialize database on first use
|
| 36 |
+
init_database(database_id)
|
| 37 |
+
|
| 38 |
+
def _parse_datetime_string(self, datetime_str: str) -> datetime:
|
| 39 |
+
"""Parse ISO datetime string to datetime object"""
|
| 40 |
+
try:
|
| 41 |
+
return parser.isoparse(datetime_str.replace('Z', '+00:00'))
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Error parsing datetime string {datetime_str}: {e}")
|
| 44 |
+
raise ValueError(f"Invalid datetime format: {datetime_str}")
|
| 45 |
+
|
| 46 |
+
def _validate_query_request(self, request: FreeBusyQueryRequest, user_id: str) -> FreeBusyQueryValidation:
|
| 47 |
+
"""Validate FreeBusy query request"""
|
| 48 |
+
time_min = self._parse_datetime_string(request.timeMin)
|
| 49 |
+
time_max = self._parse_datetime_string(request.timeMax)
|
| 50 |
+
calendar_ids = [item.id for item in request.items]
|
| 51 |
+
|
| 52 |
+
validation = FreeBusyQueryValidation(
|
| 53 |
+
time_min=time_min,
|
| 54 |
+
time_max=time_max,
|
| 55 |
+
calendar_ids=calendar_ids,
|
| 56 |
+
user_id=user_id
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if not validation.validate_time_range():
|
| 60 |
+
raise ValueError("Time range is invalid or too large (max 366 days)")
|
| 61 |
+
|
| 62 |
+
if not validation.validate_calendar_count():
|
| 63 |
+
raise ValueError("Too many calendars requested (max 50)")
|
| 64 |
+
|
| 65 |
+
# Validate that all calendar IDs exist in the database
|
| 66 |
+
self._validate_calendar_ids_exist(calendar_ids, user_id)
|
| 67 |
+
|
| 68 |
+
return validation
|
| 69 |
+
|
| 70 |
+
def _validate_calendar_ids_exist(self, calendar_ids: List[str], user_id: str) -> None:
|
| 71 |
+
"""Validate that all calendar IDs exist in the database for the user"""
|
| 72 |
+
session = get_session(self.database_id)
|
| 73 |
+
try:
|
| 74 |
+
for calendar_id in calendar_ids:
|
| 75 |
+
calendar = session.query(Calendar).filter(
|
| 76 |
+
Calendar.calendar_id == calendar_id,
|
| 77 |
+
Calendar.user_id == user_id
|
| 78 |
+
).first()
|
| 79 |
+
|
| 80 |
+
if not calendar:
|
| 81 |
+
raise ValueError(f"Calendar with ID '{calendar_id}' does not exist or is not accessible")
|
| 82 |
+
|
| 83 |
+
finally:
|
| 84 |
+
session.close()
|
| 85 |
+
|
| 86 |
+
def _get_busy_periods_for_calendar(
|
| 87 |
+
self,
|
| 88 |
+
session,
|
| 89 |
+
calendar_id: str,
|
| 90 |
+
user_id: str,
|
| 91 |
+
time_min: datetime,
|
| 92 |
+
time_max: datetime
|
| 93 |
+
) -> List[TimePeriod]:
|
| 94 |
+
"""Get busy periods for a specific calendar"""
|
| 95 |
+
try:
|
| 96 |
+
# Verify calendar belongs to user
|
| 97 |
+
calendar = session.query(Calendar).filter(
|
| 98 |
+
Calendar.calendar_id == calendar_id,
|
| 99 |
+
Calendar.user_id == user_id
|
| 100 |
+
).first()
|
| 101 |
+
|
| 102 |
+
if not calendar:
|
| 103 |
+
# This should not happen if validation passed, but handle gracefully
|
| 104 |
+
logger.error(f"Calendar {calendar_id} not found for user {user_id} during query execution")
|
| 105 |
+
raise ValueError(f"Calendar with ID '{calendar_id}' does not exist or is not accessible")
|
| 106 |
+
|
| 107 |
+
# Query events that overlap with the time range
|
| 108 |
+
events = session.query(Event).filter(
|
| 109 |
+
and_(
|
| 110 |
+
Event.calendar_id == calendar_id,
|
| 111 |
+
Event.user_id == user_id,
|
| 112 |
+
Event.status == "confirmed", # Only confirmed events block time
|
| 113 |
+
# Event overlaps with query range
|
| 114 |
+
Event.start_datetime < time_max,
|
| 115 |
+
Event.end_datetime > time_min
|
| 116 |
+
)
|
| 117 |
+
).all()
|
| 118 |
+
|
| 119 |
+
busy_periods = []
|
| 120 |
+
for event in events:
|
| 121 |
+
# Check if event is transparent (doesn't block time)
|
| 122 |
+
if hasattr(event, 'transparency') and event.transparency == "transparent":
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
# Create overlap object for validation
|
| 126 |
+
overlap = FreeBusyEventOverlap(
|
| 127 |
+
event_id=event.event_id,
|
| 128 |
+
start=event.start_datetime,
|
| 129 |
+
end=event.end_datetime,
|
| 130 |
+
transparency=getattr(event, 'transparency', 'opaque')
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
if overlap.is_busy():
|
| 134 |
+
# Clip event times to query range
|
| 135 |
+
period_start = max(event.start_datetime, time_min)
|
| 136 |
+
period_end = min(event.end_datetime, time_max)
|
| 137 |
+
|
| 138 |
+
busy_periods.append(TimePeriod(
|
| 139 |
+
start=period_start.isoformat(),
|
| 140 |
+
end=period_end.isoformat()
|
| 141 |
+
))
|
| 142 |
+
|
| 143 |
+
# Merge overlapping periods
|
| 144 |
+
busy_periods = self._merge_overlapping_periods(busy_periods)
|
| 145 |
+
|
| 146 |
+
return busy_periods
|
| 147 |
+
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.error(f"Error getting busy periods for calendar {calendar_id}: {e}")
|
| 150 |
+
raise
|
| 151 |
+
|
| 152 |
+
def _merge_overlapping_periods(self, periods: List[TimePeriod]) -> List[TimePeriod]:
|
| 153 |
+
"""Merge overlapping time periods"""
|
| 154 |
+
if not periods:
|
| 155 |
+
return []
|
| 156 |
+
|
| 157 |
+
# Sort periods by start time
|
| 158 |
+
sorted_periods = sorted(periods, key=lambda p: p.start)
|
| 159 |
+
|
| 160 |
+
merged = [sorted_periods[0]]
|
| 161 |
+
|
| 162 |
+
for current in sorted_periods[1:]:
|
| 163 |
+
last_merged = merged[-1]
|
| 164 |
+
|
| 165 |
+
# Parse times for comparison
|
| 166 |
+
current_start = self._parse_datetime_string(current.start)
|
| 167 |
+
current_end = self._parse_datetime_string(current.end)
|
| 168 |
+
last_end = self._parse_datetime_string(last_merged.end)
|
| 169 |
+
|
| 170 |
+
# If current period overlaps with last merged period
|
| 171 |
+
if current_start <= last_end:
|
| 172 |
+
# Extend the last merged period if necessary
|
| 173 |
+
if current_end > last_end:
|
| 174 |
+
merged[-1] = TimePeriod(
|
| 175 |
+
start=last_merged.start,
|
| 176 |
+
end=current.end
|
| 177 |
+
)
|
| 178 |
+
else:
|
| 179 |
+
# No overlap, add as new period
|
| 180 |
+
merged.append(current)
|
| 181 |
+
|
| 182 |
+
return merged
|
| 183 |
+
|
| 184 |
+
def query_freebusy(
|
| 185 |
+
self,
|
| 186 |
+
user_id: str,
|
| 187 |
+
request: FreeBusyQueryRequest
|
| 188 |
+
) -> FreeBusyQueryResponse:
|
| 189 |
+
"""
|
| 190 |
+
Query free/busy information for calendars
|
| 191 |
+
|
| 192 |
+
POST /freeBusy
|
| 193 |
+
"""
|
| 194 |
+
session = get_session(self.database_id)
|
| 195 |
+
try:
|
| 196 |
+
# Validate request
|
| 197 |
+
validation = self._validate_query_request(request, user_id)
|
| 198 |
+
|
| 199 |
+
# Process each calendar
|
| 200 |
+
calendar_results = {}
|
| 201 |
+
|
| 202 |
+
for calendar_item in request.items:
|
| 203 |
+
calendar_id = calendar_item.id
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
# Get busy periods for this calendar
|
| 207 |
+
busy_periods = self._get_busy_periods_for_calendar(
|
| 208 |
+
session,
|
| 209 |
+
calendar_id,
|
| 210 |
+
user_id,
|
| 211 |
+
validation.time_min,
|
| 212 |
+
validation.time_max
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# Add to response
|
| 217 |
+
calendar_results[calendar_id] = FreeBusyCalendarResultSchema(
|
| 218 |
+
busy=busy_periods
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.error(f"Error processing calendar {calendar_id}: {e}")
|
| 223 |
+
|
| 224 |
+
# Add error to response
|
| 225 |
+
calendar_results[calendar_id] = FreeBusyCalendarResultSchema(
|
| 226 |
+
errors=[FreeBusyError(
|
| 227 |
+
domain="calendar",
|
| 228 |
+
reason="backendError"
|
| 229 |
+
)]
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
response = FreeBusyQueryResponse(
|
| 234 |
+
timeMin=request.timeMin,
|
| 235 |
+
timeMax=request.timeMax,
|
| 236 |
+
calendars=calendar_results
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
logger.info(f"Processed FreeBusy query for user {user_id}")
|
| 240 |
+
return response
|
| 241 |
+
|
| 242 |
+
except Exception as e:
|
| 243 |
+
session.rollback()
|
| 244 |
+
logger.error(f"Error processing FreeBusy query for user {user_id}: {e}")
|
| 245 |
+
raise ValueError(f"Error processing FreeBusy query for user {user_id}: {e}")
|
| 246 |
+
finally:
|
| 247 |
+
session.close()
|
| 248 |
+
|
| 249 |
+
|
server/database/managers/settings_manager.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Setting Manager - Database operations for settings management using SQLAlchemy
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, List, Dict
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from database.models.settings import Settings
|
| 9 |
+
from database.models.watch_channel import WatchChannel
|
| 10 |
+
from database.session_utils import get_session, init_database
|
| 11 |
+
from schemas.settings import SettingItem, SettingsWatchRequest, Channel
|
| 12 |
+
import uuid
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SettingManager:
|
| 19 |
+
"""Manager for settings database operations using SQLAlchemy"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, database_id: str):
|
| 22 |
+
self.database_id = database_id
|
| 23 |
+
init_database(database_id)
|
| 24 |
+
|
| 25 |
+
def list_settings(self, user_id: str) -> list[SettingItem]:
|
| 26 |
+
"""List all settings"""
|
| 27 |
+
session = get_session(self.database_id)
|
| 28 |
+
try:
|
| 29 |
+
settings = (
|
| 30 |
+
session.query(Settings)
|
| 31 |
+
.filter(Settings.user_id == user_id)
|
| 32 |
+
.all()
|
| 33 |
+
)
|
| 34 |
+
return [SettingItem.model_validate(s) for s in settings]
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Error listing settings for user '{user_id}': {e}")
|
| 37 |
+
raise
|
| 38 |
+
finally:
|
| 39 |
+
session.close()
|
| 40 |
+
|
| 41 |
+
def get_setting_by_id(self, setting_id: str, user_id: str) -> Optional[Dict]:
|
| 42 |
+
"""Get a setting by its ID"""
|
| 43 |
+
session = get_session(self.database_id)
|
| 44 |
+
try:
|
| 45 |
+
setting = session.query(Settings).filter(
|
| 46 |
+
Settings.id == setting_id,
|
| 47 |
+
Settings.user_id == user_id
|
| 48 |
+
).first()
|
| 49 |
+
if setting:
|
| 50 |
+
return self._format_setting(setting)
|
| 51 |
+
return None
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Error retrieving setting '{setting_id}' for user '{user_id}': {e}")
|
| 54 |
+
raise
|
| 55 |
+
finally:
|
| 56 |
+
session.close()
|
| 57 |
+
|
| 58 |
+
def _format_setting(self, setting: Settings) -> Dict:
|
| 59 |
+
"""Format a setting for API response"""
|
| 60 |
+
return {
|
| 61 |
+
"kind": "calendar#setting",
|
| 62 |
+
"etag": setting.etag,
|
| 63 |
+
"id": setting.id,
|
| 64 |
+
"value": setting.value,
|
| 65 |
+
"user_id": setting.user_id
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def watch_settings(self, watch_request: SettingsWatchRequest, user_id: str) -> Channel:
|
| 69 |
+
"""
|
| 70 |
+
Set up a watch channel for settings changes
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
watch_request: The watch request parameters
|
| 74 |
+
user_id: The user setting up the watch
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
Channel: The created watch channel
|
| 78 |
+
"""
|
| 79 |
+
session = get_session(self.database_id)
|
| 80 |
+
try:
|
| 81 |
+
# Generate unique resource ID for settings watch
|
| 82 |
+
resource_id = f"settings-{user_id}-{uuid.uuid4().hex[:8]}"
|
| 83 |
+
resource_uri = f"/calendars/{user_id}/settings"
|
| 84 |
+
|
| 85 |
+
# Calculate expiration time (max 24 hours from now if not specified)
|
| 86 |
+
now = datetime.utcnow()
|
| 87 |
+
expires_at = now + timedelta(hours=24)
|
| 88 |
+
|
| 89 |
+
if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first():
|
| 90 |
+
raise ValueError(f"Channel with Id {watch_request.id} already exists")
|
| 91 |
+
|
| 92 |
+
# Create watch channel record
|
| 93 |
+
watch_channel = WatchChannel(
|
| 94 |
+
id=watch_request.id,
|
| 95 |
+
resource_id=resource_id,
|
| 96 |
+
resource_uri=resource_uri,
|
| 97 |
+
resource_type="settings",
|
| 98 |
+
calendar_id="",
|
| 99 |
+
user_id=user_id,
|
| 100 |
+
webhook_address=watch_request.address,
|
| 101 |
+
webhook_token=watch_request.token,
|
| 102 |
+
webhook_type=watch_request.type,
|
| 103 |
+
params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None,
|
| 104 |
+
created_at=now,
|
| 105 |
+
expires_at=expires_at,
|
| 106 |
+
is_active="true",
|
| 107 |
+
notification_count=0
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Save to database
|
| 111 |
+
session.add(watch_channel)
|
| 112 |
+
session.commit()
|
| 113 |
+
|
| 114 |
+
logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}")
|
| 115 |
+
|
| 116 |
+
# Return channel response
|
| 117 |
+
return Channel(
|
| 118 |
+
kind="api#channel",
|
| 119 |
+
id=watch_channel.id,
|
| 120 |
+
resourceId=resource_id,
|
| 121 |
+
resourceUri=resource_uri,
|
| 122 |
+
token=watch_channel.webhook_token,
|
| 123 |
+
expiration=expires_at.isoformat() + "Z" if expires_at else None
|
| 124 |
+
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
session.rollback()
|
| 129 |
+
logger.error(f"Error creating settings watch channel: {e}")
|
| 130 |
+
raise
|
| 131 |
+
finally:
|
| 132 |
+
session.close()
|
| 133 |
+
|
| 134 |
+
|
server/database/managers/user_manager.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Manager - Database operations for user management using SQLAlchemy
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import uuid
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from database.models import User
|
| 11 |
+
from database.session_utils import get_session, init_database
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class UserManager:
|
| 17 |
+
"""Manager for user database operations using SQLAlchemy"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, database_id: str):
|
| 20 |
+
self.database_id = database_id
|
| 21 |
+
# Initialize database on first use
|
| 22 |
+
init_database(database_id)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_user(self, user_data: Dict) -> Dict:
|
| 26 |
+
"""Create a new user"""
|
| 27 |
+
session = get_session(self.database_id)
|
| 28 |
+
try:
|
| 29 |
+
# Generate unique user ID if not provided
|
| 30 |
+
user_id = user_data.get("user_id") or str(uuid.uuid4())
|
| 31 |
+
|
| 32 |
+
# Check if user with email already exists
|
| 33 |
+
existing_user = session.query(User).filter(User.email == user_data["email"]).first()
|
| 34 |
+
if existing_user:
|
| 35 |
+
raise ValueError(f"User with email {user_data['email']} already exists")
|
| 36 |
+
|
| 37 |
+
# Create User model instance
|
| 38 |
+
user = User(
|
| 39 |
+
user_id=user_id,
|
| 40 |
+
email=user_data["email"],
|
| 41 |
+
name=user_data["name"],
|
| 42 |
+
given_name=user_data.get("given_name"),
|
| 43 |
+
family_name=user_data.get("family_name"),
|
| 44 |
+
picture=user_data.get("picture"),
|
| 45 |
+
locale=user_data.get("locale", "en"),
|
| 46 |
+
timezone=user_data.get("timezone", "UTC"),
|
| 47 |
+
is_active=user_data.get("is_active", True),
|
| 48 |
+
is_verified=user_data.get("is_verified", False),
|
| 49 |
+
provider=user_data.get("provider"),
|
| 50 |
+
provider_id=user_data.get("provider_id"),
|
| 51 |
+
access_token_hash=user_data.get("access_token_hash"),
|
| 52 |
+
refresh_token_hash=user_data.get("refresh_token_hash")
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
session.add(user)
|
| 56 |
+
session.commit()
|
| 57 |
+
|
| 58 |
+
# Return the created user
|
| 59 |
+
result = self._format_user_response(user)
|
| 60 |
+
return result
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
session.rollback()
|
| 64 |
+
logger.error(f"Error creating user: {e}")
|
| 65 |
+
raise
|
| 66 |
+
finally:
|
| 67 |
+
session.close()
|
| 68 |
+
|
| 69 |
+
def get_user_by_id(self, user_id: str) -> Optional[Dict]:
|
| 70 |
+
"""Get a user by ID"""
|
| 71 |
+
session = get_session(self.database_id)
|
| 72 |
+
try:
|
| 73 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 74 |
+
|
| 75 |
+
if not user:
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
return self._format_user_response(user)
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error getting user {user_id}: {e}")
|
| 82 |
+
raise
|
| 83 |
+
finally:
|
| 84 |
+
session.close()
|
| 85 |
+
|
| 86 |
+
def get_first_user_from_db(self) -> Optional[Dict]:
|
| 87 |
+
"""Get the first user from db"""
|
| 88 |
+
session = get_session(self.database_id)
|
| 89 |
+
try:
|
| 90 |
+
user = session.query(User).first()
|
| 91 |
+
if not user:
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
return self._format_user_response(user)
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"Error getting user from db: {e}")
|
| 97 |
+
raise
|
| 98 |
+
finally:
|
| 99 |
+
session.close()
|
| 100 |
+
|
| 101 |
+
def get_user_by_access_token(self, static_token: str) -> Optional[Dict]:
|
| 102 |
+
"""Get a user by access token"""
|
| 103 |
+
session = get_session(self.database_id)
|
| 104 |
+
try:
|
| 105 |
+
user = session.query(User).filter(User.static_token == static_token).first()
|
| 106 |
+
|
| 107 |
+
if not user:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
return self._format_user_response(user)
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Error getting user by access token {static_token}: {e}")
|
| 114 |
+
raise
|
| 115 |
+
finally:
|
| 116 |
+
session.close()
|
| 117 |
+
|
| 118 |
+
def get_user_by_email(self, email: str) -> Optional[Dict]:
|
| 119 |
+
"""Get a user by email"""
|
| 120 |
+
session = get_session(self.database_id)
|
| 121 |
+
try:
|
| 122 |
+
user = session.query(User).filter(User.email == email).first()
|
| 123 |
+
|
| 124 |
+
if not user:
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
return self._format_user_response(user)
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Error getting user by email {email}: {e}")
|
| 131 |
+
raise
|
| 132 |
+
finally:
|
| 133 |
+
session.close()
|
| 134 |
+
|
| 135 |
+
def update_user(self, user_id: str, user_data: Dict) -> Optional[Dict]:
|
| 136 |
+
"""Update a user"""
|
| 137 |
+
session = get_session(self.database_id)
|
| 138 |
+
try:
|
| 139 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 140 |
+
|
| 141 |
+
if not user:
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
# Update fields if provided
|
| 145 |
+
updateable_fields = [
|
| 146 |
+
"name", "given_name", "family_name", "picture", "locale",
|
| 147 |
+
"timezone", "is_active", "is_verified", "provider", "provider_id",
|
| 148 |
+
"access_token_hash", "refresh_token_hash"
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
for field in updateable_fields:
|
| 152 |
+
if field in user_data:
|
| 153 |
+
setattr(user, field, user_data[field])
|
| 154 |
+
|
| 155 |
+
# Update last login if provided
|
| 156 |
+
if user_data.get("update_last_login"):
|
| 157 |
+
user.last_login_at = datetime.now(timezone.utc)
|
| 158 |
+
|
| 159 |
+
# SQLAlchemy will automatically update updated_at due to onupdate=datetime.utcnow
|
| 160 |
+
session.commit()
|
| 161 |
+
|
| 162 |
+
return self._format_user_response(user)
|
| 163 |
+
|
| 164 |
+
except Exception as e:
|
| 165 |
+
session.rollback()
|
| 166 |
+
logger.error(f"Error updating user {user_id}: {e}")
|
| 167 |
+
raise
|
| 168 |
+
finally:
|
| 169 |
+
session.close()
|
| 170 |
+
|
| 171 |
+
def delete_user(self, user_id: str) -> bool:
|
| 172 |
+
"""Delete a user and all related data"""
|
| 173 |
+
session = get_session(self.database_id)
|
| 174 |
+
try:
|
| 175 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 176 |
+
|
| 177 |
+
if not user:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
# Delete user (cascade will delete related calendars and events)
|
| 181 |
+
session.delete(user)
|
| 182 |
+
session.commit()
|
| 183 |
+
return True
|
| 184 |
+
|
| 185 |
+
except Exception as e:
|
| 186 |
+
session.rollback()
|
| 187 |
+
logger.error(f"Error deleting user {user_id}: {e}")
|
| 188 |
+
raise
|
| 189 |
+
finally:
|
| 190 |
+
session.close()
|
| 191 |
+
|
| 192 |
+
def deactivate_user(self, user_id: str) -> Optional[Dict]:
|
| 193 |
+
"""Deactivate a user (soft delete)"""
|
| 194 |
+
session = get_session(self.database_id)
|
| 195 |
+
try:
|
| 196 |
+
user = session.query(User).filter(User.user_id == user_id).first()
|
| 197 |
+
|
| 198 |
+
if not user:
|
| 199 |
+
return None
|
| 200 |
+
|
| 201 |
+
user.is_active = False
|
| 202 |
+
session.commit()
|
| 203 |
+
|
| 204 |
+
return self._format_user_response(user)
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
session.rollback()
|
| 208 |
+
logger.error(f"Error deactivating user {user_id}: {e}")
|
| 209 |
+
raise
|
| 210 |
+
finally:
|
| 211 |
+
session.close()
|
| 212 |
+
|
| 213 |
+
def list_users(self, limit: int = 100, offset: int = 0, active_only: bool = True) -> List[Dict]:
|
| 214 |
+
"""List users"""
|
| 215 |
+
session = get_session(self.database_id)
|
| 216 |
+
try:
|
| 217 |
+
query = session.query(User)
|
| 218 |
+
|
| 219 |
+
if active_only:
|
| 220 |
+
query = query.filter(User.is_active == True)
|
| 221 |
+
|
| 222 |
+
users = query.order_by(User.created_at.desc()).offset(offset).limit(limit).all()
|
| 223 |
+
|
| 224 |
+
return [self._format_user_response(user) for user in users]
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"Error listing users: {e}")
|
| 228 |
+
raise
|
| 229 |
+
finally:
|
| 230 |
+
session.close()
|
| 231 |
+
|
| 232 |
+
def authenticate_user(self, email: str, provider: str = None, provider_id: str = None) -> Optional[Dict]:
|
| 233 |
+
"""Authenticate user by email and optionally by provider"""
|
| 234 |
+
session = get_session(self.database_id)
|
| 235 |
+
try:
|
| 236 |
+
query = session.query(User).filter(
|
| 237 |
+
User.email == email,
|
| 238 |
+
User.is_active == True
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if provider:
|
| 242 |
+
query = query.filter(User.provider == provider)
|
| 243 |
+
|
| 244 |
+
if provider_id:
|
| 245 |
+
query = query.filter(User.provider_id == provider_id)
|
| 246 |
+
|
| 247 |
+
user = query.first()
|
| 248 |
+
|
| 249 |
+
if not user:
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
# Update last login
|
| 253 |
+
user.last_login_at = datetime.now(timezone.utc)
|
| 254 |
+
session.commit()
|
| 255 |
+
|
| 256 |
+
return self._format_user_response(user)
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Error authenticating user {email}: {e}")
|
| 260 |
+
raise
|
| 261 |
+
finally:
|
| 262 |
+
session.close()
|
| 263 |
+
|
| 264 |
+
def _format_user_response(self, user: User) -> Dict:
|
| 265 |
+
"""Format user model for API response (without sensitive data)"""
|
| 266 |
+
return {
|
| 267 |
+
"id": user.user_id,
|
| 268 |
+
"email": user.email,
|
| 269 |
+
"name": user.name,
|
| 270 |
+
"given_name": user.given_name,
|
| 271 |
+
"family_name": user.family_name,
|
| 272 |
+
"picture": user.picture,
|
| 273 |
+
"locale": user.locale,
|
| 274 |
+
"timezone": user.timezone,
|
| 275 |
+
"is_active": user.is_active,
|
| 276 |
+
"is_verified": user.is_verified,
|
| 277 |
+
"provider": user.provider,
|
| 278 |
+
"created_at": user.created_at.isoformat() if user.created_at else None,
|
| 279 |
+
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
| 280 |
+
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None
|
| 281 |
+
}
|
server/database/models/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database models package
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .base import Base
|
| 6 |
+
from .user import User
|
| 7 |
+
from .calendar import Calendar
|
| 8 |
+
from .event import Event, Attendees, Attachment, WorkingLocationProperties
|
| 9 |
+
from .color import Color
|
| 10 |
+
from .settings import Settings
|
| 11 |
+
from .acl import ACLs, Scope
|
| 12 |
+
from .watch_channel import WatchChannel
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"Base",
|
| 16 |
+
"User",
|
| 17 |
+
"Calendar",
|
| 18 |
+
"Event",
|
| 19 |
+
"Attendees",
|
| 20 |
+
"Attachment",
|
| 21 |
+
"WorkingLocationProperties",
|
| 22 |
+
"Color",
|
| 23 |
+
"Settings",
|
| 24 |
+
"ACLs",
|
| 25 |
+
"Scope",
|
| 26 |
+
"WatchChannel"
|
| 27 |
+
]
|