santosh-iima commited on
Commit
beb8990
·
verified ·
1 Parent(s): 756c997

Upload 91 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. server/.DS_Store +0 -0
  2. server/Dockerfile +40 -0
  3. server/__init__.py +7 -0
  4. server/apis/__init__.py +0 -0
  5. server/apis/acl/router.py +236 -0
  6. server/apis/calendarList/__init__.py +0 -0
  7. server/apis/calendarList/router.py +802 -0
  8. server/apis/calendars/__init__.py +0 -0
  9. server/apis/calendars/router.py +271 -0
  10. server/apis/colors/__init__.py +1 -0
  11. server/apis/colors/data.py +219 -0
  12. server/apis/colors/router.py +46 -0
  13. server/apis/core_apis.py +19 -0
  14. server/apis/database_router.py +625 -0
  15. server/apis/events/router.py +893 -0
  16. server/apis/freebusy/__init__.py +7 -0
  17. server/apis/freebusy/router.py +80 -0
  18. server/apis/mcp/__init__.py +0 -0
  19. server/apis/mcp/router.py +31 -0
  20. server/apis/settings/router.py +116 -0
  21. server/apis/users/router.py +95 -0
  22. server/app.py +56 -0
  23. server/calendar_environment.py +23 -0
  24. server/calendar_mcp/__init__.py +3 -0
  25. server/calendar_mcp/tools/__init__.py +40 -0
  26. server/calendar_mcp/tools/acl.py +351 -0
  27. server/calendar_mcp/tools/calendar_list.py +704 -0
  28. server/calendar_mcp/tools/calendars.py +353 -0
  29. server/calendar_mcp/tools/colors.py +57 -0
  30. server/calendar_mcp/tools/events.py +0 -0
  31. server/calendar_mcp/tools/freebusy.py +158 -0
  32. server/calendar_mcp/tools/settings.py +161 -0
  33. server/calendar_mcp/tools/users.py +37 -0
  34. server/data/__init__.py +1 -0
  35. server/data/enhanced_event_seed_data.py +893 -0
  36. server/data/google_colors.py +46 -0
  37. server/data/multi_user_sample.py +532 -0
  38. server/data/watch_channel_seed_data.py +245 -0
  39. server/database/__init__.py +0 -0
  40. server/database/base_manager.py +112 -0
  41. server/database/managers/__init__.py +0 -0
  42. server/database/managers/acl_manager.py +595 -0
  43. server/database/managers/calendar_list_manager.py +626 -0
  44. server/database/managers/calendar_manager.py +445 -0
  45. server/database/managers/color_manager.py +224 -0
  46. server/database/managers/event_manager.py +0 -0
  47. server/database/managers/freebusy_manager.py +249 -0
  48. server/database/managers/settings_manager.py +134 -0
  49. server/database/managers/user_manager.py +281 -0
  50. 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
+ ]