From fed39745488c4c8ed82ea2a3ac48a0f8d7971193 Mon Sep 17 00:00:00 2001 From: Santosh Sah Date: Wed, 28 Jan 2026 22:03:57 +0530 Subject: [PATCH 1/5] calendar gym environment from turing --- envs/calendar_env/README.md | 66 + envs/calendar_env/__init__.py | 35 + envs/calendar_env/client.py | 1142 ++++++ ...nd_mcp_Single_Gym_Client_Meta_Turing.ipynb | 3133 ++++++++++++++ envs/calendar_env/models.py | 102 + envs/calendar_env/openenv.yaml | 6 + envs/calendar_env/pyproject.toml | 47 + envs/calendar_env/requirements.txt | 19 + envs/calendar_env/scenario_config.json | 82 + envs/calendar_env/server/Dockerfile | 40 + envs/calendar_env/server/__init__.py | 7 + envs/calendar_env/server/apis/__init__.py | 0 envs/calendar_env/server/apis/acl/router.py | 236 ++ .../server/apis/calendarList/__init__.py | 0 .../server/apis/calendarList/router.py | 802 ++++ .../server/apis/calendars/__init__.py | 0 .../server/apis/calendars/router.py | 271 ++ .../server/apis/colors/__init__.py | 1 + envs/calendar_env/server/apis/colors/data.py | 219 + .../calendar_env/server/apis/colors/router.py | 46 + envs/calendar_env/server/apis/core_apis.py | 19 + .../server/apis/database_router.py | 625 +++ .../calendar_env/server/apis/events/router.py | 893 ++++ .../server/apis/freebusy/__init__.py | 7 + .../server/apis/freebusy/router.py | 80 + envs/calendar_env/server/apis/mcp/__init__.py | 0 envs/calendar_env/server/apis/mcp/router.py | 31 + .../server/apis/settings/router.py | 116 + envs/calendar_env/server/apis/users/router.py | 95 + envs/calendar_env/server/app.py | 56 + .../server/calendar_environment.py | 23 + .../server/calendar_mcp/__init__.py | 3 + .../server/calendar_mcp/tools/__init__.py | 40 + .../server/calendar_mcp/tools/acl.py | 351 ++ .../calendar_mcp/tools/calendar_list.py | 704 ++++ .../server/calendar_mcp/tools/calendars.py | 353 ++ .../server/calendar_mcp/tools/colors.py | 57 + .../server/calendar_mcp/tools/events.py | 2503 ++++++++++++ .../server/calendar_mcp/tools/freebusy.py | 158 + .../server/calendar_mcp/tools/settings.py | 161 + .../server/calendar_mcp/tools/users.py | 37 + envs/calendar_env/server/data/__init__.py | 1 + .../server/data/enhanced_event_seed_data.py | 893 ++++ .../calendar_env/server/data/google_colors.py | 46 + .../server/data/multi_user_sample.py | 532 +++ .../server/data/watch_channel_seed_data.py | 245 ++ envs/calendar_env/server/database/__init__.py | 0 .../server/database/base_manager.py | 112 + .../server/database/managers/__init__.py | 0 .../server/database/managers/acl_manager.py | 595 +++ .../managers/calendar_list_manager.py | 626 +++ .../database/managers/calendar_manager.py | 445 ++ .../server/database/managers/color_manager.py | 224 + .../server/database/managers/event_manager.py | 3617 +++++++++++++++++ .../database/managers/freebusy_manager.py | 249 ++ .../database/managers/settings_manager.py | 134 + .../server/database/managers/user_manager.py | 281 ++ .../server/database/models/__init__.py | 27 + .../server/database/models/acl.py | 55 + .../server/database/models/base.py | 7 + .../server/database/models/calendar.py | 53 + .../server/database/models/color.py | 49 + .../server/database/models/event.py | 403 ++ .../server/database/models/settings.py | 29 + .../server/database/models/user.py | 66 + .../server/database/models/watch_channel.py | 54 + .../server/database/seed_database.py | 70 + .../server/database/session_manager.py | 164 + .../server/database/session_utils.py | 19 + .../server/google_calendar_apis.csv | 38 + envs/calendar_env/server/handlers/__init__.py | 0 .../server/handlers/mcp_handler.py | 165 + .../server/handlers/tool_handlers.py | 450 ++ envs/calendar_env/server/main.py | 213 + .../server/mcp_databases/seed_store.db | Bin 0 -> 45056 bytes .../server/middleware/__init__.py | 7 + envs/calendar_env/server/middleware/auth.py | 94 + .../server/openenv_wrapper/__init__.py | 42 + .../server/openenv_wrapper/client.py | 128 + .../server/openenv_wrapper/config.py | 174 + .../openenv_wrapper/custom_http_server.py | 348 ++ .../server/openenv_wrapper/data_models.py | 112 + .../openenv_wrapper/mcp_env_environment.py | 441 ++ envs/calendar_env/server/schemas/__init__.py | 0 envs/calendar_env/server/schemas/acl.py | 180 + envs/calendar_env/server/schemas/calendar.py | 133 + .../server/schemas/calendar_list.py | 287 ++ envs/calendar_env/server/schemas/common.py | 74 + envs/calendar_env/server/schemas/event.py | 634 +++ envs/calendar_env/server/schemas/freebusy.py | 163 + .../server/schemas/import_event.py | 543 +++ envs/calendar_env/server/schemas/settings.py | 89 + envs/calendar_env/server/services/__init__.py | 10 + .../server/services/notification_service.py | 272 ++ envs/calendar_env/server/tests/conftest.py | 135 + .../server/tests/test_calenderList_router.py | 287 ++ .../server/tests_tools/__init__.py | 0 .../server/tests_tools/conftest.py | 122 + .../server/tests_tools/debug_mcp_request.py | 40 + .../tests_tools/test_mcp_calendars_all.py | 406 ++ .../server/tests_tools/test_mcp_colors.py | 359 ++ .../server/tests_tools/test_mcp_core_all.py | 261 ++ .../server/tests_tools/test_mcp_errors.py | 128 + .../server/tests_tools/test_mcp_events_all.py | 640 +++ .../server/tests_tools/test_mcp_scenarios.py | 157 + .../server/tests_tools/test_mcp_settings.py | 358 ++ .../server/tests_tools/test_mcp_tools_list.py | 71 + .../tests_tools/test_tool_handlers_generic.py | 227 ++ envs/calendar_env/server/utils/__init__.py | 1 + .../server/utils/recurrence_utils.py | 562 +++ envs/calendar_env/server/utils/validation.py | 216 + 111 files changed, 30129 insertions(+) create mode 100644 envs/calendar_env/README.md create mode 100644 envs/calendar_env/__init__.py create mode 100644 envs/calendar_env/client.py create mode 100644 envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb create mode 100644 envs/calendar_env/models.py create mode 100644 envs/calendar_env/openenv.yaml create mode 100644 envs/calendar_env/pyproject.toml create mode 100644 envs/calendar_env/requirements.txt create mode 100644 envs/calendar_env/scenario_config.json create mode 100644 envs/calendar_env/server/Dockerfile create mode 100644 envs/calendar_env/server/__init__.py create mode 100644 envs/calendar_env/server/apis/__init__.py create mode 100644 envs/calendar_env/server/apis/acl/router.py create mode 100644 envs/calendar_env/server/apis/calendarList/__init__.py create mode 100644 envs/calendar_env/server/apis/calendarList/router.py create mode 100644 envs/calendar_env/server/apis/calendars/__init__.py create mode 100644 envs/calendar_env/server/apis/calendars/router.py create mode 100644 envs/calendar_env/server/apis/colors/__init__.py create mode 100644 envs/calendar_env/server/apis/colors/data.py create mode 100644 envs/calendar_env/server/apis/colors/router.py create mode 100644 envs/calendar_env/server/apis/core_apis.py create mode 100644 envs/calendar_env/server/apis/database_router.py create mode 100644 envs/calendar_env/server/apis/events/router.py create mode 100644 envs/calendar_env/server/apis/freebusy/__init__.py create mode 100644 envs/calendar_env/server/apis/freebusy/router.py create mode 100644 envs/calendar_env/server/apis/mcp/__init__.py create mode 100644 envs/calendar_env/server/apis/mcp/router.py create mode 100644 envs/calendar_env/server/apis/settings/router.py create mode 100644 envs/calendar_env/server/apis/users/router.py create mode 100644 envs/calendar_env/server/app.py create mode 100644 envs/calendar_env/server/calendar_environment.py create mode 100644 envs/calendar_env/server/calendar_mcp/__init__.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/__init__.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/acl.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/calendar_list.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/calendars.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/colors.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/events.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/freebusy.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/settings.py create mode 100644 envs/calendar_env/server/calendar_mcp/tools/users.py create mode 100644 envs/calendar_env/server/data/__init__.py create mode 100644 envs/calendar_env/server/data/enhanced_event_seed_data.py create mode 100644 envs/calendar_env/server/data/google_colors.py create mode 100644 envs/calendar_env/server/data/multi_user_sample.py create mode 100644 envs/calendar_env/server/data/watch_channel_seed_data.py create mode 100644 envs/calendar_env/server/database/__init__.py create mode 100644 envs/calendar_env/server/database/base_manager.py create mode 100644 envs/calendar_env/server/database/managers/__init__.py create mode 100644 envs/calendar_env/server/database/managers/acl_manager.py create mode 100644 envs/calendar_env/server/database/managers/calendar_list_manager.py create mode 100644 envs/calendar_env/server/database/managers/calendar_manager.py create mode 100644 envs/calendar_env/server/database/managers/color_manager.py create mode 100644 envs/calendar_env/server/database/managers/event_manager.py create mode 100644 envs/calendar_env/server/database/managers/freebusy_manager.py create mode 100644 envs/calendar_env/server/database/managers/settings_manager.py create mode 100644 envs/calendar_env/server/database/managers/user_manager.py create mode 100644 envs/calendar_env/server/database/models/__init__.py create mode 100644 envs/calendar_env/server/database/models/acl.py create mode 100644 envs/calendar_env/server/database/models/base.py create mode 100644 envs/calendar_env/server/database/models/calendar.py create mode 100644 envs/calendar_env/server/database/models/color.py create mode 100644 envs/calendar_env/server/database/models/event.py create mode 100644 envs/calendar_env/server/database/models/settings.py create mode 100644 envs/calendar_env/server/database/models/user.py create mode 100644 envs/calendar_env/server/database/models/watch_channel.py create mode 100644 envs/calendar_env/server/database/seed_database.py create mode 100644 envs/calendar_env/server/database/session_manager.py create mode 100644 envs/calendar_env/server/database/session_utils.py create mode 100644 envs/calendar_env/server/google_calendar_apis.csv create mode 100644 envs/calendar_env/server/handlers/__init__.py create mode 100644 envs/calendar_env/server/handlers/mcp_handler.py create mode 100644 envs/calendar_env/server/handlers/tool_handlers.py create mode 100644 envs/calendar_env/server/main.py create mode 100644 envs/calendar_env/server/mcp_databases/seed_store.db create mode 100644 envs/calendar_env/server/middleware/__init__.py create mode 100644 envs/calendar_env/server/middleware/auth.py create mode 100644 envs/calendar_env/server/openenv_wrapper/__init__.py create mode 100644 envs/calendar_env/server/openenv_wrapper/client.py create mode 100644 envs/calendar_env/server/openenv_wrapper/config.py create mode 100644 envs/calendar_env/server/openenv_wrapper/custom_http_server.py create mode 100644 envs/calendar_env/server/openenv_wrapper/data_models.py create mode 100644 envs/calendar_env/server/openenv_wrapper/mcp_env_environment.py create mode 100644 envs/calendar_env/server/schemas/__init__.py create mode 100644 envs/calendar_env/server/schemas/acl.py create mode 100644 envs/calendar_env/server/schemas/calendar.py create mode 100644 envs/calendar_env/server/schemas/calendar_list.py create mode 100644 envs/calendar_env/server/schemas/common.py create mode 100644 envs/calendar_env/server/schemas/event.py create mode 100644 envs/calendar_env/server/schemas/freebusy.py create mode 100644 envs/calendar_env/server/schemas/import_event.py create mode 100644 envs/calendar_env/server/schemas/settings.py create mode 100644 envs/calendar_env/server/services/__init__.py create mode 100644 envs/calendar_env/server/services/notification_service.py create mode 100644 envs/calendar_env/server/tests/conftest.py create mode 100644 envs/calendar_env/server/tests/test_calenderList_router.py create mode 100644 envs/calendar_env/server/tests_tools/__init__.py create mode 100644 envs/calendar_env/server/tests_tools/conftest.py create mode 100644 envs/calendar_env/server/tests_tools/debug_mcp_request.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_colors.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_core_all.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_errors.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_events_all.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_scenarios.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_settings.py create mode 100644 envs/calendar_env/server/tests_tools/test_mcp_tools_list.py create mode 100644 envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py create mode 100644 envs/calendar_env/server/utils/__init__.py create mode 100644 envs/calendar_env/server/utils/recurrence_utils.py create mode 100644 envs/calendar_env/server/utils/validation.py diff --git a/envs/calendar_env/README.md b/envs/calendar_env/README.md new file mode 100644 index 000000000..20793dfaf --- /dev/null +++ b/envs/calendar_env/README.md @@ -0,0 +1,66 @@ +# Calendar Environment + +This environment exposes a Calendar Gym tools through the OpenEnv reset/step/state interface. The server runs a FastAPI app that serves the OpenEnv endpoints. + +## Server Setup + +### Docker (Recommended) + +```bash +cd envs/calendar_env/server +docker build -t calendar-env:latest . +docker run --rm -p 8004:8004 calendar-env:latest +curl http://localhost:8004/health +``` +On Server health success response will be: +`{"status":"healthy","service":"calendar-env"}` + +### Without Docker + +```bash +cd envs/calendar_env +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cd server +uvicorn main:app --host 0.0.0.0 --port 8004 --reload +``` + +## Client Setup + +### Quick Start (Demo) + +For a quick demo, simply update `llm_api_key` in `scenario_config.json` and run: +```bash +python client.py --scenario scenario_config.json +``` +The existing config includes a sample scenario for testing. + +### Configure Scenario + +To customize for your use case, edit `scenario_config.json` and update these fields: + +**llm variables:** +- `llm_api_key` - Your OpenAI/Anthropic/Google API key (or set via env var) +- `llm_model` - Model name (e.g., `gpt-4o-mini`, `claude-3-5-sonnet-20241022`) +- `llm_provider` - Provider: `openai`, `anthropic`, or `google` + +**Scenario Variables** +- `user_prompt` - Task for the agent to complete +- `system_prompt` - Instructions for agent behavior +- `context` - The auth headers for gym like (x-access-token) +- `seed_database_file` - Path to SQL file for custom data +- `verifiers` - SQL queries to validate task completion +- `expected_tools` - Tools agent should use (for tracking) + +### Run Client + +**Run scenario-based benchmark:** +```bash +python client.py --scenario scenario_config.json +``` + +Output will be saved to `response_output/` folder with execution details, tool calls, and verification results. + +**Notebook Evaluation:** +For interactive evaluation and testing, use the: [`Jupyter notebook`](client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb) \ No newline at end of file diff --git a/envs/calendar_env/__init__.py b/envs/calendar_env/__init__.py new file mode 100644 index 000000000..25e95441b --- /dev/null +++ b/envs/calendar_env/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Calendar Environment package exports.""" + +from typing import Any +from .models import ( + CalendarAction, + CalendarObservation, + MCPAction, + MCPObservation, + ListToolsAction, + ToolCallAction, +) + +__all__ = [ + "CalendarAction", + "CalendarObservation", + "CalendarEnv", + "MCPAction", + "MCPObservation", + "ListToolsAction", + "ToolCallAction", +] + + +def __getattr__(name: str) -> Any: + if name == "CalendarEnv": + from .client import CalendarEnv as _CalendarEnv + + return _CalendarEnv + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/envs/calendar_env/client.py b/envs/calendar_env/client.py new file mode 100644 index 000000000..9e264d17f --- /dev/null +++ b/envs/calendar_env/client.py @@ -0,0 +1,1142 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Calendar Environment HTTP client. + +This client uses the OpenEnv HTTP endpoints exposed by the calendar server: +/reset, /step, and /state. It also includes helpers for database seeding. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import random +import string +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, TypeVar, Generic + +import httpx + +try: + from .models import CalendarAction, CalendarObservation +except ImportError: + from pathlib import Path + import sys + + CURRENT_DIR = Path(__file__).resolve().parent + if str(CURRENT_DIR) not in sys.path: + sys.path.insert(0, str(CURRENT_DIR)) + from models import CalendarAction, CalendarObservation + +ObsT = TypeVar("ObsT") + + +@dataclass +class StepResult(Generic[ObsT]): + observation: ObsT + reward: Optional[float] = None + done: bool = False + + +logger = logging.getLogger(__name__) +DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parent / "response_output" + + +def _generate_database_id() -> str: + timestamp = int(time.time() * 1000) + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=9)) + return f"db_{timestamp}_{suffix}" + + +class CalendarEnv: + """HTTP client for the Calendar environment.""" + + def __init__( + self, + base_url: str, + database_id: str = "default", + access_token: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + timeout_s: float = 60.0, + ): + self.base_url = base_url.rstrip("/") + self.database_id = database_id + self.access_token = access_token + self.context = context or {} + self.timeout_s = timeout_s + self._client: Optional[httpx.Client] = None + + def __enter__(self) -> "CalendarEnv": + self._ensure_client() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def close(self) -> None: + if self._client is not None: + self._client.close() + self._client = None + + def _ensure_client(self) -> httpx.Client: + if self._client is None: + self._client = httpx.Client(timeout=self.timeout_s) + return self._client + + def _headers( + self, + database_id: Optional[str] = None, + access_token: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, str]: + headers: Dict[str, str] = {"Content-Type": "application/json"} + + resolved_db = database_id if database_id is not None else self.database_id + if resolved_db: + headers["x-database-id"] = resolved_db + + resolved_token = access_token if access_token is not None else self.access_token + if resolved_token: + headers["x-access-token"] = resolved_token + + merged_context = dict(self.context) + if context: + merged_context.update(context) + + for key, value in merged_context.items(): + if value is None: + continue + header_key = str(key).strip().lower().replace("_", "-") + if not header_key.startswith("x-"): + header_key = f"x-{header_key}" + if header_key in headers: + continue + headers[header_key] = str(value) + + return headers + + def _parse_step_result(self, payload: Dict[str, Any]) -> StepResult[CalendarObservation]: + obs_data = payload.get("observation", {}) + observation = CalendarObservation( + success=obs_data.get("success", True), + error_message=obs_data.get("error_message"), + tools_list=obs_data.get("tools_list"), + tool_result=obs_data.get("tool_result"), + done=payload.get("done", False), + reward=payload.get("reward"), + metadata=obs_data.get("metadata", {}), + ) + return StepResult( + observation=observation, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def reset( + self, + database_id: Optional[str] = None, + sql_content: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> StepResult[CalendarObservation]: + payload: Dict[str, Any] = {} + if database_id: + payload["database_id"] = database_id + if sql_content: + payload["sql_content"] = sql_content + + client = self._ensure_client() + response = client.post( + f"{self.base_url}/reset", + json=payload if payload else None, + headers=self._headers(database_id=database_id, context=context), + ) + response.raise_for_status() + return self._parse_step_result(response.json()) + + def reset_with_sql_file( + self, + sql_file_path: str | Path, + database_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> StepResult[CalendarObservation]: + sql_path = Path(sql_file_path).expanduser().resolve() + if not sql_path.exists(): + raise FileNotFoundError(f"SQL file not found: {sql_path}") + sql_content = sql_path.read_text(encoding="utf-8") + return self.reset(database_id=database_id, sql_content=sql_content, context=context) + + def step( + self, + action: CalendarAction | Dict[str, Any], + database_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> StepResult[CalendarObservation]: + if isinstance(action, CalendarAction): + action_payload = action.model_dump(exclude_none=True) + elif isinstance(action, dict): + action_payload = action + else: + raise TypeError("action must be CalendarAction or dict") + + client = self._ensure_client() + response = client.post( + f"{self.base_url}/step", + json=action_payload, + headers=self._headers(database_id=database_id, context=context), + ) + response.raise_for_status() + return self._parse_step_result(response.json()) + + def list_tools( + self, + database_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + result = self.step( + CalendarAction(action_type="ListToolsAction"), + database_id=database_id, + context=context, + ) + return result.observation.tools_list or [] + + def call_tool( + self, + tool_name: str, + arguments: Optional[Dict[str, Any]] = None, + database_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> CalendarObservation: + result = self.step( + CalendarAction( + action_type="ToolCallAction", + tool_name=tool_name, + arguments=arguments or {}, + ), + database_id=database_id, + context=context, + ) + return result.observation + + def state( + self, + verify_queries: Optional[List[str]] = None, + database_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + params = [("verify_queries", q) for q in verify_queries] if verify_queries else None + client = self._ensure_client() + response = client.get( + f"{self.base_url}/state", + params=params, + headers=self._headers(database_id=database_id, context=context), + ) + response.raise_for_status() + return response.json() + + def get_sample_sql(self) -> str: + client = self._ensure_client() + response = client.get(f"{self.base_url}/api/sample-data") + response.raise_for_status() + data = response.json() + sql_content = data.get("sql_content") + if not sql_content: + raise ValueError("sample-data response did not include sql_content") + return sql_content + + def seed_database( + self, + sql_content: str, + database_id: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> str: + db_id = database_id or _generate_database_id() + payload = { + "database_id": db_id, + "sql_content": sql_content, + "name": name or f"Calendar DB {db_id}", + "description": description or "Seeded database", + } + client = self._ensure_client() + response = client.post(f"{self.base_url}/api/seed-database", json=payload) + response.raise_for_status() + return db_id + + def seed_database_from_file( + self, + sql_file_path: str | Path, + database_id: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> str: + sql_path = Path(sql_file_path).expanduser().resolve() + if not sql_path.exists(): + raise FileNotFoundError(f"SQL file not found: {sql_path}") + sql_content = sql_path.read_text(encoding="utf-8") + return self.seed_database( + sql_content=sql_content, + database_id=database_id, + name=name, + description=description, + ) + + def seed_database_from_sample( + self, + database_id: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> str: + sql_content = self.get_sample_sql() + return self.seed_database( + sql_content=sql_content, + database_id=database_id, + name=name, + description=description, + ) + + +@dataclass +class ScenarioConfig: + gym_enviornment_url: str + seed_database_file: str + system_prompt: str + user_prompt: str + llm_model: str + llm_provider: str + llm_api_key: str + database_id: str + access_token: Optional[str] = None + context: Dict[str, Any] = field(default_factory=dict) + execution_mode: str = "openenv" + expected_tools: List[str] = field(default_factory=list) + restricted_tools: List[str] = field(default_factory=list) + verifiers: List[Dict[str, Any]] = field(default_factory=list) + number_of_runs: int = 1 + reset_database_between_runs: bool = True + temperature: float = 0.0 + max_tokens: int = 4096 + max_iterations: int = 20 + seed_mode: str = "reset" + output_dir: Path = DEFAULT_OUTPUT_DIR + + +class LLMClient: + def __init__( + self, + provider: str, + model: str, + api_key: str, + temperature: float = 0.0, + max_tokens: int = 4096, + ): + self.provider = provider.lower() + self.model = model + self.api_key = api_key + self.temperature = temperature + self.max_tokens = max_tokens + self.llm = None + self._initialize_llm() + + def _initialize_llm(self) -> None: + try: + if self.provider == "anthropic": + from langchain_anthropic import ChatAnthropic + + self.llm = ChatAnthropic( + model=self.model, + anthropic_api_key=self.api_key, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + elif self.provider == "openai": + from langchain_openai import ChatOpenAI + + self.llm = ChatOpenAI( + model=self.model, + openai_api_key=self.api_key, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + elif self.provider == "google": + from langchain_google_genai import ChatGoogleGenerativeAI + + self.llm = ChatGoogleGenerativeAI( + model=self.model, + google_api_key=self.api_key, + temperature=self.temperature, + max_tokens=self.max_tokens, + ) + else: + raise ValueError(f"Unsupported LLM provider: {self.provider}") + except ImportError as exc: + raise ImportError( + f"Missing LangChain provider for {self.provider}. " + "Install requirements-client.txt to use scenario runs." + ) from exc + + def _clean_json_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(schema, dict): + return {"type": "object", "properties": {}, "required": []} + + if "oneOf" in schema: + for option in schema["oneOf"]: + if isinstance(option, dict) and option.get("type") == "object": + schema = option + break + else: + return {"type": "object", "properties": {}, "required": []} + + if "allOf" in schema: + merged_schema = {"type": "object", "properties": {}, "required": []} + for sub_schema in schema["allOf"]: + if isinstance(sub_schema, dict): + if "properties" in sub_schema: + merged_schema["properties"].update(sub_schema["properties"]) + if "required" in sub_schema: + merged_schema["required"].extend(sub_schema["required"]) + schema = merged_schema + + if "anyOf" in schema: + for option in schema["anyOf"]: + if isinstance(option, dict) and option.get("type") == "object": + schema = option + break + else: + return {"type": "object", "properties": {}, "required": []} + + schema.setdefault("type", "object") + if schema.get("type") == "object" and "properties" not in schema: + schema["properties"] = {} + return schema + + def _convert_mcp_tools_to_langchain( + self, mcp_tools: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + langchain_tools = [] + for tool in mcp_tools: + input_schema = tool.get( + "inputSchema", {"type": "object", "properties": {}, "required": []} + ) + cleaned_schema = self._clean_json_schema(input_schema) + langchain_tools.append( + { + "type": "function", + "function": { + "name": tool["name"], + "description": tool.get("description", ""), + "parameters": cleaned_schema, + }, + } + ) + return langchain_tools + + async def invoke_with_tools(self, messages: List[Any], tools: List[Dict[str, Any]]) -> Any: + langchain_tools = self._convert_mcp_tools_to_langchain(tools) + llm_with_tools = self.llm.bind_tools(langchain_tools) + return await llm_with_tools.ainvoke(messages) + + +class VerifierEngine: + def __init__(self, client: CalendarEnv, llm_client: LLMClient, execution_mode: str = "openenv"): + self.client = client + self.llm_client = llm_client + self.execution_mode = execution_mode + + async def execute_verifier( + self, + verifier: Dict[str, Any], + model_response: Dict[str, Any], + database_id: str, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + verifier_type = verifier.get("verifier_type") + if verifier_type == "database_state": + return await self._execute_database_state_verifier( + verifier.get("validation_config", {}), + database_id, + context, + ) + if verifier_type == "response_check": + return await self._execute_response_check_verifier( + verifier.get("validation_config", {}), + model_response, + database_id, + context, + ) + if verifier_type == "tool_execution": + return await self._execute_tool_execution_verifier( + verifier.get("validation_config", {}), + model_response, + ) + return {"passed": False, "error": f"Unsupported verifier type: {verifier_type}"} + + async def _execute_database_state_verifier( + self, + validation_config: Dict[str, Any], + database_id: str, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + sql_query = validation_config.get("query") + expected_value = validation_config.get("expected_value") + comparison_type = validation_config.get("comparison_type", "equals") + + if not sql_query: + return {"passed": False, "error": "No SQL query provided"} + + result = self._execute_sql_query(sql_query, database_id, context) + if not result["success"]: + return { + "passed": False, + "error": result.get("error", "SQL query failed"), + "query": sql_query, + } + + actual_value = self._extract_value_from_sql_result(result) + comparison_result = self._compare_values(actual_value, expected_value, comparison_type) + + return { + "passed": comparison_result["passed"], + "expected": expected_value, + "actual": actual_value, + "comparison_type": comparison_type, + "query": sql_query, + "details": comparison_result.get("details"), + } + + async def _execute_response_check_verifier( + self, + validation_config: Dict[str, Any], + model_response: Dict[str, Any], + database_id: str, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + sql_query = validation_config.get("sql_query") + comparison_prompt = validation_config.get("comparison_prompt") + minimum_comparison_value = validation_config.get("minimum_comparison_value", 7) + + if not sql_query or not comparison_prompt: + return {"passed": False, "error": "Missing sql_query or comparison_prompt"} + + sql_result = self._execute_sql_query(sql_query, database_id, context) + if not sql_result["success"]: + return { + "passed": False, + "error": sql_result.get("error", "SQL query failed"), + } + + llm_response_text = self._extract_llm_content(model_response) + return await self._compare_with_llm( + sql_result, + llm_response_text, + comparison_prompt, + minimum_comparison_value, + ) + + async def _execute_tool_execution_verifier( + self, validation_config: Dict[str, Any], model_response: Dict[str, Any] + ) -> Dict[str, Any]: + expected_tools = validation_config.get("expected_tools", []) + minimum_tool_calls = validation_config.get("minimum_tool_calls", 1) + + tools_called = [] + if model_response.get("tool_calls"): + tools_called = [tc.get("name") for tc in model_response["tool_calls"]] + + missing_tools = [tool for tool in expected_tools if tool not in tools_called] + passed = len(missing_tools) == 0 and len(tools_called) >= minimum_tool_calls + + return { + "passed": passed, + "expected_tools": expected_tools, + "tools_called": tools_called, + "missing_tools": missing_tools, + "minimum_tool_calls": minimum_tool_calls, + "actual_tool_calls": len(tools_called), + } + + def _execute_sql_query( + self, + query: str, + database_id: str, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + try: + state_data = self.client.state( + verify_queries=[query], + database_id=database_id, + context=context, + ) + verification_results = state_data.get("verification_results", []) + if not verification_results: + return {"success": False, "error": "No verification results returned"} + + query_result = verification_results[0] + if "error" in query_result: + return {"success": False, "error": query_result.get("error", "Query failed")} + + return {"success": True, "result": query_result.get("result", [])} + except Exception as exc: + return {"success": False, "error": str(exc)} + + def _extract_value_from_sql_result(self, sql_result: Dict[str, Any]) -> Any: + result_data = sql_result.get("result", []) + if isinstance(result_data, list) and result_data: + first_row = result_data[0] + if isinstance(first_row, dict) and len(first_row) == 1: + return next(iter(first_row.values())) + return result_data + + def _compare_values(self, actual: Any, expected: Any, comparison_type: str) -> Dict[str, Any]: + try: + if comparison_type == "equals": + passed = actual == expected + elif comparison_type == "greater_than": + passed = actual > expected + elif comparison_type == "less_than": + passed = actual < expected + elif comparison_type == "contains": + passed = expected in str(actual) + else: + return {"passed": False, "details": f"Unknown comparison type: {comparison_type}"} + + return { + "passed": passed, + "details": f"Comparison {comparison_type}: {actual} vs {expected}", + } + except Exception as exc: + return {"passed": False, "details": f"Comparison error: {exc}"} + + def _extract_llm_content(self, model_response: Dict[str, Any]) -> str: + for key in ("content", "text", "response"): + if key in model_response: + return str(model_response[key]) + return str(model_response) + + async def _compare_with_llm( + self, + sql_result: Dict[str, Any], + llm_response: str, + comparison_prompt: str, + minimum_score: int, + ) -> Dict[str, Any]: + from langchain_core.messages import SystemMessage, HumanMessage + + system_prompt = ( + "You are an evaluator comparing database results with an assistant response. " + "Return only JSON in this format: " + '{"score": , "reasoning": ""}' + ) + + sql_result_str = json.dumps(sql_result.get("result", {}), indent=2) + user_prompt = ( + f"Database result:\n{sql_result_str}\n\n" + f"Assistant response:\n{llm_response}\n\n" + f"Comparison task:\n{comparison_prompt}" + ) + + response = await self.llm_client.invoke_with_tools( + [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)], + [], + ) + + try: + content = response.content + if isinstance(content, list): + content = "".join(str(item) for item in content) + response_text = str(content) + if "```json" in response_text: + response_text = response_text.split("```json")[1].split("```")[0].strip() + judge_result = json.loads(response_text) + score = int(judge_result.get("score", 0)) + return { + "passed": score >= minimum_score, + "score": score, + "reasoning": judge_result.get("reasoning", ""), + } + except Exception as exc: + return {"passed": False, "error": f"Judge parsing failed: {exc}"} + + +class ScenarioRunner: + def __init__(self, config: ScenarioConfig): + self.config = config + self.client = CalendarEnv( + base_url=config.gym_enviornment_url, + database_id=config.database_id, + access_token=config.access_token, + context=config.context, + ) + self.llm_client = LLMClient( + config.llm_provider, + config.llm_model, + config.llm_api_key, + temperature=config.temperature, + max_tokens=config.max_tokens, + ) + self.verifier_engine = VerifierEngine(self.client, self.llm_client, config.execution_mode) + self.available_tools: List[Dict[str, Any]] = [] + self._seed_sql_content: Optional[str] = None + + def _prepare_database(self) -> None: + sql_content = None + seed_file = self.config.seed_database_file + + if seed_file: + sql_path = Path(seed_file).expanduser().resolve() + if not sql_path.exists(): + raise FileNotFoundError(f"SQL file not found: {sql_path}") + sql_content = sql_path.read_text(encoding="utf-8") + self._seed_sql_content = sql_content + + if self.config.seed_mode == "api": + if sql_content is None: + sql_content = self.client.get_sample_sql() + self._seed_sql_content = sql_content + self.client.seed_database( + sql_content=sql_content, + database_id=self.config.database_id, + ) + self.client.reset(database_id=self.config.database_id, sql_content=sql_content) + else: + self.client.reset(database_id=self.config.database_id, sql_content=sql_content) + + async def initialize(self) -> None: + self._prepare_database() + self.available_tools = self.client.list_tools() + if self.config.restricted_tools: + self.available_tools = [ + tool for tool in self.available_tools if tool["name"] not in self.config.restricted_tools + ] + + async def execute_benchmark(self) -> Dict[str, Any]: + await self.initialize() + runs: List[Dict[str, Any]] = [] + + for run_number in range(1, self.config.number_of_runs + 1): + if run_number > 1 and self.config.reset_database_between_runs: + self.client.reset( + database_id=self.config.database_id, + sql_content=self._seed_sql_content, + ) + + run_result = await self.execute_single_run(run_number) + runs.append(run_result) + + statistics = self._calculate_statistics(runs) + return { + "benchmark_config": { + "execution_mode": self.config.execution_mode, + "model": f"{self.config.llm_provider}/{self.config.llm_model}", + "number_of_runs": self.config.number_of_runs, + "user_prompt": self.config.user_prompt, + "database_id": self.config.database_id, + "seed_database_file": self.config.seed_database_file or "", + }, + "runs": runs, + "statistics": statistics, + } + + async def execute_single_run(self, run_number: int) -> Dict[str, Any]: + start_time = datetime.now(timezone.utc) + task_result = await self._execute_task() + verification_results = await self._run_verifiers(task_result) + + execution_time_ms = int( + (datetime.now(timezone.utc) - start_time).total_seconds() * 1000 + ) + overall_success = all(v.get("passed") for v in verification_results.values()) + + total_verifiers = len(verification_results) + passed_verifiers = sum(1 for v in verification_results.values() if v.get("passed", False)) + + return { + "run_number": run_number, + "started_at": start_time.isoformat(), + "execution_time_ms": execution_time_ms, + "model_response": task_result.get("final_response"), + "conversation_flow": task_result.get("conversation_flow", []), + "tools_used": task_result.get("tools_used", []), + "tool_results": task_result.get("tool_results", []), + "verification_results": verification_results, + "verification_summary": { + "total": total_verifiers, + "passed": passed_verifiers, + "failed": total_verifiers - passed_verifiers, + "pass_rate": passed_verifiers / total_verifiers if total_verifiers > 0 else 0.0, + }, + "overall_success": overall_success, + } + + async def _execute_task(self) -> Dict[str, Any]: + from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage + + messages = [ + SystemMessage(content=self.config.system_prompt), + HumanMessage(content=self.config.user_prompt), + ] + + conversation_flow: List[Dict[str, Any]] = [] + tools_used: List[str] = [] + tool_results: List[Dict[str, Any]] = [] + + for iteration in range(self.config.max_iterations): + response = await self.llm_client.invoke_with_tools(messages, self.available_tools) + messages.append(response) + + tool_calls = self._normalize_tool_calls(response) + conversation_flow.append( + {"type": "ai_message", "content": self._normalize_content(response.content), "tool_calls": tool_calls} + ) + + if not tool_calls: + break + + for tool_call in tool_calls: + tool_name = tool_call.get("name") + tool_args = tool_call.get("args", {}) + + observation = self.client.call_tool(tool_name, arguments=tool_args) + tool_result = { + "success": observation.success, + "result": observation.tool_result, + "error_message": observation.error_message, + "metadata": observation.metadata, + } + + if tool_name and tool_name not in tools_used: + tools_used.append(tool_name) + + tool_results.append( + {"tool_name": tool_name, "arguments": tool_args, "result": tool_result} + ) + + tool_message = ToolMessage( + content=json.dumps(tool_result.get("result", {})), + tool_call_id=tool_call.get("id", ""), + ) + messages.append(tool_message) + + conversation_flow.append( + {"type": "tool_result", "tool_name": tool_name, "result": tool_result} + ) + + final_response = self._normalize_content(messages[-1].content) if messages else "" + return { + "final_response": final_response, + "conversation_flow": conversation_flow, + "tools_used": tools_used, + "tool_results": tool_results, + } + + async def _run_verifiers(self, task_result: Dict[str, Any]) -> Dict[str, Any]: + verification_results: Dict[str, Any] = {} + model_response = { + "content": task_result.get("final_response", ""), + "tool_calls": [ + {"name": tr["tool_name"], "args": tr["arguments"]} + for tr in task_result.get("tool_results", []) + ], + } + + for i, verifier in enumerate(self.config.verifiers): + verifier_name = verifier.get("name") or f"verifier_{i + 1}" + verification_results[verifier_name] = await self.verifier_engine.execute_verifier( + verifier, model_response, self.config.database_id, self.config.context + ) + + return verification_results + + def _normalize_tool_calls(self, response: Any) -> List[Dict[str, Any]]: + raw_calls = getattr(response, "tool_calls", None) or [] + normalized = [] + for call in raw_calls: + if isinstance(call, dict): + name = call.get("name") + args = call.get("args", {}) + call_id = call.get("id", "") + else: + name = getattr(call, "name", None) + args = getattr(call, "args", {}) + call_id = getattr(call, "id", "") + + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + + normalized.append({"id": call_id, "name": name, "args": args}) + return normalized + + def _normalize_content(self, content: Any) -> str: + if isinstance(content, list): + return "".join(str(item) for item in content) + return str(content) + + def _calculate_statistics(self, runs: List[Dict[str, Any]]) -> Dict[str, Any]: + successful_runs = [r for r in runs if r.get("overall_success")] + total_runs = len(runs) + overall_success_rate = len(successful_runs) / total_runs if total_runs > 0 else 0.0 + pass_at_1 = 1.0 if runs and runs[0].get("overall_success") else 0.0 + + total_verifiers_count = 0 + passed_verifiers_count = 0 + verifier_pass_rates: Dict[str, Dict[str, int]] = {} + + for run in runs: + summary = run.get("verification_summary") + if summary: + total_verifiers_count += summary.get("total", 0) + passed_verifiers_count += summary.get("passed", 0) + + for verifier_name, result in run.get("verification_results", {}).items(): + if verifier_name not in verifier_pass_rates: + verifier_pass_rates[verifier_name] = {"passed": 0, "total": 0} + verifier_pass_rates[verifier_name]["total"] += 1 + if result.get("passed", False): + verifier_pass_rates[verifier_name]["passed"] += 1 + + verifier_stats = {} + for verifier_name, counts in verifier_pass_rates.items(): + total = counts["total"] + passed = counts["passed"] + verifier_stats[verifier_name] = { + "passed": passed, + "total": total, + "pass_rate": passed / total if total > 0 else 0.0, + } + + verifier_level_pass_rate = ( + passed_verifiers_count / total_verifiers_count if total_verifiers_count > 0 else 0.0 + ) + + execution_times = [r.get("execution_time_ms", 0) for r in runs if "execution_time_ms" in r] + mean_time = sum(execution_times) / len(execution_times) if execution_times else 0.0 + + tool_counts: Dict[str, int] = {} + for run in runs: + for tool in run.get("tools_used", []): + tool_counts[tool] = tool_counts.get(tool, 0) + 1 + + return { + "total_runs": total_runs, + "successful_runs": len(successful_runs), + "overall_success_rate": overall_success_rate, + "pass_at_1": pass_at_1, + "verifier_level_pass_rate": verifier_level_pass_rate, + "total_verifiers_checked": total_verifiers_count, + "total_verifiers_passed": passed_verifiers_count, + "individual_verifier_stats": verifier_stats, + "mean_execution_time_ms": mean_time, + "tool_usage": tool_counts, + } + + +def _resolve_api_key(provider: str, config_key: Optional[str]) -> str: + if config_key: + return config_key + + provider = provider.lower() + env_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + } + key = os.getenv(env_map.get(provider, "LLM_API_KEY")) or os.getenv("LLM_API_KEY") + if not key: + raise ValueError("LLM API key is required for scenario runs") + return key + + +def _load_scenario_config( + config_path: str, + base_url_override: Optional[str], + seed_sql_override: Optional[str], + database_id_override: Optional[str], + auto_db: bool, + seed_mode_override: Optional[str], + output_dir: Optional[str], + access_token_override: Optional[str], +) -> ScenarioConfig: + path = Path(config_path).expanduser().resolve() + if not path.exists(): + raise FileNotFoundError(f"Scenario config not found: {path}") + + with path.open("r", encoding="utf-8") as file: + config_data = json.load(file) + + config_data = {k: v for k, v in config_data.items() if not str(k).startswith("_")} + + base_url = ( + base_url_override + or config_data.get("gym_enviornment_url") + or config_data.get("gym_environment_url") + or config_data.get("base_url") + ) + if not base_url: + raise ValueError("Missing gym_enviornment_url in scenario config") + + seed_database_file = seed_sql_override if seed_sql_override is not None else config_data.get("seed_database_file", "") + seed_mode = seed_mode_override or config_data.get("seed_mode", "reset") + if seed_mode not in {"reset", "api"}: + raise ValueError(f"Invalid seed_mode: {seed_mode}") + + required_fields = ["system_prompt", "user_prompt", "llm_provider", "llm_model"] + missing = [field for field in required_fields if not config_data.get(field)] + if missing: + raise ValueError(f"Missing required fields in scenario config: {', '.join(missing)}") + + execution_mode = config_data.get("execution_mode", "openenv") + if execution_mode != "openenv": + raise ValueError("Only execution_mode 'openenv' is supported by this client") + + database_id = database_id_override or config_data.get("database_id") + if auto_db: + database_id = _generate_database_id() + if not database_id: + database_id = "default" + + access_token = access_token_override or config_data.get("access_token") + + llm_provider = config_data.get("llm_provider") + llm_model = config_data.get("llm_model") + llm_api_key = _resolve_api_key(llm_provider, config_data.get("llm_api_key")) + + output_path = Path(output_dir).expanduser().resolve() if output_dir else DEFAULT_OUTPUT_DIR + if not output_path.is_absolute(): + output_path = Path(__file__).resolve().parent / output_path + + return ScenarioConfig( + gym_enviornment_url=base_url, + seed_database_file=seed_database_file or "", + system_prompt=config_data.get("system_prompt", ""), + user_prompt=config_data.get("user_prompt", ""), + llm_model=llm_model, + llm_provider=llm_provider, + llm_api_key=llm_api_key, + database_id=database_id, + access_token=access_token, + context=config_data.get("context", {}), + execution_mode=execution_mode, + expected_tools=config_data.get("expected_tools", []) or [], + restricted_tools=config_data.get("restricted_tools", []) or [], + verifiers=config_data.get("verifiers", []) or [], + number_of_runs=config_data.get("number_of_runs", 1), + reset_database_between_runs=config_data.get("reset_database_between_runs", True), + temperature=config_data.get("temperature", 0.0), + max_tokens=config_data.get("max_tokens", 4096), + max_iterations=config_data.get("max_iterations", 20), + seed_mode=seed_mode, + output_dir=output_path, + ) + + +def _write_scenario_output(result: Dict[str, Any], output_dir: Path) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + filename = f"benchmark_results_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json" + output_path = output_dir / filename + with output_path.open("w", encoding="utf-8") as file: + json.dump(result, file, indent=2, default=str) + return output_path + + +def _parse_json_arg(value: str) -> Dict[str, Any]: + if not value: + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON value: {value}") from exc + if not isinstance(parsed, dict): + raise ValueError("JSON value must be an object") + return parsed + + +def main() -> None: + parser = argparse.ArgumentParser(description="Calendar environment HTTP client") + parser.add_argument("--base-url", default="http://localhost:8004") + parser.add_argument("--database-id", default="default") + parser.add_argument("--auto-db", action="store_true") + parser.add_argument("--access-token", default=None) + parser.add_argument("--context", default="{}") + parser.add_argument("--seed-sql", default=None) + parser.add_argument("--seed-mode", choices=["reset", "api"], default=None) + parser.add_argument("--tool", default=None) + parser.add_argument("--args", default="{}") + parser.add_argument("--scenario", default=None) + parser.add_argument("--output-dir", default=None) + args = parser.parse_args() + + context = _parse_json_arg(args.context) + + if args.scenario: + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + config = _load_scenario_config( + config_path=args.scenario, + base_url_override=args.base_url, + seed_sql_override=args.seed_sql, + database_id_override=args.database_id, + auto_db=args.auto_db, + seed_mode_override=args.seed_mode, + output_dir=args.output_dir, + access_token_override=args.access_token, + ) + + async def _run() -> None: + runner = ScenarioRunner(config) + result = await runner.execute_benchmark() + output_path = _write_scenario_output(result, config.output_dir) + logger.info("Scenario results saved to %s", output_path) + + asyncio.run(_run()) + return + + database_id = _generate_database_id() if args.auto_db else args.database_id + + with CalendarEnv( + base_url=args.base_url, + database_id=database_id, + access_token=args.access_token, + context=context, + ) as client: + seed_mode = args.seed_mode or "reset" + if args.seed_sql: + if seed_mode == "api": + client.seed_database_from_file( + sql_file_path=args.seed_sql, + database_id=database_id, + ) + client.reset_with_sql_file( + sql_file_path=args.seed_sql, + database_id=database_id, + ) + else: + client.reset_with_sql_file( + sql_file_path=args.seed_sql, + database_id=database_id, + ) + else: + client.reset(database_id=database_id) + + tools = client.list_tools() + print(f"tools: {len(tools)}") + + if args.tool: + tool_args = _parse_json_arg(args.args) + observation = client.call_tool(args.tool, arguments=tool_args) + print(observation.model_dump()) + + +if __name__ == "__main__": + main() diff --git a/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb b/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb new file mode 100644 index 000000000..e3ebe1fe7 --- /dev/null +++ b/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb @@ -0,0 +1,3133 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "2qFCLLUZ8E_T" + }, + "source": [ + "# OpenEnv Benchmark Executor\n", + "\n", + "## Eval script for OpenEnv & MCP Environments\n", + "\n", + "**Last Updated:** January 2026 \n", + "**Prepared by:** Turing\n", + "\n", + "---\n", + "\n", + "### Overview\n", + "\n", + "This notebook provides a **self-contained, production-ready eval system** for evaluating AI agents interacting with OpenEnv or MCP gym environments.\n", + "\n", + "### Key Features\n", + "\n", + "- **Dual Protocol Support** - OpenEnv REST API + MCP JSON-RPC\n", + "- **Multi-LLM Support** - Anthropic, OpenAI, Google via LangChain\n", + "- **Flexible Database Seeding** - Use custom .sql files or gym's api/sample-data\n", + "- **Multiple Verifier Types** - database_state, response_check\n", + "- **Multi-Run Statistics** - Pass@n, success rates, performance metrics\n", + "\n", + "### Execution Modes\n", + "\n", + "- **OpenEnv Mode** (`execution_mode: \"openenv\"`) - REST API with /reset, /step, /state endpoints\n", + "- **MCP Mode** (`execution_mode: \"mcp\"`) - JSON-RPC 2.0 protocol with session management\n", + "\n", + "### Database Seeding\n", + "\n", + "- **Custom SQL File**: Set `seed_database_file: \"path/to/file.sql\"` to seed from your own SQL file\n", + "- **Gym Sample Data**: Set `seed_database_file: \"\"` (empty) to use the gym's built-in api/sample-data\n", + "\n", + "### Installation\n", + "\n", + "Run the next cell to install dependencies." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "fB24I46k-S8Y" + }, + "source": [ + "## Prerequisites for Running on Google Colab\n", + "\n", + "### Step 1: Setup MCP Gym Server Locally\n", + "\n", + "1. **Download the Openenv gym**\n", + " - Download the openenv gym environemnt `calendar_env` folder\n", + "\n", + "2. **Run the gym environment Locally**\n", + " ### Docker (Recommended)\n", + " ```bash\n", + " cd calendar_env/server\n", + " docker build -t calendar-env:latest .\n", + " docker run --rm -p 8004:8004 calendar-env:latest\n", + " curl http://localhost:8004/health\n", + " ```\n", + " On Server health success response will be:\n", + " `{\"status\":\"healthy\",\"service\":\"calendar-env\"}`\n", + "\n", + " ### Without Docker\n", + "\n", + " ```bash\n", + " cd calendar_env\n", + " python3 -m venv venv\n", + " source venv/bin/activate\n", + " pip install -r requirements.txt\n", + " cd server\n", + " uvicorn main:app --host 0.0.0.0 --port 8004 --reload\n", + " ```\n", + "\n", + "3. **Install Cloudflare Tunnel** (If you want to run this notebook on colab)\n", + " ```bash\n", + " # Install cloudflared\n", + " # macOS\n", + " brew install cloudflare/cloudflare/cloudflared\n", + " \n", + " # Linux\n", + " wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb\n", + " sudo dpkg -i cloudflared-linux-amd64.deb\n", + " \n", + " # Windows\n", + " # Download from: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/\n", + " ```\n", + "\n", + "4. **Expose Server with Cloudflare Tunnel**\n", + " ```bash\n", + " cloudflared tunnel --url http://localhost:8004\n", + " ```\n", + " \n", + " This will output something like:\n", + " ```\n", + " +--------------------------------------------------------------------------------------------+\n", + " | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |\n", + " | https://randomly-generated-subdomain.trycloudflare.com |\n", + " +--------------------------------------------------------------------------------------------+\n", + " ```\n", + "\n", + "5. **Copy the HTTPS URL**\n", + " - Copy the `https://` URL from the terminal output\n", + " - Use this URL in the configuration in the below cell number 17: for the value of `gym_enviornment_url`\n", + "\n", + "### Step 2: Database Seeding Configuration\n", + "\n", + "The notebook supports two ways to seed the database:\n", + "\n", + "\n", + "#### Option A: Use Gym's Sample Data in below cell number 17 (Recommended)\n", + "```python\n", + "CONFIG = {\n", + " ...\n", + " \"seed_database_file\": \"\", # Leave empty for default database\n", + " ...\n", + "}\n", + "```\n", + "\n", + "#### Option B: Use Custom SQL File in below cell number 17 (To create custom data based on gym's tool schema)\n", + "```python\n", + "CONFIG = {\n", + " ...\n", + " \"seed_database_file\": \"path/to/your/custom_seed.sql\", # Provide SQL file path\n", + " ...\n", + "}\n", + "```\n", + "Download sample sql file from the github and upload in the notebook's folder section. Then copy the path and assign the path to `seed_database_file`.\n", + "- If you provide a `seed_database_file` path, the notebook will:\n", + " - Read the SQL content from your custom file\n", + " - Create the database with your custom SQL schema and data\n", + "\n", + "### Step 3: Configure LLM provider and API key\n", + "\n", + "1. **Set API Keys in Colab Secrets**\n", + " - Click the 'key' icon in the left sidebar\n", + " - Add your LLM API key:\n", + " - `LLM_API_KEY`\n", + "\n", + "2. **Update Configuration (Cell 5)**\n", + " - Configure your LLM provider and model in below cell number 17\n", + " - Add any context headers needed (e.g., `x-access-token`)\n", + "\n", + "### Example Configuration for Colab\n", + "\n", + "```python\n", + "from google.colab import userdata\n", + "\n", + "CONFIG = {\n", + " \"execution_mode\": \"openenv\",\n", + " \"gym_enviornment_url\": \"https://your-tunnel-subdomain.trycloudflare.com\",\n", + " \"mcp_endpoint\": \"/mcp\",\n", + " \"seed_database_file\": \"\", # Empty = use gym's sample data, or provide path\n", + " \n", + " # LLM Configuration\n", + " \"llm_provider\": \"anthropic\",\n", + " \"llm_model\": \"claude-sonnet-4-5\",\n", + " \"llm_api_key\": userdata.get('ANTHROPIC_API_KEY'),\n", + " \n", + " # Context headers (if needed)\n", + " \"context\": {\n", + " \"x-access-token\": \"your-access-token-here\", # Already has x- prefix\n", + " \"actingUserId\": \"alice_manager\" # Will become x-actinguserid\n", + " },\n", + " ...\n", + "}\n", + "```\n", + "\n", + "\n", + "### Run Collab code snippets by clicking on the Run all option in the menu section.\n", + "\n", + "### The final run result's .json file will be created in the folder section of the collab notebook.\n", + "\n", + "### Important Notes\n", + "\n", + "- **Tunnel Stability**: Cloudflare tunnels are temporary. If disconnected, you'll need to restart the tunnel and get a new URL\n", + "- **Custom SQL Files**: When using custom SQL files on Colab, upload them first or mount Google Drive\n", + "- **Database Cleanup**: The notebook automatically creates and deletes databases, so no manual cleanup needed\n", + "\n", + "---" + ] + }, + { + "cell_type": "code", + "execution_count": 74, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "NiPAfaaM8E_V", + "outputId": "ed383032-50a0-4a48-8b28-73206d8dbe56" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: httpx in /usr/local/lib/python3.12/dist-packages (0.28.1)\n", + "Requirement already satisfied: langchain in /usr/local/lib/python3.12/dist-packages (1.2.4)\n", + "Requirement already satisfied: langchain-core in /usr/local/lib/python3.12/dist-packages (1.2.7)\n", + "Requirement already satisfied: langchain-anthropic in /usr/local/lib/python3.12/dist-packages (1.3.1)\n", + "Requirement already satisfied: langchain-openai in /usr/local/lib/python3.12/dist-packages (1.1.7)\n", + "Requirement already satisfied: langchain-google-genai in /usr/local/lib/python3.12/dist-packages (4.2.0)\n", + "Requirement already satisfied: anyio in /usr/local/lib/python3.12/dist-packages (from httpx) (4.12.1)\n", + "Requirement already satisfied: certifi in /usr/local/lib/python3.12/dist-packages (from httpx) (2026.1.4)\n", + "Requirement already satisfied: httpcore==1.* in /usr/local/lib/python3.12/dist-packages (from httpx) (1.0.9)\n", + "Requirement already satisfied: idna in /usr/local/lib/python3.12/dist-packages (from httpx) (3.11)\n", + "Requirement already satisfied: h11>=0.16 in /usr/local/lib/python3.12/dist-packages (from httpcore==1.*->httpx) (0.16.0)\n", + "Requirement already satisfied: langgraph<1.1.0,>=1.0.2 in /usr/local/lib/python3.12/dist-packages (from langchain) (1.0.6)\n", + "Requirement already satisfied: pydantic<3.0.0,>=2.7.4 in /usr/local/lib/python3.12/dist-packages (from langchain) (2.12.3)\n", + "Requirement already satisfied: jsonpatch<2.0.0,>=1.33.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (1.33)\n", + "Requirement already satisfied: langsmith<1.0.0,>=0.3.45 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (0.6.4)\n", + "Requirement already satisfied: packaging<26.0.0,>=23.2.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (25.0)\n", + "Requirement already satisfied: pyyaml<7.0.0,>=5.3.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (6.0.3)\n", + "Requirement already satisfied: tenacity!=8.4.0,<10.0.0,>=8.1.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (9.1.2)\n", + "Requirement already satisfied: typing-extensions<5.0.0,>=4.7.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (4.15.0)\n", + "Requirement already satisfied: uuid-utils<1.0,>=0.12.0 in /usr/local/lib/python3.12/dist-packages (from langchain-core) (0.13.0)\n", + "Requirement already satisfied: anthropic<1.0.0,>=0.75.0 in /usr/local/lib/python3.12/dist-packages (from langchain-anthropic) (0.76.0)\n", + "Requirement already satisfied: openai<3.0.0,>=1.109.1 in /usr/local/lib/python3.12/dist-packages (from langchain-openai) (2.15.0)\n", + "Requirement already satisfied: tiktoken<1.0.0,>=0.7.0 in /usr/local/lib/python3.12/dist-packages (from langchain-openai) (0.12.0)\n", + "Requirement already satisfied: filetype<2.0.0,>=1.2.0 in /usr/local/lib/python3.12/dist-packages (from langchain-google-genai) (1.2.0)\n", + "Requirement already satisfied: google-genai<2.0.0,>=1.56.0 in /usr/local/lib/python3.12/dist-packages (from langchain-google-genai) (1.60.0)\n", + "Requirement already satisfied: distro<2,>=1.7.0 in /usr/local/lib/python3.12/dist-packages (from anthropic<1.0.0,>=0.75.0->langchain-anthropic) (1.9.0)\n", + "Requirement already satisfied: docstring-parser<1,>=0.15 in /usr/local/lib/python3.12/dist-packages (from anthropic<1.0.0,>=0.75.0->langchain-anthropic) (0.17.0)\n", + "Requirement already satisfied: jiter<1,>=0.4.0 in /usr/local/lib/python3.12/dist-packages (from anthropic<1.0.0,>=0.75.0->langchain-anthropic) (0.12.0)\n", + "Requirement already satisfied: sniffio in /usr/local/lib/python3.12/dist-packages (from anthropic<1.0.0,>=0.75.0->langchain-anthropic) (1.3.1)\n", + "Requirement already satisfied: google-auth<3.0.0,>=2.47.0 in /usr/local/lib/python3.12/dist-packages (from google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (2.48.0)\n", + "Requirement already satisfied: requests<3.0.0,>=2.28.1 in /usr/local/lib/python3.12/dist-packages (from google-genai<2.0.0,>=1.56.0->langchain-google-genai) (2.32.4)\n", + "Requirement already satisfied: websockets<15.1.0,>=13.0.0 in /usr/local/lib/python3.12/dist-packages (from google-genai<2.0.0,>=1.56.0->langchain-google-genai) (15.0.1)\n", + "Requirement already satisfied: jsonpointer>=1.9 in /usr/local/lib/python3.12/dist-packages (from jsonpatch<2.0.0,>=1.33.0->langchain-core) (3.0.0)\n", + "Requirement already satisfied: langgraph-checkpoint<5.0.0,>=2.1.0 in /usr/local/lib/python3.12/dist-packages (from langgraph<1.1.0,>=1.0.2->langchain) (4.0.0)\n", + "Requirement already satisfied: langgraph-prebuilt<1.1.0,>=1.0.2 in /usr/local/lib/python3.12/dist-packages (from langgraph<1.1.0,>=1.0.2->langchain) (1.0.6)\n", + "Requirement already satisfied: langgraph-sdk<0.4.0,>=0.3.0 in /usr/local/lib/python3.12/dist-packages (from langgraph<1.1.0,>=1.0.2->langchain) (0.3.3)\n", + "Requirement already satisfied: xxhash>=3.5.0 in /usr/local/lib/python3.12/dist-packages (from langgraph<1.1.0,>=1.0.2->langchain) (3.6.0)\n", + "Requirement already satisfied: orjson>=3.9.14 in /usr/local/lib/python3.12/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core) (3.11.5)\n", + "Requirement already satisfied: requests-toolbelt>=1.0.0 in /usr/local/lib/python3.12/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core) (1.0.0)\n", + "Requirement already satisfied: zstandard>=0.23.0 in /usr/local/lib/python3.12/dist-packages (from langsmith<1.0.0,>=0.3.45->langchain-core) (0.25.0)\n", + "Requirement already satisfied: tqdm>4 in /usr/local/lib/python3.12/dist-packages (from openai<3.0.0,>=1.109.1->langchain-openai) (4.67.1)\n", + "Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.12/dist-packages (from pydantic<3.0.0,>=2.7.4->langchain) (0.7.0)\n", + "Requirement already satisfied: pydantic-core==2.41.4 in /usr/local/lib/python3.12/dist-packages (from pydantic<3.0.0,>=2.7.4->langchain) (2.41.4)\n", + "Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.12/dist-packages (from pydantic<3.0.0,>=2.7.4->langchain) (0.4.2)\n", + "Requirement already satisfied: regex>=2022.1.18 in /usr/local/lib/python3.12/dist-packages (from tiktoken<1.0.0,>=0.7.0->langchain-openai) (2025.11.3)\n", + "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/lib/python3.12/dist-packages (from google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (0.4.2)\n", + "Requirement already satisfied: cryptography>=38.0.3 in /usr/local/lib/python3.12/dist-packages (from google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (43.0.3)\n", + "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/lib/python3.12/dist-packages (from google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (4.9.1)\n", + "Requirement already satisfied: ormsgpack>=1.12.0 in /usr/local/lib/python3.12/dist-packages (from langgraph-checkpoint<5.0.0,>=2.1.0->langgraph<1.1.0,>=1.0.2->langchain) (1.12.1)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests<3.0.0,>=2.28.1->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (3.4.4)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests<3.0.0,>=2.28.1->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (2.5.0)\n", + "Requirement already satisfied: cffi>=1.12 in /usr/local/lib/python3.12/dist-packages (from cryptography>=38.0.3->google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (2.0.0)\n", + "Requirement already satisfied: pyasn1<0.7.0,>=0.6.1 in /usr/local/lib/python3.12/dist-packages (from pyasn1-modules>=0.2.1->google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (0.6.2)\n", + "Requirement already satisfied: pycparser in /usr/local/lib/python3.12/dist-packages (from cffi>=1.12->cryptography>=38.0.3->google-auth<3.0.0,>=2.47.0->google-auth[requests]<3.0.0,>=2.47.0->google-genai<2.0.0,>=1.56.0->langchain-google-genai) (2.23)\n", + "Dependencies ready\n" + ] + } + ], + "source": [ + "# Install required packages (uncomment if needed)\n", + "!pip install httpx langchain langchain-core langchain-anthropic langchain-openai langchain-google-genai\n", + "\n", + "print(\"Dependencies ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "LAuxddul8E_W" + }, + "source": [ + "## Configuration Settings\n", + "\n", + "Configure your benchmark run by setting the parameters below:\n", + "\n", + "- `execution_mode`: \"openenv\" (REST API) or \"mcp\" (JSON-RPC)\n", + "- `gym_enviornment_url`: URL of your Gym server\n", + "- `seed_database_file`: Path to .sql file for database seeding (empty = use gym's api/sample-data)\n", + "- `system_prompt` & `user_prompt`: Instructions for the AI agent\n", + "- `llm_model`, `llm_provider`, `llm_api_key`: LLM configuration\n", + "- `expected_tools`: List of tools the agent should use (optional, for verification)\n", + "- `restricted_tools`: Tools the agent should NOT use (optional)\n", + "- `verifiers`: List of verifier configurations to validate agent behavior\n", + "- `number_of_runs`: How many times to run the benchmark (for statistical analysis)\n", + "- `reset_database_between_runs`: Whether to reset database between runs (default: true)\n", + "- `context`: Additional context information passed to Gym server via headers" + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "metadata": { + "id": "PVBcjH1z8E_X" + }, + "outputs": [], + "source": [ + "import json\n", + "import asyncio\n", + "import httpx\n", + "import logging\n", + "import sys\n", + "import time\n", + "import random\n", + "import string\n", + "from typing import Dict, Any, List, Optional, Tuple\n", + "from datetime import datetime, timezone\n", + "from dataclasses import dataclass, asdict\n", + "from enum import Enum\n", + "import os\n", + "from google.colab import userdata\n", + "\n", + "logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\n", + "logger = logging.getLogger(__name__)\n", + "\n", + "LLM_API_KEY = userdata.get('LLM_API_KEY')" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "FPe7R4X28E_Y", + "outputId": "ed96a6f8-cca5-43c2-daa9-aac26d9dae26" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Configuration loaded\n" + ] + } + ], + "source": [ + "# BENCHMARK CONFIGURATION (Update The Bellow JSON According to your requinments)\n", + "CONFIG = {\n", + " \"execution_mode\": \"openenv\", # \"openenv\" or \"mcp\"\n", + " \"gym_enviornment_url\": \"https://correspondence-water-sentence-requested.trycloudflare.com\",\n", + " \"mcp_endpoint\": \"/mcp\",\n", + " \"seed_database_file\": \"/content/seed-db.sql\", # Path to .sql file (empty = use gym's api/sample-data)\n", + " \"llm_provider\": \"openai\",\n", + " \"llm_model\": \"gpt-5.2\",\n", + " \"llm_api_key\": LLM_API_KEY,\n", + " \"temperature\": 0.0,\n", + " \"max_tokens\": 4096,\n", + " \"system_prompt\": \"You are a Google Calendar automation agent with full administrative permissions to manage users, meetings, recordings, and messages. Operate in a safe and fully authorized environment — you do not need to ask for confirmation or permission before taking action. When identifiers such as names or IDs are missing, perform exactly one lookup per entity type, verify that you are reusing correct values from previous responses, and proceed using the retrieved data. Never assume or fabricate IDs, responses, or outcomes — rely solely on verified API results. Complete each task in a single, logical, and efficient execution flow.\",\n", + " \"user_prompt\": \"Help me tidy up my calendars for Q4? First, make sure my Project Management calendar is showing and selected. Then find my \\\"Sprint Planning & Architecture Review\\\" meeting and move the latest one to my main calendar so it's easier to track. Update its description to something like \\\"Updated for Q4 planning with new architecture goals and sprint alignment.\\\"\\nAfter that, add a new event called \\\"Q4 Initiative Kickoff – Engineering & UX\\\" to the Project Management calendar for next Thursday from 10AM-12PM. That should cover everything I need!\",\n", + " \"number_of_runs\": 2,\n", + " \"reset_database_between_runs\": True,\n", + " \"context\": {\n", + " \"actingUserId\": \"alice_manager\",\n", + " \"actingUserEmail\": \"alice.manager@techcorp.com\",\n", + " \"defaultTimeZone\": \"America/New_York\",\n", + " \"x-access-token\": \"ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2.qwErTyUIopASDfGhJkLzXcVbNm12_34-56\"\n", + " },\n", + " \"auth_config\": None,\n", + " \"expected_tools\": [\n", + " \"get_calendar_list\",\n", + " \"list_events\",\n", + " \"update_calendar_in_list\",\n", + " \"move_event\",\n", + " \"patch_event\",\n", + " \"create_event\",\n", + " \"delete_event\"\n", + " ],\n", + " \"restricted_tools\": [],\n", + " \"verifiers\": [\n", + " {\n", + " \"verifier_type\": \"database_state\",\n", + " \"name\": \"Project Calendar Visible\",\n", + " \"description\": \"Ensures Alice's Project Management calendar is visible and selected.\",\n", + " \"validation_config\": {\n", + " \"query\": \"SELECT COUNT(*) FROM calendars WHERE calendar_id='alice-projects' AND hidden=0 AND selected=1;\",\n", + " \"expected_value\": 1,\n", + " \"comparison_type\": \"equals\"\n", + " }\n", + " },\n", + " {\n", + " \"verifier_type\": \"database_state\",\n", + " \"name\": \"Event Moved to Primary\",\n", + " \"description\": \"Checks that the Sprint Planning event is on the primary calendar.\",\n", + " \"validation_config\": {\n", + " \"query\": \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-primary';\",\n", + " \"expected_value\": 1,\n", + " \"comparison_type\": \"equals\"\n", + " }\n", + " },\n", + " {\n", + " \"verifier_type\": \"database_state\",\n", + " \"name\": \"Event Description Updated\",\n", + " \"description\": \"Ensures the event description was updated to the new Q4 wording .\",\n", + " \"validation_config\": {\n", + " \"query\": \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND description LIKE '%Q4%' AND description LIKE '%planning%' AND description LIKE '%architecture%' \",\n", + " \"expected_value\": 1,\n", + " \"comparison_type\": \"equals\"\n", + " }\n", + " },\n", + " {\n", + " \"verifier_type\": \"database_state\",\n", + " \"name\": \"Kickoff Event Created\",\n", + " \"description\": \"Ensures that the new Q4 kickoff event was created on Project Management.\",\n", + " \"validation_config\": {\n", + " \"query\": \"SELECT COUNT(*) FROM events WHERE summary='Q4 Initiative Kickoff – Engineering & UX' AND calendar_id='alice-projects' AND start_datetime LIKE '%2026-01-15 10:00%'\\nAND end_datetime LIKE '%2026-01-15 12:00:%'AND status='confirmed'\",\n", + " \"expected_value\": 1,\n", + " \"comparison_type\": \"equals\"\n", + " }\n", + " },\n", + " {\n", + " \"verifier_type\": \"database_state\",\n", + " \"name\": \"Verify Old mapping removed\",\n", + " \"description\": \"Verify Old mapping removed\",\n", + " \"validation_config\": {\n", + " \"query\": \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-projects';\",\n", + " \"expected_value\": 0,\n", + " \"comparison_type\": \"equals\"\n", + " }\n", + " }\n", + " ]\n", + "}\n", + "\n", + "print(\"Configuration loaded\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "CbdPdjX08E_Y" + }, + "source": [ + "## Complete Implementation\n", + "\n", + "The following cell contains the full implementation of all classes and functions." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "4LOv7xdE8E_Z" + }, + "source": [ + "## 3. Data Models\n", + "\n", + "Define all data structures and enums used throughout the benchmark system." + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "o7DDlwub8E_Z", + "outputId": "3c1de56a-0b9d-41e7-956d-328a3faea436" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Data models defined\n" + ] + } + ], + "source": [ + "# ============================================================================\n", + "# DATA MODELS\n", + "# ============================================================================\n", + "\n", + "class VerifierType(str, Enum):\n", + " DATABASE_STATE = \"database_state\"\n", + " RESPONSE_CHECKER = \"response_check\"\n", + " TOOL_EXECUTION = \"tool_execution\"\n", + "\n", + "\n", + "@dataclass\n", + "class MCPToolCall:\n", + " \"\"\"Represents an MCP tool call\"\"\"\n", + " tool_name: str\n", + " arguments: Dict[str, Any]\n", + "\n", + "\n", + "@dataclass\n", + "class MCPToolResponse:\n", + " \"\"\"Represents an MCP tool response\"\"\"\n", + " success: bool\n", + " result: Any = None\n", + " error: Optional[str] = None\n", + "\n", + "\n", + "@dataclass\n", + "class VerifierConfig:\n", + " \"\"\"Configuration for a verifier\"\"\"\n", + " verifier_type: str\n", + " validation_config: Dict[str, Any]\n", + " name: Optional[str] = None\n", + " description: Optional[str] = None\n", + "\n", + "\n", + "@dataclass\n", + "class BenchmarkConfig:\n", + " \"\"\"Complete benchmark configuration\"\"\"\n", + " gym_enviornment_url: str\n", + " mcp_endpoint: str\n", + " seed_database_file: str # Path to .sql file (empty = use gym's api/sample-data)\n", + " system_prompt: str\n", + " user_prompt: str\n", + " llm_model: str\n", + " llm_provider: str # \"anthropic\", \"openai\", \"google\"\n", + " llm_api_key: str\n", + " verifiers: List[Dict[str, Any]]\n", + " number_of_runs: int\n", + " context: Dict[str, Any]\n", + " execution_mode: str = \"openenv\" # \"openenv\" or \"mcp\"\n", + " expected_tools: Optional[List[str]] = None\n", + " restricted_tools: Optional[List[str]] = None\n", + " temperature: float = 0.0\n", + " max_tokens: int = 4096\n", + " reset_database_between_runs: bool = True\n", + " auth_config: Optional[Dict[str, Any]] = None\n", + "\n", + "print(\"Data models defined\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "2xipsolO8E_a" + }, + "source": [ + "## 4. Database Management Functions\n", + "\n", + "Functions for automatically creating and deleting test databases from the Gym server." + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "metadata": { + "id": "xWtUwkMN8E_a" + }, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# DATABASE MANAGEMENT\n", + "# ============================================================================\n", + "\n", + "def create_database_from_file(gym_url: str, sql_file_path: str, config_dir: str = \"\") -> Optional[str]:\n", + " \"\"\"Create a new database from a SQL file and return database_id.\"\"\"\n", + " try:\n", + " # Generate unique database_id\n", + " timestamp = int(time.time() * 1000)\n", + " suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=9))\n", + " database_id = f\"db_{timestamp}_{suffix}\"\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + "\n", + " # Read SQL file\n", + " if not os.path.isabs(sql_file_path):\n", + " sql_file_path = os.path.join(config_dir, sql_file_path) if config_dir else sql_file_path\n", + "\n", + " logger.info(f\"Reading SQL file: {sql_file_path}\")\n", + " if not os.path.exists(sql_file_path):\n", + " logger.error(f\"SQL file not found: {sql_file_path}\")\n", + " return None\n", + "\n", + " with open(sql_file_path, 'r', encoding='utf-8') as f:\n", + " sql_content = f.read()\n", + "\n", + " logger.info(f\"SQL size: {len(sql_content) / 1024:.2f} KB\")\n", + "\n", + " # Create database\n", + " db_name = f\"Auto DB {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n", + " logger.info(f\"Creating database '{db_name}'...\")\n", + " payload = {\n", + " \"database_id\": database_id,\n", + " \"name\": db_name,\n", + " \"description\": f\"Created from {os.path.basename(sql_file_path)}\",\n", + " \"sql_content\": sql_content\n", + " }\n", + "\n", + " timeout = max(120, int(120 + len(sql_content) / 102400))\n", + " with httpx.Client(timeout=timeout) as client:\n", + " response = client.post(f\"{gym_url}/api/seed-database\", headers=headers, json=payload)\n", + " response.raise_for_status()\n", + "\n", + " logger.info(f\"Database created from file: {database_id}\")\n", + " return database_id\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"Error creating database from file: {e}\")\n", + " return None\n", + "\n", + "\n", + "def create_database(gym_url: str) -> Optional[str]:\n", + " \"\"\"Create a new database from Gym server sample SQL and return database_id.\"\"\"\n", + " try:\n", + " # Generate unique database_id\n", + " timestamp = int(time.time() * 1000)\n", + " suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=9))\n", + " database_id = f\"db_{timestamp}_{suffix}\"\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + "\n", + " # Fetch sample SQL from Gym server\n", + " logger.info(f\"Fetching sample SQL from {gym_url}...\")\n", + " with httpx.Client(timeout=60) as client:\n", + " response = client.get(f\"{gym_url}/api/sample-data\", headers=headers)\n", + " response.raise_for_status()\n", + " data = response.json()\n", + "\n", + " if isinstance(data, dict) and 'sql_content' in data:\n", + " sql_content = data['sql_content']\n", + " elif isinstance(data, dict) and 'text' in data:\n", + " sql_content = data['text']\n", + " elif isinstance(data, str):\n", + " sql_content = data\n", + " else:\n", + " raise ValueError(f\"Could not extract SQL content from response\")\n", + "\n", + " logger.info(f\"SQL size: {len(sql_content) / 1024:.2f} KB\")\n", + "\n", + " # Create database\n", + " db_name = f\"Auto DB {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n", + " logger.info(f\"Creating database '{db_name}'...\")\n", + " payload = {\n", + " \"database_id\": database_id,\n", + " \"name\": db_name,\n", + " \"description\": \"Auto-created from Gym server\",\n", + " \"sql_content\": sql_content\n", + " }\n", + "\n", + " timeout = max(120, int(120 + len(sql_content) / 102400))\n", + " with httpx.Client(timeout=timeout) as client:\n", + " response = client.post(f\"{gym_url}/api/seed-database\", headers=headers, json=payload)\n", + " response.raise_for_status()\n", + "\n", + " logger.info(f\"Database created: {database_id}\")\n", + " return database_id\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"Error creating database: {e}\")\n", + " return None\n", + "\n", + "\n", + "def delete_database(gym_url: str, database_id: str) -> bool:\n", + " \"\"\"Delete a database from the Gym server.\"\"\"\n", + " try:\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + " payload = {\"database_id\": database_id}\n", + "\n", + " logger.info(f\"Deleting database: {database_id}...\")\n", + "\n", + " with httpx.Client(timeout=30) as client:\n", + " response = client.request(\"DELETE\", f\"{gym_url}/api/delete-database\", headers=headers, json=payload)\n", + "\n", + " # Handle servers that don't have this API\n", + " if response.status_code == 404:\n", + " logger.warning(f\"Server does not support database deletion API\")\n", + " return False\n", + " elif response.status_code == 405:\n", + " logger.warning(f\"Database deletion not allowed on this server\")\n", + " return False\n", + "\n", + " response.raise_for_status()\n", + "\n", + " logger.info(f\"Database deleted successfully\")\n", + " return True\n", + "\n", + " except httpx.HTTPStatusError as e:\n", + " if e.response.status_code in [404, 405]:\n", + " logger.warning(f\"Server does not support database deletion (HTTP {e.response.status_code})\")\n", + " else:\n", + " logger.error(f\"Error deleting database: {e}\")\n", + " return False\n", + " except Exception as e:\n", + " logger.error(f\"Error deleting database: {e}\")\n", + " return False" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "hIkBTwhy8E_b" + }, + "source": [ + "## 5. Protocol Clients (OpenEnv + MCP)\n", + "\n", + "HTTP clients for interacting with both OpenEnv REST API (RFC 002) and MCP JSON-RPC protocol." + ] + }, + { + "cell_type": "code", + "execution_count": 79, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "aiKLGXr_8E_b", + "outputId": "8f86cd18-c174-4ee4-bb1b-279afd3bbf50" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Protocol clients (OpenEnv + MCP) defined\n" + ] + } + ], + "source": [ + "# ============================================================================\n", + "# PROTOCOL CLIENTS (OpenEnv REST API + MCP JSON-RPC)\n", + "# ============================================================================\n", + "\n", + "class OpenEnvClient:\n", + " \"\"\"\n", + " HTTP-based OpenEnv Client for REST API communication with OpenEnv servers.\n", + " Uses /reset, /step, and /state endpoints (OpenEnv RFC 002).\n", + " \"\"\"\n", + "\n", + " def __init__(self, base_url: str, auth_config: Optional[Dict[str, Any]] = None,\n", + " mcp_endpoint: str = \"/mcp\", database_id: Optional[str] = None,\n", + " context: Optional[Dict[str, Any]] = None, seed_database_file: str = \"\",\n", + " sql_content: Optional[str] = None):\n", + " self.base_url = base_url.rstrip(\"/\")\n", + " self.database_id = database_id\n", + " self.auth_config = auth_config\n", + " self.context = context or {}\n", + " self.seed_database_file = seed_database_file\n", + " self.sql_content = sql_content # SQL content from seed file\n", + " self.timeout = 60.0\n", + " self.total_reward = 0.0\n", + " self.connected = False\n", + "\n", + " def _headers(self) -> Dict[str, str]:\n", + " \"\"\"Get headers for HTTP requests\"\"\"\n", + " headers = {\n", + " \"Content-Type\": \"application/json\",\n", + " \"x-database-id\": self.database_id,\n", + " }\n", + " # Add authentication token if provided\n", + " if self.auth_config:\n", + " auth_type = self.auth_config.get(\"type\")\n", + " token = self.auth_config.get(\"token\")\n", + " header_name = self.auth_config.get(\"header_name\", \"Authorization\")\n", + "\n", + " if auth_type == \"bearer\":\n", + " headers[header_name] = f\"Bearer {token}\"\n", + " elif auth_type == \"api_key\":\n", + " headers[header_name] = token\n", + "\n", + " # Add context headers\n", + " if self.context and isinstance(self.context, dict):\n", + " for key, value in self.context.items():\n", + " # Don't add extra \"x-\" if key already starts with \"x-\"\n", + " if key.lower().startswith(\"x-\"):\n", + " header_key = key.lower().replace('_', '-')\n", + " else:\n", + " header_key = f\"x-{key.lower().replace('_', '-')}\"\n", + " headers[header_key] = str(value)\n", + "\n", + " return headers\n", + "\n", + " async def connect(self) -> bool:\n", + " \"\"\"Connect to OpenEnv server (no initialization needed for REST API)\"\"\"\n", + " try:\n", + " # For OpenEnv, we just verify the server is reachable\n", + " self.connected = True\n", + " logger.info(f\"Connected to OpenEnv server: {self.base_url}\")\n", + " return True\n", + " except Exception as e:\n", + " logger.error(f\"Failed to connect to OpenEnv server: {e}\")\n", + " return False\n", + "\n", + " async def reset(self) -> Dict[str, Any]:\n", + " \"\"\"Reset environment using OpenEnv /reset endpoint with custom SQL content\"\"\"\n", + " logger.info(f\"Resetting {self.base_url} (db: {self.database_id})...\")\n", + " try:\n", + " # Build request payload\n", + " payload = {}\n", + "\n", + " # Add sql_content if available (from user-provided seed file)\n", + " if self.sql_content:\n", + " payload[\"sql_content\"] = self.sql_content\n", + " logger.info(f\"Using custom SQL ({len(self.sql_content) / 1024:.2f} KB)\")\n", + "\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.post(\n", + " f\"{self.base_url}/reset\",\n", + " json=payload if payload else None,\n", + " headers=self._headers()\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " data = response.json()\n", + " episode_id = data.get('metadata', {}).get('episode_id', 'N/A')\n", + " logger.info(f\"Reset successful - Episode: {episode_id[:8] if episode_id != 'N/A' else 'N/A'}\")\n", + " self.total_reward = 0.0\n", + " return {\"success\": True, \"data\": data}\n", + "\n", + " # Handle servers that don't support /reset endpoint\n", + " if response.status_code == 404:\n", + " logger.warning(\"Server does not support /reset endpoint - continuing without reset\")\n", + " self.total_reward = 0.0\n", + " return {\"success\": True, \"data\": {}, \"fallback\": True}\n", + "\n", + " logger.error(f\"Reset failed: HTTP {response.status_code}\")\n", + " return {\"success\": False, \"error\": f\"HTTP {response.status_code}\"}\n", + "\n", + " except Exception as e:\n", + " logger.warning(f\"Reset failed: {e} - continuing without reset\")\n", + " self.total_reward = 0.0\n", + " return {\"success\": True, \"data\": {}, \"fallback\": True}\n", + "\n", + " async def list_tools(self) -> List[Dict[str, Any]]:\n", + " \"\"\"List available tools using OpenEnv /step with ListToolsAction\"\"\"\n", + " logger.info(\"Listing tools...\")\n", + " try:\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.post(\n", + " f\"{self.base_url}/step\",\n", + " json={\"action_type\": \"ListToolsAction\"},\n", + " headers=self._headers()\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " tools = response.json().get(\"observation\", {}).get(\"tools_list\", [])\n", + " logger.info(f\"Found {len(tools)} tools\")\n", + " return tools\n", + "\n", + " logger.error(f\"List tools failed: HTTP {response.status_code}\")\n", + " return []\n", + " except Exception as e:\n", + " logger.error(f\"List tools failed: {e}\")\n", + " return []\n", + "\n", + " async def call_tool(\n", + " self,\n", + " tool_name: str,\n", + " arguments: Dict[str, Any] = None,\n", + " database_id: Optional[str] = None,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute a tool using OpenEnv /step with ToolCallAction\"\"\"\n", + " logger.info(f\"Calling {tool_name}...\")\n", + " try:\n", + " # Build headers (override instance values if provided)\n", + " headers = self._headers()\n", + " if database_id:\n", + " headers[\"x-database-id\"] = database_id\n", + "\n", + " # Add any additional context headers (these override instance context)\n", + " if context and isinstance(context, dict):\n", + " for key, value in context.items():\n", + " # Don't add extra \"x-\" if key already starts with \"x-\"\n", + " if key.lower().startswith(\"x-\"):\n", + " header_key = key.lower().replace('_', '-')\n", + " else:\n", + " header_key = f\"x-{key.lower().replace('_', '-')}\"\n", + " headers[header_key] = str(value)\n", + "\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.post(\n", + " f\"{self.base_url}/step\",\n", + " json={\n", + " \"action_type\": \"ToolCallAction\",\n", + " \"tool_name\": tool_name,\n", + " \"arguments\": arguments or {}\n", + " },\n", + " headers=headers\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " data = response.json()\n", + " reward = data.get(\"reward\", 0.0)\n", + " self.total_reward += reward\n", + " observation = data.get(\"observation\", {})\n", + " success = observation.get(\"success\", False)\n", + " logger.info(f\"{tool_name} (success={success}, reward={reward})\")\n", + " return {\n", + " \"success\": True,\n", + " \"result\": observation,\n", + " \"reward\": reward\n", + " }\n", + "\n", + " logger.error(f\"Tool call failed: HTTP {response.status_code}\")\n", + " return {\"success\": False, \"error\": f\"HTTP {response.status_code}\"}\n", + " except Exception as e:\n", + " logger.error(f\"Tool call failed: {e}\")\n", + " return {\"success\": False, \"error\": str(e)}\n", + "\n", + " async def reset_database(self, database_id: str = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:\n", + " \"\"\"Reset database using OpenEnv /reset endpoint\"\"\"\n", + " return await self.reset()\n", + "\n", + " async def get_state(self, verify_queries: Optional[List[str]] = None) -> Dict[str, Any]:\n", + " \"\"\"Get current environment state using OpenEnv /state endpoint\"\"\"\n", + " logger.info(\"GET /state\")\n", + " try:\n", + " params = [(\"verify_queries\", q) for q in verify_queries] if verify_queries else []\n", + "\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.get(\n", + " f\"{self.base_url}/state\",\n", + " params=params if params else None,\n", + " headers=self._headers()\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " return {\"success\": True, \"data\": response.json()}\n", + "\n", + " logger.error(f\"Get state failed: HTTP {response.status_code}\")\n", + " return {\"success\": False, \"error\": f\"HTTP {response.status_code}\"}\n", + " except Exception as e:\n", + " logger.error(f\"Get state failed: {e}\")\n", + " return {\"success\": False, \"error\": str(e)}\n", + "\n", + "\n", + "class MCPClient:\n", + " \"\"\"\n", + " MCP (Model Context Protocol) Client for JSON-RPC communication.\n", + " Uses /mcp endpoint with JSON-RPC 2.0 protocol.\n", + " \"\"\"\n", + "\n", + " def __init__(self, base_url: str, auth_config: Optional[Dict[str, Any]] = None,\n", + " mcp_endpoint: str = \"/mcp\", database_id: Optional[str] = None,\n", + " context: Optional[Dict[str, Any]] = None, seed_database_file: str = \"\",\n", + " sql_content: Optional[str] = None):\n", + " self.base_url = base_url.rstrip(\"/\")\n", + " self.mcp_endpoint = mcp_endpoint\n", + " self.database_id = database_id\n", + " self.auth_config = auth_config\n", + " self.context = context or {}\n", + " self.seed_database_file = seed_database_file\n", + " self.sql_content = sql_content # SQL content from seed file\n", + " self.timeout = 60.0\n", + " self.session_id = None\n", + " self.request_id = 0\n", + " self.connected = False\n", + "\n", + " def _next_request_id(self) -> int:\n", + " \"\"\"Get next request ID for JSON-RPC\"\"\"\n", + " self.request_id += 1\n", + " return self.request_id\n", + "\n", + " def _headers(self) -> Dict[str, str]:\n", + " \"\"\"Get headers for HTTP requests\"\"\"\n", + " headers = {\"Content-Type\": \"application/json\"}\n", + "\n", + " if self.session_id:\n", + " headers[\"mcp-session-id\"] = self.session_id\n", + "\n", + " if self.database_id:\n", + " headers[\"x-database-id\"] = self.database_id\n", + "\n", + " # Add authentication\n", + " if self.auth_config:\n", + " auth_type = self.auth_config.get(\"type\")\n", + " token = self.auth_config.get(\"token\")\n", + " header_name = self.auth_config.get(\"header_name\", \"Authorization\")\n", + "\n", + " if auth_type == \"bearer\":\n", + " headers[header_name] = f\"Bearer {token}\"\n", + " elif auth_type == \"api_key\":\n", + " headers[header_name] = token\n", + "\n", + " # Add context headers\n", + " if self.context and isinstance(self.context, dict):\n", + " for key, value in self.context.items():\n", + " # Don't add extra \"x-\" if key already starts with \"x-\"\n", + " if key.lower().startswith(\"x-\"):\n", + " header_key = key.lower().replace('_', '-')\n", + " else:\n", + " header_key = f\"x-{key.lower().replace('_', '-')}\"\n", + " headers[header_key] = str(value)\n", + "\n", + " return headers\n", + "\n", + " async def _send_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:\n", + " \"\"\"Send JSON-RPC request\"\"\"\n", + " payload = {\n", + " \"jsonrpc\": \"2.0\",\n", + " \"id\": self._next_request_id(),\n", + " \"method\": method,\n", + " \"params\": params or {}\n", + " }\n", + "\n", + " try:\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.post(\n", + " f\"{self.base_url}{self.mcp_endpoint}\",\n", + " json=payload,\n", + " headers=self._headers()\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " data = response.json()\n", + " if \"error\" in data:\n", + " return {\"success\": False, \"error\": data[\"error\"]}\n", + " return {\"success\": True, \"result\": data.get(\"result\")}\n", + "\n", + " return {\"success\": False, \"error\": f\"HTTP {response.status_code}\"}\n", + " except Exception as e:\n", + " return {\"success\": False, \"error\": str(e)}\n", + "\n", + " async def connect(self) -> bool:\n", + " \"\"\"Initialize MCP session\"\"\"\n", + " logger.info(f\"Initializing MCP session with {self.base_url}...\")\n", + " result = await self._send_request(\"initialize\", {\n", + " \"protocolVersion\": \"2024-11-05\",\n", + " \"capabilities\": {},\n", + " \"clientInfo\": {\"name\": \"benchmark-executor\", \"version\": \"1.0.0\"}\n", + " })\n", + "\n", + " if result.get(\"success\"):\n", + " # Extract session ID from response if provided\n", + " session_info = result.get(\"result\", {})\n", + " if isinstance(session_info, dict) and \"sessionId\" in session_info:\n", + " self.session_id = session_info[\"sessionId\"]\n", + "\n", + " self.connected = True\n", + " logger.info(f\"MCP session initialized\")\n", + " return True\n", + "\n", + " logger.error(f\"MCP initialization failed: {result.get('error')}\")\n", + " return False\n", + "\n", + " async def reset(self) -> Dict[str, Any]:\n", + " \"\"\"Reset environment (no-op for MCP, handled by reset_database)\"\"\"\n", + " return {\"success\": True, \"data\": {}}\n", + "\n", + " async def list_tools(self) -> List[Dict[str, Any]]:\n", + " \"\"\"List available tools via MCP tools/list\"\"\"\n", + " logger.info(\"Listing tools via MCP...\")\n", + " result = await self._send_request(\"tools/list\")\n", + "\n", + " if result.get(\"success\"):\n", + " tools = result.get(\"result\", {}).get(\"tools\", [])\n", + " logger.info(f\"Found {len(tools)} tools\")\n", + " return tools\n", + "\n", + " logger.error(f\"List tools failed: {result.get('error')}\")\n", + " return []\n", + "\n", + " async def call_tool(\n", + " self,\n", + " tool_name: str,\n", + " arguments: Dict[str, Any] = None,\n", + " database_id: Optional[str] = None,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute a tool via MCP tools/call\"\"\"\n", + " logger.info(f\"Calling {tool_name} via MCP...\")\n", + "\n", + " # Temporarily override database_id and context if provided\n", + " original_db = self.database_id\n", + " original_ctx = self.context\n", + "\n", + " if database_id:\n", + " self.database_id = database_id\n", + " if context:\n", + " self.context = {**self.context, **context}\n", + "\n", + " try:\n", + " result = await self._send_request(\"tools/call\", {\n", + " \"name\": tool_name,\n", + " \"arguments\": arguments or {}\n", + " })\n", + "\n", + " if result.get(\"success\"):\n", + " tool_result = result.get(\"result\", {})\n", + " logger.info(f\"{tool_name} completed\")\n", + " return {\"success\": True, \"result\": tool_result}\n", + "\n", + " logger.error(f\"Tool call failed: {result.get('error')}\")\n", + " return {\"success\": False, \"error\": result.get(\"error\")}\n", + " finally:\n", + " # Restore original values\n", + " self.database_id = original_db\n", + " self.context = original_ctx\n", + "\n", + " async def reset_database(self, database_id: str = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:\n", + " \"\"\"Reset database using MCP or fallback to /api/seed-database\"\"\"\n", + " target_db = database_id or self.database_id\n", + " logger.info(f\"Resetting database: {target_db}\")\n", + "\n", + " # Try OpenEnv /reset endpoint first (with sql_content if available)\n", + " try:\n", + " headers = self._headers()\n", + " if database_id:\n", + " headers[\"x-database-id\"] = database_id\n", + "\n", + " if context and isinstance(context, dict):\n", + " for key, value in context.items():\n", + " # Don't add extra \"x-\" if key already starts with \"x-\"\n", + " if key.lower().startswith(\"x-\"):\n", + " header_key = key.lower().replace('_', '-')\n", + " else:\n", + " header_key = f\"x-{key.lower().replace('_', '-')}\"\n", + " headers[header_key] = str(value)\n", + "\n", + " # Build payload with sql_content if available\n", + " payload = {}\n", + " if self.sql_content:\n", + " payload[\"sql_content\"] = self.sql_content\n", + " logger.info(f\"Using custom SQL ({len(self.sql_content) / 1024:.2f} KB)\")\n", + "\n", + " async with httpx.AsyncClient(timeout=self.timeout) as client:\n", + " response = await client.post(\n", + " f\"{self.base_url}/reset\",\n", + " json=payload if payload else None,\n", + " headers=headers\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " logger.info(f\"Database reset successful via /reset\")\n", + " return {\"success\": True}\n", + " except Exception as e:\n", + " logger.debug(f\"OpenEnv /reset not available: {e}\")\n", + "\n", + " # Fallback: Use /api/seed-database endpoint\n", + " logger.info(f\"Attempting database reset via /api/seed-database...\")\n", + " try:\n", + " # Use sql_content if available, otherwise get sample SQL\n", + " if self.sql_content:\n", + " sql_content = self.sql_content\n", + " logger.info(f\"Using custom SQL from seed file\")\n", + " else:\n", + " async with httpx.AsyncClient(timeout=60) as client:\n", + " response = await client.get(f\"{self.base_url}/api/sample-data\")\n", + " if response.status_code != 200:\n", + " return {\"success\": False, \"error\": \"Failed to fetch sample SQL\"}\n", + "\n", + " data = response.json()\n", + " sql_content = data.get('sql_content') or data.get('text') or data\n", + "\n", + " # Seed database\n", + " async with httpx.AsyncClient(timeout=max(120, int(120 + len(sql_content) / 102400))) as client:\n", + " payload = {\n", + " \"database_id\": target_db,\n", + " \"name\": f\"Reset DB {datetime.now().strftime('%Y%m%d_%H%M%S')}\",\n", + " \"sql_content\": sql_content\n", + " }\n", + "\n", + " response = await client.post(\n", + " f\"{self.base_url}/api/seed-database\",\n", + " json=payload,\n", + " headers={\"Content-Type\": \"application/json\"}\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " logger.info(f\"Database reset successful via /api/seed-database\")\n", + " return {\"success\": True}\n", + "\n", + " return {\"success\": False, \"error\": f\"Seed failed: HTTP {response.status_code}\"}\n", + " except Exception as e:\n", + " logger.error(f\"Database reset failed: {e}\")\n", + " return {\"success\": False, \"error\": str(e)}\n", + "\n", + " async def get_state(self, verify_queries: Optional[List[str]] = None) -> Dict[str, Any]:\n", + " \"\"\"Get state (MCP doesn't have native state endpoint, return empty)\"\"\"\n", + " return {\"success\": True, \"data\": {}}\n", + "\n", + "print(\"Protocol clients (OpenEnv + MCP) defined\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "I1tAD_zA8E_c" + }, + "source": [ + "## 6. LLM Client\n", + "\n", + "Unified LLM interface supporting multiple providers (Anthropic Claude, OpenAI GPT, Google Gemini) with LangChain integration." + ] + }, + { + "cell_type": "code", + "execution_count": 80, + "metadata": { + "id": "DyVp1JiV8E_c" + }, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# LLM CLIENT (Unified interface for Anthropic, OpenAI, Google)\n", + "# ============================================================================\n", + "\n", + "class LLMClient:\n", + " \"\"\"\n", + " Unified LLM client supporting multiple providers.\n", + " Uses LangChain for consistent tool calling interface.\n", + " \"\"\"\n", + "\n", + " def __init__(self, provider: str, model: str, api_key: str,\n", + " temperature: float = 0.0, max_tokens: int = 4096):\n", + " self.provider = provider.lower()\n", + " self.model = model\n", + " self.api_key = api_key\n", + " self.temperature = temperature\n", + " self.max_tokens = max_tokens\n", + " self.llm = None\n", + "\n", + " self._initialize_llm()\n", + "\n", + " def _initialize_llm(self):\n", + " \"\"\"Initialize LLM based on provider\"\"\"\n", + " try:\n", + " if self.provider == \"anthropic\":\n", + " from langchain_anthropic import ChatAnthropic\n", + " self.llm = ChatAnthropic(\n", + " model=self.model,\n", + " anthropic_api_key=self.api_key,\n", + " temperature=self.temperature,\n", + " max_tokens=self.max_tokens\n", + " )\n", + " elif self.provider == \"openai\":\n", + " from langchain_openai import ChatOpenAI\n", + " self.llm = ChatOpenAI(\n", + " model=self.model,\n", + " openai_api_key=self.api_key,\n", + " temperature=self.temperature,\n", + " max_tokens=self.max_tokens\n", + " )\n", + " elif self.provider == \"google\":\n", + " from langchain_google_genai import ChatGoogleGenerativeAI\n", + " self.llm = ChatGoogleGenerativeAI(\n", + " model=self.model,\n", + " google_api_key=self.api_key,\n", + " temperature=self.temperature,\n", + " max_tokens=self.max_tokens\n", + " )\n", + " else:\n", + " raise ValueError(f\"Unsupported LLM provider: {self.provider}\")\n", + "\n", + " logger.info(f\"Initialized {self.provider} LLM: {self.model}\")\n", + "\n", + " except ImportError as e:\n", + " logger.error(f\"Failed to import LangChain provider for {self.provider}: {e}\")\n", + " raise\n", + "\n", + " def _convert_mcp_tools_to_langchain(self, mcp_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n", + " \"\"\"Convert MCP tool definitions to LangChain format, filtering incompatible schemas\"\"\"\n", + " langchain_tools = []\n", + "\n", + " for tool in mcp_tools:\n", + " input_schema = tool.get(\"inputSchema\", {\n", + " \"type\": \"object\",\n", + " \"properties\": {},\n", + " \"required\": []\n", + " })\n", + "\n", + " # Clean schema: remove oneOf, allOf, anyOf at top level (Anthropic doesn't support them)\n", + " cleaned_schema = self._clean_json_schema(input_schema)\n", + "\n", + " tool_def = {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": tool[\"name\"],\n", + " \"description\": tool.get(\"description\", \"\"),\n", + " \"parameters\": cleaned_schema\n", + " }\n", + " }\n", + " langchain_tools.append(tool_def)\n", + "\n", + " return langchain_tools\n", + "\n", + " def _clean_json_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:\n", + " \"\"\"Clean JSON schema to be compatible with Anthropic API\n", + "\n", + " Removes oneOf, allOf, anyOf at top level and converts to simple object schema.\n", + " \"\"\"\n", + " if not isinstance(schema, dict):\n", + " return {\n", + " \"type\": \"object\",\n", + " \"properties\": {},\n", + " \"required\": []\n", + " }\n", + "\n", + " # If schema has oneOf/allOf/anyOf at top level, extract the first valid object schema\n", + " if \"oneOf\" in schema:\n", + " logger.debug(f\"Schema has oneOf at top level, extracting first object schema\")\n", + " for option in schema[\"oneOf\"]:\n", + " if isinstance(option, dict) and option.get(\"type\") == \"object\":\n", + " schema = option\n", + " break\n", + " else:\n", + " # No object schema found, return empty\n", + " return {\n", + " \"type\": \"object\",\n", + " \"properties\": {},\n", + " \"required\": []\n", + " }\n", + "\n", + " if \"allOf\" in schema:\n", + " logger.debug(f\"Schema has allOf at top level, merging schemas\")\n", + " merged_schema = {\"type\": \"object\", \"properties\": {}, \"required\": []}\n", + " for sub_schema in schema[\"allOf\"]:\n", + " if isinstance(sub_schema, dict):\n", + " if \"properties\" in sub_schema:\n", + " merged_schema[\"properties\"].update(sub_schema[\"properties\"])\n", + " if \"required\" in sub_schema:\n", + " merged_schema[\"required\"].extend(sub_schema[\"required\"])\n", + " schema = merged_schema\n", + "\n", + " if \"anyOf\" in schema:\n", + " logger.debug(f\"Schema has anyOf at top level, extracting first object schema\")\n", + " for option in schema[\"anyOf\"]:\n", + " if isinstance(option, dict) and option.get(\"type\") == \"object\":\n", + " schema = option\n", + " break\n", + " else:\n", + " return {\n", + " \"type\": \"object\",\n", + " \"properties\": {},\n", + " \"required\": []\n", + " }\n", + "\n", + " # Ensure schema has required fields\n", + " if \"type\" not in schema:\n", + " schema[\"type\"] = \"object\"\n", + "\n", + " if schema[\"type\"] == \"object\" and \"properties\" not in schema:\n", + " schema[\"properties\"] = {}\n", + "\n", + " return schema\n", + "\n", + " async def invoke_with_tools(\n", + " self,\n", + " messages: List[Any],\n", + " tools: List[Dict[str, Any]]\n", + " ) -> Any:\n", + " \"\"\"Invoke LLM with tools\"\"\"\n", + " from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage\n", + "\n", + " # Convert MCP tools to LangChain format\n", + " langchain_tools = self._convert_mcp_tools_to_langchain(tools)\n", + "\n", + " # Bind tools to LLM\n", + " llm_with_tools = self.llm.bind_tools(langchain_tools)\n", + "\n", + " # Invoke\n", + " logger.info(f\"Invoking {self.provider} LLM with {len(tools)} tools\")\n", + " response = await llm_with_tools.ainvoke(messages)\n", + "\n", + " return response" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "xRcKeQTE8E_c" + }, + "source": [ + "## 7. Verifier Engine\n", + "\n", + "Comprehensive verification engine supporting multiple verifier types: database_state (SQL validation), response_check (LLM-as-judge), and tool_execution (tool usage validation)." + ] + }, + { + "cell_type": "code", + "execution_count": 81, + "metadata": { + "id": "64YSa4aJ8E_c" + }, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# VERIFIER ENGINE\n", + "# ============================================================================\n", + "\n", + "class VerifierEngine:\n", + " \"\"\"\n", + " Verifier engine for validating benchmark results.\n", + " Supports: database_state, response_check, tool_execution\n", + " \"\"\"\n", + "\n", + " def __init__(self, protocol_client, llm_client: LLMClient, execution_mode: str = \"openenv\"):\n", + " self.protocol_client = protocol_client\n", + " self.llm_client = llm_client\n", + " self.execution_mode = execution_mode\n", + "\n", + " async def execute_verifier(\n", + " self,\n", + " verifier: VerifierConfig,\n", + " model_response: Dict[str, Any],\n", + " database_id: str,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute a single verifier\"\"\"\n", + " logger.info(f\"Executing verifier: {verifier.verifier_type}\")\n", + "\n", + " if verifier.verifier_type == \"database_state\":\n", + " return await self._execute_database_state_verifier(\n", + " verifier.validation_config, database_id, context\n", + " )\n", + " elif verifier.verifier_type == \"response_check\":\n", + " return await self._execute_response_check_verifier(\n", + " verifier.validation_config, model_response, database_id, context\n", + " )\n", + " elif verifier.verifier_type == \"tool_execution\":\n", + " return await self._execute_tool_execution_verifier(\n", + " verifier.validation_config, model_response\n", + " )\n", + " else:\n", + " return {\n", + " \"passed\": False,\n", + " \"error\": f\"Unsupported verifier type: {verifier.verifier_type}\"\n", + " }\n", + "\n", + " async def _execute_database_state_verifier(\n", + " self,\n", + " validation_config: Dict[str, Any],\n", + " database_id: str,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute database state verifier\"\"\"\n", + " sql_query = validation_config.get(\"query\")\n", + " expected_value = validation_config.get(\"expected_value\")\n", + " comparison_type = validation_config.get(\"comparison_type\", \"equals\")\n", + "\n", + " if not sql_query:\n", + " return {\"passed\": False, \"error\": \"No SQL query provided\"}\n", + "\n", + " logger.info(f\"Executing SQL query: {sql_query}\")\n", + "\n", + " # Execute SQL query via MCP\n", + " result = await self._execute_sql_query(sql_query, database_id, context)\n", + "\n", + " if not result[\"success\"]:\n", + " return {\n", + " \"passed\": False,\n", + " \"error\": f\"SQL query failed: {result.get('error')}\",\n", + " \"query\": sql_query\n", + " }\n", + "\n", + " # Extract value from result\n", + " actual_value = self._extract_value_from_sql_result(result)\n", + "\n", + " logger.info(f\"SQL result - Expected: {expected_value}, Actual: {actual_value}\")\n", + "\n", + " # Compare values\n", + " comparison_result = self._compare_values(actual_value, expected_value, comparison_type)\n", + "\n", + " return {\n", + " \"passed\": comparison_result[\"passed\"],\n", + " \"expected\": expected_value,\n", + " \"actual\": actual_value,\n", + " \"comparison_type\": comparison_type,\n", + " \"query\": sql_query,\n", + " \"details\": comparison_result.get(\"details\")\n", + " }\n", + "\n", + " async def _execute_response_check_verifier(\n", + " self,\n", + " validation_config: Dict[str, Any],\n", + " model_response: Dict[str, Any],\n", + " database_id: str,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute response check verifier using LLM-as-judge\"\"\"\n", + " sql_query = validation_config.get(\"sql_query\")\n", + " comparison_prompt = validation_config.get(\"comparison_prompt\")\n", + " minimum_comparison_value = validation_config.get(\"minimum_comparison_value\", 7)\n", + "\n", + " if not sql_query or not comparison_prompt:\n", + " return {\n", + " \"passed\": False,\n", + " \"error\": \"Missing sql_query or comparison_prompt\"\n", + " }\n", + "\n", + " # Execute SQL query\n", + " sql_result = await self._execute_sql_query(sql_query, database_id, context)\n", + "\n", + " if not sql_result[\"success\"]:\n", + " return {\n", + " \"passed\": False,\n", + " \"error\": f\"SQL query failed: {sql_result.get('error')}\"\n", + " }\n", + "\n", + " # Extract LLM response text\n", + " llm_response_text = self._extract_llm_content(model_response)\n", + "\n", + " # Use LLM as judge\n", + " judge_result = await self._compare_with_llm(\n", + " sql_result,\n", + " llm_response_text,\n", + " comparison_prompt,\n", + " minimum_comparison_value\n", + " )\n", + "\n", + " return judge_result\n", + "\n", + " async def _execute_tool_execution_verifier(\n", + " self,\n", + " validation_config: Dict[str, Any],\n", + " model_response: Dict[str, Any]\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute tool execution verifier\"\"\"\n", + " expected_tools = validation_config.get(\"expected_tools\", [])\n", + " minimum_tool_calls = validation_config.get(\"minimum_tool_calls\", 1)\n", + "\n", + " # Extract tools called from model response\n", + " tools_called = []\n", + " if \"tool_calls\" in model_response and model_response[\"tool_calls\"]:\n", + " tools_called = [tc[\"name\"] for tc in model_response[\"tool_calls\"]]\n", + "\n", + " logger.info(f\"Expected tools: {expected_tools}, Called: {tools_called}\")\n", + "\n", + " # Check if expected tools were called\n", + " missing_tools = [tool for tool in expected_tools if tool not in tools_called]\n", + "\n", + " # Check minimum tool calls\n", + " passed = (\n", + " len(missing_tools) == 0 and\n", + " len(tools_called) >= minimum_tool_calls\n", + " )\n", + "\n", + " return {\n", + " \"passed\": passed,\n", + " \"expected_tools\": expected_tools,\n", + " \"tools_called\": tools_called,\n", + " \"missing_tools\": missing_tools,\n", + " \"minimum_tool_calls\": minimum_tool_calls,\n", + " \"actual_tool_calls\": len(tools_called)\n", + " }\n", + "\n", + " async def _execute_sql_query(\n", + " self,\n", + " query: str,\n", + " database_id: str,\n", + " context: Optional[Dict[str, Any]] = None\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Execute SQL query via OpenEnv /state or MCP /api/sql-runner\"\"\"\n", + " try:\n", + " logger.info(f\"Executing SQL query: {query}\")\n", + "\n", + " if self.execution_mode == \"openenv\":\n", + " # Use OpenEnv /state endpoint with verify_queries parameter\n", + " state_result = await self.protocol_client.get_state(verify_queries=[query])\n", + "\n", + " if not state_result.get(\"success\"):\n", + " return {\n", + " \"success\": False,\n", + " \"error\": state_result.get(\"error\", \"State query failed\")\n", + " }\n", + "\n", + " # Extract verification result\n", + " state_data = state_result.get(\"data\", {})\n", + " verification_results = state_data.get(\"verification_results\", [])\n", + "\n", + " if not verification_results or len(verification_results) == 0:\n", + " return {\n", + " \"success\": False,\n", + " \"error\": \"No verification results returned from /state\"\n", + " }\n", + "\n", + " # Get the first (and only) verification result\n", + " query_result = verification_results[0]\n", + "\n", + " # Check if query itself failed\n", + " if \"error\" in query_result:\n", + " return {\n", + " \"success\": False,\n", + " \"error\": query_result.get(\"error\", \"Query execution failed\")\n", + " }\n", + "\n", + " # Return the query result\n", + " return {\n", + " \"success\": True,\n", + " \"result\": query_result.get(\"result\", [])\n", + " }\n", + "\n", + " else: # MCP mode\n", + " # Use direct HTTP call to /api/sql-runner endpoint\n", + " headers = {\n", + " \"Content-Type\": \"application/json\",\n", + " \"x-database-id\": database_id\n", + " }\n", + "\n", + " # Add context headers\n", + " if context and isinstance(context, dict):\n", + " for key, value in context.items():\n", + " header_key = f\"x-{key.lower().replace('_', '-')}\"\n", + " headers[header_key] = str(value)\n", + "\n", + " # Add session ID if available\n", + " if hasattr(self.protocol_client, 'session_id') and self.protocol_client.session_id:\n", + " headers[\"mcp-session-id\"] = self.protocol_client.session_id\n", + "\n", + " payload = {\"query\": query}\n", + "\n", + " async with httpx.AsyncClient(timeout=30.0) as client:\n", + " response = await client.post(\n", + " f\"{self.protocol_client.base_url}/api/sql-runner\",\n", + " json=payload,\n", + " headers=headers\n", + " )\n", + "\n", + " if response.status_code == 200:\n", + " result_data = response.json()\n", + " return {\"success\": True, \"result\": result_data}\n", + "\n", + " return {\n", + " \"success\": False,\n", + " \"error\": f\"SQL runner failed: HTTP {response.status_code}\"\n", + " }\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"SQL query execution failed: {e}\")\n", + " return {\"success\": False, \"error\": str(e)}\n", + "\n", + " def _extract_value_from_sql_result(self, result: dict) -> Any:\n", + " \"\"\"Extract the actual value from SQL query result (matches production implementation)\"\"\"\n", + " if not result:\n", + " return None\n", + "\n", + " # If the result itself is not successful, check for error content\n", + " if not result.get(\"success\"):\n", + " # Try to extract error message from MCP response format\n", + " result_data = result.get(\"result\", {})\n", + " if isinstance(result_data, dict) and \"content\" in result_data:\n", + " content = result_data[\"content\"]\n", + " if isinstance(content, list) and len(content) > 0:\n", + " for item in content:\n", + " if isinstance(item, dict) and item.get(\"type\") == \"text\":\n", + " return item.get(\"text\", \"Error\")\n", + " return None\n", + "\n", + " result_data = result.get(\"result\", {})\n", + "\n", + " # Handle OpenEnv format: result is directly a list like [{'count': 1}]\n", + " if isinstance(result_data, list):\n", + " if len(result_data) > 0:\n", + " # If single row with single column, return the value directly\n", + " if len(result_data) == 1 and isinstance(result_data[0], dict) and len(result_data[0]) == 1:\n", + " return list(result_data[0].values())[0]\n", + " # If single row with multiple columns, return the row dict\n", + " elif len(result_data) == 1:\n", + " return result_data[0]\n", + " # Multiple rows, return the full result\n", + " else:\n", + " return result_data\n", + " return result_data\n", + "\n", + " # Handle different result formats from MCP sql-runner\n", + " if isinstance(result_data, dict):\n", + " # If result has 'data' field (common format)\n", + " if \"data\" in result_data:\n", + " data = result_data[\"data\"]\n", + " if isinstance(data, list) and len(data) > 0:\n", + " # If single row with single column, return the value directly\n", + " if len(data) == 1 and isinstance(data[0], dict) and len(data[0]) == 1:\n", + " return list(data[0].values())[0]\n", + " # If single row with multiple columns, return the row dict\n", + " elif len(data) == 1:\n", + " return data[0]\n", + " # Multiple rows, return the full result\n", + " else:\n", + " return data\n", + " return data\n", + "\n", + " # If result has 'rows' field\n", + " elif \"rows\" in result_data:\n", + " rows = result_data[\"rows\"]\n", + " if isinstance(rows, list) and len(rows) > 0:\n", + " # Single value from single row\n", + " if len(rows) == 1 and isinstance(rows[0], dict) and len(rows[0]) == 1:\n", + " return list(rows[0].values())[0]\n", + " # Single row as list\n", + " elif len(rows) == 1 and isinstance(rows[0], list) and len(rows[0]) == 1:\n", + " return rows[0][0]\n", + " # Single row (dict or list)\n", + " elif len(rows) == 1:\n", + " return rows[0]\n", + " # Multiple rows\n", + " else:\n", + " return rows\n", + " return rows\n", + "\n", + " # If result has 'content' field (MCP error format)\n", + " elif \"content\" in result_data:\n", + " content = result_data[\"content\"]\n", + " if isinstance(content, list) and len(content) > 0:\n", + " # Extract text from content array\n", + " for item in content:\n", + " if isinstance(item, dict) and item.get(\"type\") == \"text\":\n", + " return item.get(\"text\", result_data)\n", + " return content\n", + "\n", + " # Direct result format (nested 'result' field)\n", + " elif \"result\" in result_data:\n", + " return result_data[\"result\"]\n", + "\n", + " # Return as-is if we can't extract anything specific\n", + " return result_data\n", + "\n", + " def _compare_values(\n", + " self,\n", + " actual: Any,\n", + " expected: Any,\n", + " comparison_type: str\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Compare actual vs expected values\"\"\"\n", + " try:\n", + " if comparison_type == \"equals\":\n", + " passed = actual == expected\n", + " elif comparison_type == \"greater_than\":\n", + " passed = actual > expected\n", + " elif comparison_type == \"less_than\":\n", + " passed = actual < expected\n", + " elif comparison_type == \"contains\":\n", + " passed = expected in str(actual)\n", + " else:\n", + " return {\n", + " \"passed\": False,\n", + " \"details\": f\"Unknown comparison type: {comparison_type}\"\n", + " }\n", + "\n", + " return {\n", + " \"passed\": passed,\n", + " \"details\": f\"Comparison {comparison_type}: {actual} vs {expected}\"\n", + " }\n", + "\n", + " except Exception as e:\n", + " return {\n", + " \"passed\": False,\n", + " \"details\": f\"Comparison error: {e}\"\n", + " }\n", + "\n", + " def _extract_llm_content(self, model_response: Dict[str, Any]) -> str:\n", + " \"\"\"Extract text content from LLM response\"\"\"\n", + " if \"content\" in model_response:\n", + " return str(model_response[\"content\"])\n", + " elif \"text\" in model_response:\n", + " return str(model_response[\"text\"])\n", + " elif \"response\" in model_response:\n", + " return str(model_response[\"response\"])\n", + "\n", + " return str(model_response)\n", + "\n", + " async def _compare_with_llm(\n", + " self,\n", + " sql_result: Dict[str, Any],\n", + " llm_response: str,\n", + " comparison_prompt: str,\n", + " minimum_score: int\n", + " ) -> Dict[str, Any]:\n", + " \"\"\"Use LLM as judge to compare SQL result with LLM response\"\"\"\n", + " from langchain_core.messages import SystemMessage, HumanMessage\n", + "\n", + " # Build judge prompt\n", + " system_prompt = \"\"\"You are an AI judge evaluating the quality and accuracy of an AI assistant's response.\n", + "Compare the database query result with the AI's response and rate how well they match.\n", + "Provide a score from 1-10 where:\n", + "- 1-3: Poor match, incorrect or missing information\n", + "- 4-6: Partial match, some correct information\n", + "- 7-8: Good match, mostly correct\n", + "- 9-10: Excellent match, fully accurate\n", + "\n", + "Respond with ONLY a JSON object in this format:\n", + "{\n", + " \"score\": ,\n", + " \"reasoning\": \"\"\n", + "}\"\"\"\n", + "\n", + " sql_result_str = json.dumps(sql_result.get(\"result\", {}), indent=2)\n", + "\n", + " user_prompt = f\"\"\"Database Query Result:\n", + "{sql_result_str}\n", + "\n", + "AI Assistant Response:\n", + "{llm_response}\n", + "\n", + "Comparison Task:\n", + "{comparison_prompt}\n", + "\n", + "Please provide your judgment as JSON.\"\"\"\n", + "\n", + " messages = [\n", + " SystemMessage(content=system_prompt),\n", + " HumanMessage(content=user_prompt)\n", + " ]\n", + "\n", + " try:\n", + " response = await self.llm_client.llm.ainvoke(messages)\n", + " response_text = response.content\n", + "\n", + " # Parse JSON response\n", + " # Try to extract JSON from markdown code blocks\n", + " if \"```json\" in response_text:\n", + " response_text = response_text.split(\"```json\")[1].split(\"```\")[0].strip()\n", + " elif \"```\" in response_text:\n", + " response_text = response_text.split(\"```\")[1].split(\"```\")[0].strip()\n", + "\n", + " judge_result = json.loads(response_text)\n", + " score = judge_result.get(\"score\", 0)\n", + " reasoning = judge_result.get(\"reasoning\", \"\")\n", + "\n", + " passed = score >= minimum_score\n", + "\n", + " return {\n", + " \"passed\": passed,\n", + " \"score\": score,\n", + " \"minimum_score\": minimum_score,\n", + " \"reasoning\": reasoning,\n", + " \"sql_result\": sql_result_str,\n", + " \"llm_response\": llm_response\n", + " }\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"LLM judge comparison failed: {e}\")\n", + " return {\n", + " \"passed\": False,\n", + " \"error\": f\"Judge comparison failed: {e}\"\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "tb5-Pnvk8E_d" + }, + "source": [ + "## 8. Benchmark Executor\n", + "\n", + "Main orchestration engine that coordinates the entire benchmark execution: environment setup, LLM invocation, tool execution, and verification." + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "metadata": { + "id": "Uk3J_sgL8E_d" + }, + "outputs": [], + "source": [ + "# ============================================================================\n", + "# BENCHMARK EXECUTOR\n", + "# ============================================================================\n", + "\n", + "class BenchmarkExecutor:\n", + " \"\"\"\n", + " Main benchmark executor that orchestrates the complete flow:\n", + " 1. Load tools from OpenEnv server\n", + " 2. Send prompts to LLM\n", + " 3. Execute tool calls via OpenEnv /step\n", + " 4. Loop until completion\n", + " 5. Run verifiers\n", + " \"\"\"\n", + "\n", + " def __init__(self, config: BenchmarkConfig):\n", + " self.config = config\n", + " self.protocol_client = None\n", + " self.llm_client = None\n", + " self.verifier_engine = None\n", + " self.available_tools = []\n", + " self.auto_created_database = False # Track if we created the database\n", + " self.execution_mode = config.execution_mode if hasattr(config, 'execution_mode') else \"openenv\"\n", + "\n", + " async def initialize(self):\n", + " \"\"\"Initialize all clients\"\"\"\n", + " logger.info(f\"Initializing benchmark executor in {self.execution_mode.upper()} mode...\")\n", + "\n", + " # Initialize protocol client based on execution mode\n", + " if self.execution_mode == \"openenv\":\n", + " self.protocol_client = OpenEnvClient(\n", + " self.config.gym_enviornment_url,\n", + " auth_config=self.config.auth_config,\n", + " mcp_endpoint=self.config.mcp_endpoint,\n", + " database_id=self.config.database_id,\n", + " context=self.config.context,\n", + " seed_database_file=self.config.seed_database_file\n", + " )\n", + " else: # mcp mode\n", + " self.protocol_client = MCPClient(\n", + " self.config.gym_enviornment_url,\n", + " auth_config=self.config.auth_config,\n", + " mcp_endpoint=self.config.mcp_endpoint,\n", + " database_id=self.config.database_id,\n", + " context=self.config.context,\n", + " seed_database_file=self.config.seed_database_file\n", + " )\n", + "\n", + " connected = await self.protocol_client.connect()\n", + " if not connected:\n", + " raise Exception(f\"Failed to connect to {self.execution_mode.upper()} server\")\n", + "\n", + " # Reset the environment\n", + " reset_result = await self.protocol_client.reset()\n", + " if not reset_result.get(\"success\") and not reset_result.get(\"fallback\"):\n", + " logger.warning(\"Failed to reset environment, continuing anyway\")\n", + "\n", + " # Load available tools\n", + " self.available_tools = await self.protocol_client.list_tools()\n", + " logger.info(f\"Loaded {len(self.available_tools)} tools from {self.execution_mode.upper()} server\")\n", + "\n", + " # Apply tool restrictions if configured\n", + " if self.config.restricted_tools:\n", + " self.available_tools = [\n", + " tool for tool in self.available_tools\n", + " if tool[\"name\"] not in self.config.restricted_tools\n", + " ]\n", + " logger.info(f\"Applied tool restrictions. {len(self.available_tools)} tools available\")\n", + "\n", + " # Initialize LLM client\n", + " self.llm_client = LLMClient(\n", + " self.config.llm_provider,\n", + " self.config.llm_model,\n", + " self.config.llm_api_key,\n", + " temperature=self.config.temperature,\n", + " max_tokens=self.config.max_tokens\n", + " )\n", + "\n", + " # Initialize verifier engine\n", + " self.verifier_engine = VerifierEngine(\n", + " self.protocol_client,\n", + " self.llm_client,\n", + " self.execution_mode\n", + " )\n", + "\n", + " logger.info(\"Initialization complete\")\n", + "\n", + " async def execute_single_run(self, run_number: int) -> Dict[str, Any]:\n", + " \"\"\"Execute a single benchmark run\"\"\"\n", + " logger.info(f\"\\n{'='*80}\")\n", + " logger.info(f\"STARTING RUN {run_number}/{self.config.number_of_runs}\")\n", + " logger.info(f\"{'='*80}\\n\")\n", + "\n", + " start_time = datetime.now(timezone.utc)\n", + "\n", + " # Reset database if configured\n", + " if run_number > 1 and self.config.reset_database_between_runs:\n", + " logger.info(\"Resetting environment between runs...\")\n", + " # database_id and context are already set in protocol_client\n", + " await self.protocol_client.reset()\n", + "\n", + " # Execute the main task (LLM + tool calling loop)\n", + " task_result = await self._execute_task()\n", + "\n", + " # Run verifiers\n", + " verification_results = await self._run_verifiers(task_result)\n", + "\n", + " # Calculate execution time\n", + " execution_time_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)\n", + "\n", + " # Determine overall success\n", + " overall_success = all(\n", + " v[\"passed\"] for v in verification_results.values()\n", + " )\n", + "\n", + " # Calculate verification summary\n", + " total_verifiers = len(verification_results)\n", + " passed_verifiers = sum(1 for v in verification_results.values() if v.get(\"passed\", False))\n", + " failed_verifiers = total_verifiers - passed_verifiers\n", + "\n", + " result = {\n", + " \"run_number\": run_number,\n", + " \"started_at\": start_time.isoformat(),\n", + " \"execution_time_ms\": execution_time_ms,\n", + " \"model_response\": task_result.get(\"final_response\"),\n", + " \"conversation_flow\": task_result.get(\"conversation_flow\", []),\n", + " \"tools_used\": task_result.get(\"tools_used\", []),\n", + " \"tool_results\": task_result.get(\"tool_results\", []),\n", + " \"verification_results\": verification_results,\n", + " \"verification_summary\": {\n", + " \"total\": total_verifiers,\n", + " \"passed\": passed_verifiers,\n", + " \"failed\": failed_verifiers,\n", + " \"pass_rate\": passed_verifiers / total_verifiers if total_verifiers > 0 else 0.0\n", + " },\n", + " \"overall_success\": overall_success\n", + " }\n", + "\n", + " logger.info(f\"\\nRUN {run_number} COMPLETED\")\n", + " logger.info(f\"Verification: {passed_verifiers}/{total_verifiers} passed ({passed_verifiers/total_verifiers*100:.1f}%)\")\n", + " logger.info(f\"Overall Success: {overall_success}\")\n", + " logger.info(f\"Execution time: {execution_time_ms}ms\")\n", + " logger.info(f\"Tools used: {', '.join(task_result.get('tools_used', []))}\")\n", + "\n", + " return result\n", + "\n", + " async def _execute_task(self) -> Dict[str, Any]:\n", + " \"\"\"Execute the main task with LLM + tool calling loop\"\"\"\n", + " from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage\n", + "\n", + " # Build initial messages\n", + " messages = [\n", + " SystemMessage(content=self.config.system_prompt),\n", + " HumanMessage(content=self.config.user_prompt)\n", + " ]\n", + "\n", + " conversation_flow = []\n", + " tools_used = []\n", + " tool_results = []\n", + " max_iterations = 20 # Prevent infinite loops\n", + "\n", + " for iteration in range(max_iterations):\n", + " logger.info(f\"\\n--- Iteration {iteration + 1} ---\")\n", + "\n", + " # Invoke LLM with tools\n", + " response = await self.llm_client.invoke_with_tools(\n", + " messages,\n", + " self.available_tools\n", + " )\n", + "\n", + " # Add AI response to conversation\n", + " messages.append(response)\n", + " conversation_flow.append({\n", + " \"type\": \"ai_message\",\n", + " \"content\": response.content,\n", + " \"tool_calls\": [\n", + " {\n", + " \"name\": tc[\"name\"],\n", + " \"args\": tc[\"args\"]\n", + " }\n", + " for tc in (response.tool_calls or [])\n", + " ]\n", + " })\n", + "\n", + " logger.info(f\"LLM Response: {response.content}\")\n", + "\n", + " # Check if LLM wants to call tools\n", + " if not response.tool_calls or len(response.tool_calls) == 0:\n", + " logger.info(\"No tool calls requested. Task complete.\")\n", + " break\n", + "\n", + " # Execute tool calls\n", + " for tool_call in response.tool_calls:\n", + " tool_name = tool_call[\"name\"]\n", + " tool_args = tool_call[\"args\"]\n", + "\n", + " logger.info(f\"Executing tool: {tool_name}\")\n", + " logger.debug(f\"Tool arguments: {tool_args}\")\n", + "\n", + " # Execute tool via protocol client (database_id and context already set in client)\n", + " tool_result = await self.protocol_client.call_tool(\n", + " tool_name,\n", + " tool_args\n", + " )\n", + "\n", + " logger.info(f\"Tool result success: {tool_result.get('success')}\")\n", + "\n", + " # Track tools used\n", + " if tool_name not in tools_used:\n", + " tools_used.append(tool_name)\n", + "\n", + " tool_results.append({\n", + " \"tool_name\": tool_name,\n", + " \"arguments\": tool_args,\n", + " \"result\": tool_result\n", + " })\n", + "\n", + " # Add tool result to conversation\n", + " tool_message = ToolMessage(\n", + " content=json.dumps(tool_result.get(\"result\", {})),\n", + " tool_call_id=tool_call.get(\"id\", \"\")\n", + " )\n", + " messages.append(tool_message)\n", + "\n", + " conversation_flow.append({\n", + " \"type\": \"tool_result\",\n", + " \"tool_name\": tool_name,\n", + " \"result\": tool_result\n", + " })\n", + "\n", + " return {\n", + " \"final_response\": messages[-1].content if messages else \"\",\n", + " \"conversation_flow\": conversation_flow,\n", + " \"tools_used\": tools_used,\n", + " \"tool_results\": tool_results,\n", + " \"messages\": messages\n", + " }\n", + "\n", + " async def _run_verifiers(self, task_result: Dict[str, Any]) -> Dict[str, Any]:\n", + " \"\"\"Run all configured verifiers\"\"\"\n", + " logger.info(\"\\n--- Running Verifiers ---\")\n", + "\n", + " verification_results = {}\n", + "\n", + " for i, verifier_config in enumerate(self.config.verifiers):\n", + " verifier = VerifierConfig(**verifier_config)\n", + " verifier_name = verifier.name or f\"verifier_{i+1}\"\n", + "\n", + " logger.info(f\"Running verifier: {verifier_name} ({verifier.verifier_type})\")\n", + "\n", + " model_response = {\n", + " \"content\": task_result.get(\"final_response\", \"\"),\n", + " \"tool_calls\": [\n", + " {\"name\": tr[\"tool_name\"], \"args\": tr[\"arguments\"]}\n", + " for tr in task_result.get(\"tool_results\", [])\n", + " ]\n", + " }\n", + "\n", + " result = await self.verifier_engine.execute_verifier(\n", + " verifier,\n", + " model_response,\n", + " self.config.database_id,\n", + " self.config.context\n", + " )\n", + "\n", + " verification_results[verifier_name] = result\n", + "\n", + " logger.info(f\"Verifier result: {'PASSED' if result.get('passed') else 'FAILED'}\")\n", + " if not result.get(\"passed\"):\n", + " logger.warning(f\"Failure reason: {result.get('error') or result.get('details')}\")\n", + "\n", + " return verification_results\n", + "\n", + " async def execute_benchmark(self) -> Dict[str, Any]:\n", + " \"\"\"Execute complete benchmark with multiple runs\"\"\"\n", + " logger.info(f\"\\n{'='*80}\")\n", + " logger.info(f\"STARTING BENCHMARK EXECUTION\")\n", + " logger.info(f\"Mode: {self.execution_mode.upper()}\")\n", + " logger.info(f\"Model: {self.config.llm_provider}/{self.config.llm_model}\")\n", + " logger.info(f\"Number of runs: {self.config.number_of_runs}\")\n", + " logger.info(f\"{'='*80}\\n\")\n", + "\n", + " # Create database: use seed_database_file if provided, otherwise use gym's api/sample-data\n", + " seed_file = self.config.seed_database_file\n", + " sql_content = None # Will store SQL content for resets\n", + "\n", + " if seed_file and seed_file.strip():\n", + " # Use custom SQL file\n", + " logger.info(f\"Using seed database file: {seed_file}\")\n", + " config_dir = os.path.dirname(os.path.abspath(\"config.json\")) if os.path.exists(\"config.json\") else \"\"\n", + " created_db_id = create_database_from_file(\n", + " self.config.gym_enviornment_url,\n", + " seed_file,\n", + " config_dir\n", + " )\n", + "\n", + " # Read and store SQL content for reset operations\n", + " sql_file_path = seed_file if os.path.isabs(seed_file) else os.path.join(config_dir, seed_file)\n", + " if os.path.exists(sql_file_path):\n", + " with open(sql_file_path, 'r', encoding='utf-8') as f:\n", + " sql_content = f.read()\n", + " else:\n", + " # Use gym's api/sample-data\n", + " logger.info(\"Using gym's api/sample-data for database creation...\")\n", + " created_db_id = create_database(self.config.gym_enviornment_url)\n", + "\n", + " if created_db_id:\n", + " self.config.database_id = created_db_id\n", + " self.auto_created_database = True\n", + " logger.info(f\"Using database: {created_db_id}\")\n", + " else:\n", + " logger.error(\"Failed to create database. Cannot proceed.\")\n", + " raise RuntimeError(\"Database creation failed\")\n", + "\n", + " try:\n", + " # Update initialize() to pass sql_content to protocol clients\n", + " await self.initialize()\n", + "\n", + " # Store sql_content in protocol_client for resets\n", + " if sql_content and self.protocol_client:\n", + " self.protocol_client.sql_content = sql_content\n", + "\n", + " # Reset already done in initialize(), but log it\n", + " logger.info(\"Environment initialized and reset\")\n", + "\n", + " all_runs = []\n", + "\n", + " for run_number in range(1, self.config.number_of_runs + 1):\n", + " try:\n", + " run_result = await self.execute_single_run(run_number)\n", + " all_runs.append(run_result)\n", + " except Exception as e:\n", + " logger.error(f\"Run {run_number} failed with error: {e}\")\n", + " all_runs.append({\n", + " \"run_number\": run_number,\n", + " \"error\": str(e),\n", + " \"overall_success\": False\n", + " })\n", + "\n", + " # Calculate statistics\n", + " statistics = self._calculate_statistics(all_runs)\n", + "\n", + " result = {\n", + " \"benchmark_config\": {\n", + " \"execution_mode\": self.execution_mode,\n", + " \"model\": f\"{self.config.llm_provider}/{self.config.llm_model}\",\n", + " \"number_of_runs\": self.config.number_of_runs,\n", + " \"user_prompt\": self.config.user_prompt,\n", + " \"database_id\": self.config.database_id,\n", + " \"seed_database_file\": seed_file or \"(gym's api/sample-data)\",\n", + " \"auto_created_database\": self.auto_created_database\n", + " },\n", + " \"runs\": all_runs,\n", + " \"statistics\": statistics\n", + " }\n", + "\n", + " logger.info(f\"\\n{'='*80}\")\n", + " logger.info(f\"BENCHMARK COMPLETED\")\n", + " logger.info(f\"Overall Success Rate: {statistics['overall_success_rate']:.1%} ({statistics['successful_runs']}/{statistics['total_runs']} runs)\")\n", + " logger.info(f\"Verifier Pass Rate: {statistics['verifier_level_pass_rate']:.1%} ({statistics['total_verifiers_passed']}/{statistics['total_verifiers_checked']} verifiers)\")\n", + " logger.info(f\"Pass@1: {statistics['pass_at_1']:.1%}\")\n", + " logger.info(f\"Mean execution time: {statistics['mean_execution_time_ms']:.0f}ms\")\n", + " logger.info(f\"\\nIndividual Verifier Statistics:\")\n", + " for verifier_name, stats in statistics.get('individual_verifier_stats', {}).items():\n", + " logger.info(f\" - {verifier_name}: {stats['pass_rate']:.1%} ({stats['passed']}/{stats['total']})\")\n", + " logger.info(f\"{'='*80}\\n\")\n", + "\n", + " return result\n", + "\n", + " finally:\n", + " # Cleanup: Delete auto-created database\n", + " if self.auto_created_database and self.config.database_id:\n", + " logger.info(f\"\\nCleaning up auto-created database...\")\n", + " print(\"DATABASE_ID: \", self.config.database_id)\n", + " # delete_database(self.config.gym_enviornment_url, self.config.database_id)\n", + "\n", + " def _calculate_statistics(self, runs: List[Dict[str, Any]]) -> Dict[str, Any]:\n", + " \"\"\"Calculate benchmark statistics\"\"\"\n", + " successful_runs = [r for r in runs if r.get(\"overall_success\")]\n", + " total_runs = len(runs)\n", + "\n", + " # Overall success rate (all verifiers must pass)\n", + " overall_success_rate = len(successful_runs) / total_runs if total_runs > 0 else 0\n", + "\n", + " # Pass@1: success on first run\n", + " pass_at_1 = 1.0 if runs and runs[0].get(\"overall_success\") else 0.0\n", + "\n", + " # Verifier-level statistics\n", + " total_verifiers_count = 0\n", + " passed_verifiers_count = 0\n", + " verifier_pass_rates = {}\n", + "\n", + " for run in runs:\n", + " if \"verification_summary\" in run:\n", + " total_verifiers_count += run[\"verification_summary\"][\"total\"]\n", + " passed_verifiers_count += run[\"verification_summary\"][\"passed\"]\n", + "\n", + " # Track individual verifier pass rates\n", + " for verifier_name, result in run.get(\"verification_results\", {}).items():\n", + " if verifier_name not in verifier_pass_rates:\n", + " verifier_pass_rates[verifier_name] = {\"passed\": 0, \"total\": 0}\n", + " verifier_pass_rates[verifier_name][\"total\"] += 1\n", + " if result.get(\"passed\", False):\n", + " verifier_pass_rates[verifier_name][\"passed\"] += 1\n", + "\n", + " # Calculate pass rate for each verifier\n", + " verifier_stats = {}\n", + " for verifier_name, counts in verifier_pass_rates.items():\n", + " verifier_stats[verifier_name] = {\n", + " \"passed\": counts[\"passed\"],\n", + " \"total\": counts[\"total\"],\n", + " \"pass_rate\": counts[\"passed\"] / counts[\"total\"] if counts[\"total\"] > 0 else 0.0\n", + " }\n", + "\n", + " # Overall verifier pass rate\n", + " verifier_level_pass_rate = passed_verifiers_count / total_verifiers_count if total_verifiers_count > 0 else 0\n", + "\n", + " # Mean execution time\n", + " execution_times = [\n", + " r.get(\"execution_time_ms\", 0)\n", + " for r in runs\n", + " if \"execution_time_ms\" in r\n", + " ]\n", + " mean_time = sum(execution_times) / len(execution_times) if execution_times else 0\n", + "\n", + " # Tool usage\n", + " all_tools = []\n", + " for run in runs:\n", + " all_tools.extend(run.get(\"tools_used\", []))\n", + "\n", + " tool_counts = {}\n", + " for tool in all_tools:\n", + " tool_counts[tool] = tool_counts.get(tool, 0) + 1\n", + "\n", + " return {\n", + " \"total_runs\": total_runs,\n", + " \"successful_runs\": len(successful_runs),\n", + " \"overall_success_rate\": overall_success_rate,\n", + " \"pass_at_1\": pass_at_1,\n", + " \"verifier_level_pass_rate\": verifier_level_pass_rate,\n", + " \"total_verifiers_checked\": total_verifiers_count,\n", + " \"total_verifiers_passed\": passed_verifiers_count,\n", + " \"individual_verifier_stats\": verifier_stats,\n", + " \"mean_execution_time_ms\": mean_time,\n", + " \"tool_usage\": tool_counts\n", + " }" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "N29yaxV58E_e" + }, + "source": [ + "## 9. Configuration Loader & Main Execution\n", + "\n", + "Configuration loading utility and main execution function to run the complete benchmark." + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "piBZa5Cz8E_e", + "outputId": "76dba87a-9004-45b4-886b-4d80e80df02e" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Configuration loader and main function ready\n" + ] + } + ], + "source": [ + "# ============================================================================\n", + "# CONFIGURATION LOADER\n", + "# ============================================================================\n", + "\n", + "def load_config_from_dict(config_dict: Dict[str, Any]) -> BenchmarkConfig:\n", + " \"\"\"Load configuration from CONFIG dictionary (no external file needed)\"\"\"\n", + " try:\n", + " config_data = config_dict.copy()\n", + "\n", + " # Remove comment fields (any key starting with underscore)\n", + " config_data = {k: v for k, v in config_data.items() if not k.startswith(\"_\")}\n", + "\n", + " # Clean verifiers - remove _description fields\n", + " if \"verifiers\" in config_data and config_data[\"verifiers\"]:\n", + " cleaned_verifiers = []\n", + " for verifier in config_data[\"verifiers\"]:\n", + " cleaned_verifier = {k: v for k, v in verifier.items() if not k.startswith(\"_\")}\n", + " cleaned_verifiers.append(cleaned_verifier)\n", + " config_data[\"verifiers\"] = cleaned_verifiers\n", + "\n", + " # Validate required fields\n", + " required_fields = [\n", + " \"gym_enviornment_url\",\n", + " \"seed_database_file\",\n", + " \"system_prompt\",\n", + " \"user_prompt\",\n", + " \"llm_model\",\n", + " \"llm_provider\",\n", + " \"llm_api_key\"\n", + " ]\n", + "\n", + " for field in required_fields:\n", + " if field not in config_data:\n", + " raise ValueError(f\"Missing required field in CONFIG: {field}\")\n", + "\n", + " # Set defaults\n", + " config_data.setdefault(\"mcp_endpoint\", \"/mcp\")\n", + " config_data.setdefault(\"verifiers\", [])\n", + " config_data.setdefault(\"number_of_runs\", 1)\n", + " config_data.setdefault(\"context\", {})\n", + " config_data.setdefault(\"temperature\", 0.0)\n", + " config_data.setdefault(\"max_tokens\", 4096)\n", + " config_data.setdefault(\"reset_database_between_runs\", True)\n", + " config_data.setdefault(\"execution_mode\", \"openenv\")\n", + "\n", + " # Validate execution_mode\n", + " if config_data[\"execution_mode\"] not in [\"openenv\", \"mcp\"]:\n", + " raise ValueError(f\"Invalid execution_mode: {config_data['execution_mode']}. Must be 'openenv' or 'mcp'\")\n", + "\n", + " return BenchmarkConfig(**config_data)\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"Failed to load configuration: {e}\")\n", + " raise\n", + "\n", + "\n", + "# ============================================================================\n", + "# MAIN ENTRY POINT (Uses CONFIG from cell 5)\n", + "# ============================================================================\n", + "\n", + "async def main(config_dict: Dict[str, Any] = None):\n", + " \"\"\"Main entry point - uses CONFIG dictionary from notebook\"\"\"\n", + " try:\n", + " # Use provided config_dict or expect CONFIG to be defined globally\n", + " if config_dict is None:\n", + " if 'CONFIG' not in globals():\n", + " raise ValueError(\"CONFIG dictionary not found. Please define it in cell 5.\")\n", + " config_dict = CONFIG\n", + "\n", + " # Load configuration from dictionary\n", + " config = load_config_from_dict(config_dict)\n", + "\n", + " # Create executor\n", + " executor = BenchmarkExecutor(config)\n", + "\n", + " # Execute benchmark\n", + " result = await executor.execute_benchmark()\n", + "\n", + " # Save results to file\n", + " output_file = f\"benchmark_results_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json\"\n", + " with open(output_file, 'w') as f:\n", + " json.dump(result, f, indent=2, default=str)\n", + "\n", + " logger.info(f\"\\nResults saved to: {output_file}\")\n", + "\n", + " # Print summary\n", + " print(\"\\n\" + \"=\"*80)\n", + " print(\"BENCHMARK SUMMARY\")\n", + " print(\"=\"*80)\n", + " print(f\"Execution Mode: {result['benchmark_config']['execution_mode'].upper()}\")\n", + " print(f\"Model: {result['benchmark_config']['model']}\")\n", + " print(f\"Runs: {result['statistics']['total_runs']}\")\n", + " print(f\"Overall Success Rate: {result['statistics']['overall_success_rate']:.1%} ({result['statistics']['successful_runs']}/{result['statistics']['total_runs']} runs)\")\n", + " print(f\"Verifier Pass Rate: {result['statistics']['verifier_level_pass_rate']:.1%} ({result['statistics']['total_verifiers_passed']}/{result['statistics']['total_verifiers_checked']} verifiers)\")\n", + " print(f\"Pass@1: {result['statistics']['pass_at_1']:.1%}\")\n", + " print(f\"Mean Execution Time: {result['statistics']['mean_execution_time_ms']:.0f}ms\")\n", + " print(\"\\nIndividual Verifier Statistics:\")\n", + " for verifier_name, stats in result['statistics'].get('individual_verifier_stats', {}).items():\n", + " print(f\" - {verifier_name}: {stats['pass_rate']:.1%} ({stats['passed']}/{stats['total']})\")\n", + " print(\"\\nTool Usage:\")\n", + " for tool, count in result['statistics']['tool_usage'].items():\n", + " print(f\" - {tool}: {count} calls\")\n", + " print(\"=\"*80)\n", + "\n", + " return result\n", + "\n", + " except Exception as e:\n", + " logger.error(f\"Benchmark execution failed: {e}\", exc_info=True)\n", + " raise\n", + "\n", + "print(\"Configuration loader and main function ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JErjQF6m8E_e" + }, + "source": [ + "## Execute Benchmark\n", + "\n", + "Run the cell below to execute the benchmark with your configuration." + ] + }, + { + "cell_type": "code", + "execution_count": 84, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "Wg8rQh6U8E_e", + "outputId": "627845f6-ba42-477a-8b3b-8a3b2738f537" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "WARNING:__main__:Failure reason: Comparison equals: 0 vs 1\n", + "WARNING:__main__:Failure reason: Comparison equals: 0 vs 1\n", + "WARNING:__main__:Failure reason: Comparison equals: 0 vs 1\n", + "WARNING:__main__:Failure reason: Comparison equals: 0 vs 1\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DATABASE_ID: db_1769535944605_7a0xldyiq\n", + "\n", + "================================================================================\n", + "BENCHMARK SUMMARY\n", + "================================================================================\n", + "Execution Mode: OPENENV\n", + "Model: openai/gpt-5.2\n", + "Runs: 2\n", + "Overall Success Rate: 0.0% (0/2 runs)\n", + "Verifier Pass Rate: 60.0% (6/10 verifiers)\n", + "Pass@1: 0.0%\n", + "Mean Execution Time: 20168ms\n", + "\n", + "Individual Verifier Statistics:\n", + " - Project Calendar Visible: 100.0% (2/2)\n", + " - Event Moved to Primary: 50.0% (1/2)\n", + " - Event Description Updated: 50.0% (1/2)\n", + " - Kickoff Event Created: 0.0% (0/2)\n", + " - Verify Old mapping removed: 100.0% (2/2)\n", + "\n", + "Tool Usage:\n", + " - get_calendar_list: 2 calls\n", + " - update_calendar_in_list: 1 calls\n", + " - list_events: 1 calls\n", + " - move_event: 1 calls\n", + " - patch_event: 1 calls\n", + " - create_event: 1 calls\n", + "================================================================================\n" + ] + }, + { + "data": { + "text/plain": [ + "{'benchmark_config': {'execution_mode': 'openenv',\n", + " 'model': 'openai/gpt-5.2',\n", + " 'number_of_runs': 2,\n", + " 'user_prompt': 'Help me tidy up my calendars for Q4? First, make sure my Project Management calendar is showing and selected. Then find my \"Sprint Planning & Architecture Review\" meeting and move the latest one to my main calendar so it\\'s easier to track. Update its description to something like \"Updated for Q4 planning with new architecture goals and sprint alignment.\"\\nAfter that, add a new event called \"Q4 Initiative Kickoff – Engineering & UX\" to the Project Management calendar for next Thursday from 10AM-12PM. That should cover everything I need!',\n", + " 'database_id': 'db_1769535944605_7a0xldyiq',\n", + " 'seed_database_file': '/content/seed-db.sql',\n", + " 'auto_created_database': True},\n", + " 'runs': [{'run_number': 1,\n", + " 'started_at': '2026-01-27T17:45:46.615589+00:00',\n", + " 'execution_time_ms': 23452,\n", + " 'model_response': '- **Project Management calendar**: confirmed it’s **visible and selected** (Calendar ID: `alice-projects`).\\n- **Moved latest “Sprint Planning & Architecture Review”**: moved event `event-corrected-001` from **Project Management** → **Main (primary) calendar** (`alice-primary`), and updated the description to: \\n **“Updated for Q4 planning with new architecture goals and sprint alignment.”**\\n- **Created new event** on **Project Management** calendar:\\n - **Title:** Q4 Initiative Kickoff – Engineering & UX\\n - **When:** **Thu, Jan 29, 2026, 10:00 AM – 12:00 PM** (America/New_York)\\n - **Event ID:** `d4a790ae-eddb-41f2-ae65-b8c5c5234978`',\n", + " 'conversation_flow': [{'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'get_calendar_list', 'args': {}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 2,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#calendarList\",\\n \"etag\": \"etag-list-db_1769535944605_7a0xldyiq\",\\n \"nextPageToken\": null,\\n \"nextSyncToken\": \"MjAyNi0wMS0yN1QxNzo0NTo0OS4xMzg3NTU=\",\\n \"items\": [\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-primary-2026-01-27T17:45:45\",\\n \"id\": \"alice-primary\",\\n \"summary\": \"Alice Johnson\",\\n \"description\": \"Primary calendar for Alice Johnson - Project Manager\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"1\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": true,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n },\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-projects-2026-01-27T17:45:45\",\\n \"id\": \"alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"7\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n },\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-team-2026-01-27T17:45:45\",\\n \"id\": \"alice-team\",\\n \"summary\": \"Team Coordination\",\\n \"description\": \"Team meetings, 1-on-1s, and team events\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"11\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n }\\n ]\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'update_calendar_in_list',\n", + " 'args': {'calendarId': 'alice-projects',\n", + " 'hidden': False,\n", + " 'selected': True}},\n", + " {'name': 'list_events',\n", + " 'args': {'calendarId': 'alice-projects',\n", + " 'q': 'Sprint Planning & Architecture Review',\n", + " 'singleEvents': True,\n", + " 'orderBy': 'startTime',\n", + " 'timeMin': '2025-01-27T00:00:00-05:00',\n", + " 'timeMax': '2026-12-31T23:59:59-05:00',\n", + " 'maxResults': 2500}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'update_calendar_in_list',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 3,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'update_calendar_in_list',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'hidden': False,\n", + " 'selected': True}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-projects-2026-01-27T17:45:45\",\\n \"id\": \"alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"7\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'list_events',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 4,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'list_events',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'q': 'Sprint Planning & Architecture Review',\n", + " 'singleEvents': True,\n", + " 'orderBy': 'startTime',\n", + " 'timeMin': '2025-01-27T00:00:00-05:00',\n", + " 'timeMax': '2026-12-31T23:59:59-05:00',\n", + " 'maxResults': 2500}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#events\",\\n \"etag\": \"etag-events-alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"updated\": \"2026-01-27T17:45:54.715123+00:00\",\\n \"timeZone\": \"America/New_York\",\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": [],\\n \"nextPageToken\": null,\\n \"nextSyncToken\": null,\\n \"items\": [\\n {\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516145.0\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:45:45\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Detailed sprint planning session with architecture discussion for Q4 features. We\\'ll review user stories, estimate effort, and plan the technical approach.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 1,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n }\\n ]\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'move_event',\n", + " 'args': {'calendarId': 'alice-projects',\n", + " 'eventId': 'event-corrected-001',\n", + " 'destination': 'alice-primary',\n", + " 'sendUpdates': 'all'}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'move_event',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 5,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'move_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'eventId': 'event-corrected-001',\n", + " 'destination': 'alice-primary',\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516157.58162\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:45:57.581620\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Detailed sprint planning session with architecture discussion for Q4 features. We\\'ll review user stories, estimate effort, and plan the technical approach.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 1,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'patch_event',\n", + " 'args': {'calendarId': 'alice-primary',\n", + " 'eventId': 'event-corrected-001',\n", + " 'description': 'Updated for Q4 planning with new architecture goals and sprint alignment.',\n", + " 'sendUpdates': 'all'}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'patch_event',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 6,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'patch_event',\n", + " 'arguments': {'calendarId': 'alice-primary',\n", + " 'eventId': 'event-corrected-001',\n", + " 'description': 'Updated for Q4 planning with new architecture goals and sprint alignment.',\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516160.36052\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:46:00.360520\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Updated for Q4 planning with new architecture goals and sprint alignment.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 2,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'create_event',\n", + " 'args': {'calendarId': 'alice-projects',\n", + " 'summary': 'Q4 Initiative Kickoff – Engineering & UX',\n", + " 'start': {'dateTime': '2026-01-29T10:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'end': {'dateTime': '2026-01-29T12:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'sendUpdates': 'all'}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'create_event',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 7,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'create_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'summary': 'Q4 Initiative Kickoff – Engineering & UX',\n", + " 'start': {'dateTime': '2026-01-29T10:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'end': {'dateTime': '2026-01-29T12:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516163.408931\\\\\"\",\\n \"id\": \"d4a790ae-eddb-41f2-ae65-b8c5c5234978\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=d4a790ae-eddb-41f2-ae65-b8c5c5234978\",\\n \"created\": \"2026-01-27T17:46:03.408927\",\\n \"updated\": \"2026-01-27T17:46:03.408931\",\\n \"summary\": \"Q4 Initiative Kickoff \\\\u2013 Engineering & UX\",\\n \"description\": null,\\n \"location\": null,\\n \"colorId\": null,\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-29T10:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-29T12:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": null,\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-29T10:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": \"opaque\",\\n \"visibility\": \"default\",\\n \"iCalUID\": \"d4a790ae-eddb-41f2-ae65-b8c5c5234978@calendar.google.com\",\\n \"sequence\": 0,\\n \"attendees\": [],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": null,\\n \"hangoutLink\": null,\\n \"conferenceData\": null,\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": false,\\n \"locked\": false,\\n \"reminders\": {\\n \"overrides\": [],\\n \"useDefault\": false\\n },\\n \"source\": null,\\n \"attachments\": [],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 201,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'type': 'ai_message',\n", + " 'content': '- **Project Management calendar**: confirmed it’s **visible and selected** (Calendar ID: `alice-projects`).\\n- **Moved latest “Sprint Planning & Architecture Review”**: moved event `event-corrected-001` from **Project Management** → **Main (primary) calendar** (`alice-primary`), and updated the description to: \\n **“Updated for Q4 planning with new architecture goals and sprint alignment.”**\\n- **Created new event** on **Project Management** calendar:\\n - **Title:** Q4 Initiative Kickoff – Engineering & UX\\n - **When:** **Thu, Jan 29, 2026, 10:00 AM – 12:00 PM** (America/New_York)\\n - **Event ID:** `d4a790ae-eddb-41f2-ae65-b8c5c5234978`',\n", + " 'tool_calls': []}],\n", + " 'tools_used': ['get_calendar_list',\n", + " 'update_calendar_in_list',\n", + " 'list_events',\n", + " 'move_event',\n", + " 'patch_event',\n", + " 'create_event'],\n", + " 'tool_results': [{'tool_name': 'get_calendar_list',\n", + " 'arguments': {},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 2,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#calendarList\",\\n \"etag\": \"etag-list-db_1769535944605_7a0xldyiq\",\\n \"nextPageToken\": null,\\n \"nextSyncToken\": \"MjAyNi0wMS0yN1QxNzo0NTo0OS4xMzg3NTU=\",\\n \"items\": [\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-primary-2026-01-27T17:45:45\",\\n \"id\": \"alice-primary\",\\n \"summary\": \"Alice Johnson\",\\n \"description\": \"Primary calendar for Alice Johnson - Project Manager\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"1\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": true,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n },\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-projects-2026-01-27T17:45:45\",\\n \"id\": \"alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"7\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n },\\n {\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-team-2026-01-27T17:45:45\",\\n \"id\": \"alice-team\",\\n \"summary\": \"Team Coordination\",\\n \"description\": \"Team meetings, 1-on-1s, and team events\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"11\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n }\\n ]\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'tool_name': 'update_calendar_in_list',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'hidden': False,\n", + " 'selected': True},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 3,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'update_calendar_in_list',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'hidden': False,\n", + " 'selected': True}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#calendarListEntry\",\\n \"etag\": \"etag-list-alice-projects-2026-01-27T17:45:45\",\\n \"id\": \"alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"location\": null,\\n \"timeZone\": \"America/New_York\",\\n \"summaryOverride\": null,\\n \"colorId\": \"7\",\\n \"backgroundColor\": null,\\n \"foregroundColor\": null,\\n \"hidden\": false,\\n \"selected\": true,\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": null,\\n \"notificationSettings\": null,\\n \"primary\": false,\\n \"deleted\": false,\\n \"conferenceProperties\": {\\n \"allowedConferenceSolutionTypes\": []\\n }\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'tool_name': 'list_events',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'q': 'Sprint Planning & Architecture Review',\n", + " 'singleEvents': True,\n", + " 'orderBy': 'startTime',\n", + " 'timeMin': '2025-01-27T00:00:00-05:00',\n", + " 'timeMax': '2026-12-31T23:59:59-05:00',\n", + " 'maxResults': 2500},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 4,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'list_events',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'q': 'Sprint Planning & Architecture Review',\n", + " 'singleEvents': True,\n", + " 'orderBy': 'startTime',\n", + " 'timeMin': '2025-01-27T00:00:00-05:00',\n", + " 'timeMax': '2026-12-31T23:59:59-05:00',\n", + " 'maxResults': 2500}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#events\",\\n \"etag\": \"etag-events-alice-projects\",\\n \"summary\": \"Project Management\",\\n \"description\": \"Project meetings, deadlines, and milestones\",\\n \"updated\": \"2026-01-27T17:45:54.715123+00:00\",\\n \"timeZone\": \"America/New_York\",\\n \"accessRole\": \"owner\",\\n \"defaultReminders\": [],\\n \"nextPageToken\": null,\\n \"nextSyncToken\": null,\\n \"items\": [\\n {\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516145.0\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:45:45\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Detailed sprint planning session with architecture discussion for Q4 features. We\\'ll review user stories, estimate effort, and plan the technical approach.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 1,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n }\\n ]\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'tool_name': 'move_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'eventId': 'event-corrected-001',\n", + " 'destination': 'alice-primary',\n", + " 'sendUpdates': 'all'},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 5,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'move_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'eventId': 'event-corrected-001',\n", + " 'destination': 'alice-primary',\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516157.58162\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:45:57.581620\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Detailed sprint planning session with architecture discussion for Q4 features. We\\'ll review user stories, estimate effort, and plan the technical approach.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 1,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'tool_name': 'patch_event',\n", + " 'arguments': {'calendarId': 'alice-primary',\n", + " 'eventId': 'event-corrected-001',\n", + " 'description': 'Updated for Q4 planning with new architecture goals and sprint alignment.',\n", + " 'sendUpdates': 'all'},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 6,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'patch_event',\n", + " 'arguments': {'calendarId': 'alice-primary',\n", + " 'eventId': 'event-corrected-001',\n", + " 'description': 'Updated for Q4 planning with new architecture goals and sprint alignment.',\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516160.36052\\\\\"\",\\n \"id\": \"event-corrected-001\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=event-corrected-001\",\\n \"created\": \"2026-01-27T17:45:45\",\\n \"updated\": \"2026-01-27T17:46:00.360520\",\\n \"summary\": \"Sprint Planning & Architecture Review\",\\n \"description\": \"Updated for Q4 planning with new architecture goals and sprint alignment.\",\\n \"location\": \"Conference Room A, Building 1\",\\n \"colorId\": \"7\",\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.johnson@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-27T11:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": \"NULL\",\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-27T09:00:00+00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": null,\\n \"visibility\": \"default\",\\n \"iCalUID\": \"event-corrected-001@gmail.com\",\\n \"sequence\": 2,\\n \"attendees\": [\\n {\\n \"id\": \"att-corrected-001\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": \"Looking forward to planning Q4!\",\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-002\",\\n \"email\": \"bob.smith@techcorp.com\",\\n \"displayName\": \"Bob Smith\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-003\",\\n \"email\": \"carol.white@techcorp.com\",\\n \"displayName\": \"Carol White\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"tentative\",\\n \"comment\": \"Will join if no conflicts with user research session\",\\n \"additionalGuests\": 0,\\n \"optional\": true,\\n \"resource\": false\\n },\\n {\\n \"id\": \"att-corrected-004\",\\n \"email\": null,\\n \"displayName\": \"Conference Room A\",\\n \"self\": false,\\n \"organizer\": false,\\n \"responseStatus\": \"accepted\",\\n \"comment\": null,\\n \"additionalGuests\": 0,\\n \"optional\": false,\\n \"resource\": true\\n }\\n ],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": {\\n \"private\": {\\n \"departmentBudget\": \"engineering\",\\n \"projectCode\": \"PROJ-2024-Q4\"\\n },\\n \"shared\": {\\n \"meetingType\": \"sprint_planning\",\\n \"priority\": \"high\"\\n }\\n },\\n \"hangoutLink\": null,\\n \"conferenceData\": {\\n \"conferenceSolution\": {\\n \"iconUri\": null,\\n \"key\": {\\n \"type\": \"hangoutsMeet\"\\n },\\n \"name\": null\\n },\\n \"createRequest\": {\\n \"conferenceSolutionKey\": null,\\n \"requestId\": \"req-sprint-planning-001\",\\n \"status\": {\\n \"statusCode\": \"success\"\\n }\\n },\\n \"entryPoints\": [\\n {\\n \"accessCode\": null,\\n \"entryPointType\": \"video\",\\n \"label\": null,\\n \"meetingCode\": null,\\n \"passcode\": null,\\n \"password\": null,\\n \"pin\": null,\\n \"uri\": \"https://meet.google.com/abc-defg-hij\"\\n }\\n ],\\n \"notes\": null,\\n \"signature\": null\\n },\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": null,\\n \"locked\": null,\\n \"reminders\": {\\n \"overrides\": [\\n {\\n \"method\": \"email\",\\n \"minutes\": 1440\\n },\\n {\\n \"method\": \"popup\",\\n \"minutes\": 30\\n }\\n ],\\n \"useDefault\": false\\n },\\n \"source\": {\\n \"url\": \"https://jira.techcorp.com/sprint-planning-q4\",\\n \"title\": \"Sprint Planning Board\"\\n },\\n \"attachments\": [\\n {\\n \"fileUrl\": \"https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view\",\\n \"title\": \"view\"\\n },\\n {\\n \"fileUrl\": \"https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit\",\\n \"title\": \"edit\"\\n }\\n ],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 200,\n", + " 'isError': False}},\n", + " 'reward': 1.0}},\n", + " {'tool_name': 'create_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'summary': 'Q4 Initiative Kickoff – Engineering & UX',\n", + " 'start': {'dateTime': '2026-01-29T10:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'end': {'dateTime': '2026-01-29T12:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'sendUpdates': 'all'},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': 1.0,\n", + " 'metadata': {'step': 7,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'create_event',\n", + " 'arguments': {'calendarId': 'alice-projects',\n", + " 'summary': 'Q4 Initiative Kickoff – Engineering & UX',\n", + " 'start': {'dateTime': '2026-01-29T10:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'end': {'dateTime': '2026-01-29T12:00:00-05:00',\n", + " 'timeZone': 'America/New_York'},\n", + " 'sendUpdates': 'all'}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': '{\\n \"kind\": \"calendar#event\",\\n \"etag\": \"\\\\\"1769516163.408931\\\\\"\",\\n \"id\": \"d4a790ae-eddb-41f2-ae65-b8c5c5234978\",\\n \"status\": \"confirmed\",\\n \"htmlLink\": \"https://calendar.google.com/event?eid=d4a790ae-eddb-41f2-ae65-b8c5c5234978\",\\n \"created\": \"2026-01-27T17:46:03.408927\",\\n \"updated\": \"2026-01-27T17:46:03.408931\",\\n \"summary\": \"Q4 Initiative Kickoff \\\\u2013 Engineering & UX\",\\n \"description\": null,\\n \"location\": null,\\n \"colorId\": null,\\n \"creator\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"organizer\": {\\n \"id\": \"alice_manager\",\\n \"email\": \"alice.manager@techcorp.com\",\\n \"displayName\": \"Alice Johnson\",\\n \"self\": true\\n },\\n \"start\": {\\n \"dateTime\": \"2026-01-29T10:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"end\": {\\n \"dateTime\": \"2026-01-29T12:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"endTimeUnspecified\": false,\\n \"recurrence\": [],\\n \"recurringEventId\": null,\\n \"originalStartTime\": {\\n \"dateTime\": \"2026-01-29T10:00:00\",\\n \"date\": null,\\n \"timeZone\": \"America/New_York\"\\n },\\n \"transparency\": \"opaque\",\\n \"visibility\": \"default\",\\n \"iCalUID\": \"d4a790ae-eddb-41f2-ae65-b8c5c5234978@calendar.google.com\",\\n \"sequence\": 0,\\n \"attendees\": [],\\n \"attendeesOmitted\": false,\\n \"extendedProperties\": null,\\n \"hangoutLink\": null,\\n \"conferenceData\": null,\\n \"guestsCanInviteOthers\": true,\\n \"guestsCanModify\": false,\\n \"guestsCanSeeOtherGuests\": true,\\n \"privateCopy\": false,\\n \"locked\": false,\\n \"reminders\": {\\n \"overrides\": [],\\n \"useDefault\": false\\n },\\n \"source\": null,\\n \"attachments\": [],\\n \"eventType\": \"default\",\\n \"birthdayProperties\": null,\\n \"focusTimeProperties\": null,\\n \"outOfOfficeProperties\": null,\\n \"workingLocationProperties\": null\\n}',\n", + " 'status_code': 201,\n", + " 'isError': False}},\n", + " 'reward': 1.0}}],\n", + " 'verification_results': {'Project Calendar Visible': {'passed': True,\n", + " 'expected': 1,\n", + " 'actual': 1,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM calendars WHERE calendar_id='alice-projects' AND hidden=0 AND selected=1;\",\n", + " 'details': 'Comparison equals: 1 vs 1'},\n", + " 'Event Moved to Primary': {'passed': True,\n", + " 'expected': 1,\n", + " 'actual': 1,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-primary';\",\n", + " 'details': 'Comparison equals: 1 vs 1'},\n", + " 'Event Description Updated': {'passed': True,\n", + " 'expected': 1,\n", + " 'actual': 1,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND description LIKE '%Q4%' AND description LIKE '%planning%' AND description LIKE '%architecture%' \",\n", + " 'details': 'Comparison equals: 1 vs 1'},\n", + " 'Kickoff Event Created': {'passed': False,\n", + " 'expected': 1,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE summary='Q4 Initiative Kickoff – Engineering & UX' AND calendar_id='alice-projects' AND start_datetime LIKE '%2026-01-15 10:00%'\\nAND end_datetime LIKE '%2026-01-15 12:00:%'AND status='confirmed'\",\n", + " 'details': 'Comparison equals: 0 vs 1'},\n", + " 'Verify Old mapping removed': {'passed': True,\n", + " 'expected': 0,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-projects';\",\n", + " 'details': 'Comparison equals: 0 vs 0'}},\n", + " 'verification_summary': {'total': 5,\n", + " 'passed': 4,\n", + " 'failed': 1,\n", + " 'pass_rate': 0.8},\n", + " 'overall_success': False},\n", + " {'run_number': 2,\n", + " 'started_at': '2026-01-27T17:46:10.067973+00:00',\n", + " 'execution_time_ms': 16884,\n", + " 'model_response': 'I can do this, but I’m currently blocked by an API issue when listing your calendars: the `get_calendar_list` endpoint is returning entries with `accessRole: \"none\"`, which our connector rejects (it only accepts `freeBusyReader/reader/writer/owner`). Because of that, I can’t reliably identify:\\n\\n- which calendar is your **“Project Management”** calendar (and whether it’s hidden/unselected), or \\n- your **primary (“main”)** calendar ID, or \\n- where the latest **“Sprint Planning & Architecture Review”** event currently lives so I can move it.\\n\\n### What I *can* do immediately once calendar listing is working\\n1. Ensure **Project Management** is **visible + selected** in your calendar list.\\n2. Search for **“Sprint Planning & Architecture Review”**, pick the latest occurrence, and **move it to your primary calendar**, then update its description to: \\n > Updated for Q4 planning with new architecture goals and sprint alignment.\\n3. Create **“Q4 Initiative Kickoff – Engineering & UX”** on the **Project Management** calendar for **next Thursday 10:00–12:00** (using your calendar time zone).\\n\\n### One quick question so I can proceed even without calendar listing\\nWhich of these do you prefer?\\n\\nA) Tell me the **calendar ID** (or the exact calendar email) for **Project Management** (and confirm your primary is just `\"primary\"`), **and** tell me which calendar currently contains “Sprint Planning & Architecture Review” (or share the event link). \\n**or** \\nB) If you don’t have IDs, tell me the **exact title** of your primary calendar as it appears in your list, and any distinguishing detail for “Project Management” (exact name or color). I’ll retry with a different lookup strategy as soon as the calendar-list issue is resolved.\\n\\nReply with A or B and the details, and I’ll execute the whole flow end-to-end.',\n", + " 'conversation_flow': [{'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'get_calendar_list', 'args': {}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': -0.5,\n", + " 'metadata': {'step': 1,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': \"3 validation errors for CalendarListResponse\\nitems.0.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.1.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.2.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\",\n", + " 'status_code': 400,\n", + " 'isError': True}},\n", + " 'reward': -0.5}},\n", + " {'type': 'ai_message',\n", + " 'content': '',\n", + " 'tool_calls': [{'name': 'get_calendar_list',\n", + " 'args': {'showHidden': True, 'showDeleted': True}}]},\n", + " {'type': 'tool_result',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': -0.5,\n", + " 'metadata': {'step': 2,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {'showHidden': True, 'showDeleted': True}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': \"3 validation errors for CalendarListResponse\\nitems.0.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.1.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.2.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\",\n", + " 'status_code': 400,\n", + " 'isError': True}},\n", + " 'reward': -0.5}},\n", + " {'type': 'ai_message',\n", + " 'content': 'I can do this, but I’m currently blocked by an API issue when listing your calendars: the `get_calendar_list` endpoint is returning entries with `accessRole: \"none\"`, which our connector rejects (it only accepts `freeBusyReader/reader/writer/owner`). Because of that, I can’t reliably identify:\\n\\n- which calendar is your **“Project Management”** calendar (and whether it’s hidden/unselected), or \\n- your **primary (“main”)** calendar ID, or \\n- where the latest **“Sprint Planning & Architecture Review”** event currently lives so I can move it.\\n\\n### What I *can* do immediately once calendar listing is working\\n1. Ensure **Project Management** is **visible + selected** in your calendar list.\\n2. Search for **“Sprint Planning & Architecture Review”**, pick the latest occurrence, and **move it to your primary calendar**, then update its description to: \\n > Updated for Q4 planning with new architecture goals and sprint alignment.\\n3. Create **“Q4 Initiative Kickoff – Engineering & UX”** on the **Project Management** calendar for **next Thursday 10:00–12:00** (using your calendar time zone).\\n\\n### One quick question so I can proceed even without calendar listing\\nWhich of these do you prefer?\\n\\nA) Tell me the **calendar ID** (or the exact calendar email) for **Project Management** (and confirm your primary is just `\"primary\"`), **and** tell me which calendar currently contains “Sprint Planning & Architecture Review” (or share the event link). \\n**or** \\nB) If you don’t have IDs, tell me the **exact title** of your primary calendar as it appears in your list, and any distinguishing detail for “Project Management” (exact name or color). I’ll retry with a different lookup strategy as soon as the calendar-list issue is resolved.\\n\\nReply with A or B and the details, and I’ll execute the whole flow end-to-end.',\n", + " 'tool_calls': []}],\n", + " 'tools_used': ['get_calendar_list'],\n", + " 'tool_results': [{'tool_name': 'get_calendar_list',\n", + " 'arguments': {},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': -0.5,\n", + " 'metadata': {'step': 1,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': \"3 validation errors for CalendarListResponse\\nitems.0.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.1.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.2.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\",\n", + " 'status_code': 400,\n", + " 'isError': True}},\n", + " 'reward': -0.5}},\n", + " {'tool_name': 'get_calendar_list',\n", + " 'arguments': {'showHidden': True, 'showDeleted': True},\n", + " 'result': {'success': True,\n", + " 'result': {'done': False,\n", + " 'reward': -0.5,\n", + " 'metadata': {'step': 2,\n", + " 'action_type': 'tool_call',\n", + " 'tool_name': 'get_calendar_list',\n", + " 'arguments': {'showHidden': True, 'showDeleted': True}},\n", + " 'success': True,\n", + " 'error_message': None,\n", + " 'tools_list': None,\n", + " 'tool_result': {'text': \"3 validation errors for CalendarListResponse\\nitems.0.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.1.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\\nitems.2.accessRole\\n Input should be 'freeBusyReader', 'reader', 'writer' or 'owner' [type=enum, input_value='none', input_type=str]\\n For further information visit https://errors.pydantic.dev/2.12/v/enum\",\n", + " 'status_code': 400,\n", + " 'isError': True}},\n", + " 'reward': -0.5}}],\n", + " 'verification_results': {'Project Calendar Visible': {'passed': True,\n", + " 'expected': 1,\n", + " 'actual': 1,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM calendars WHERE calendar_id='alice-projects' AND hidden=0 AND selected=1;\",\n", + " 'details': 'Comparison equals: 1 vs 1'},\n", + " 'Event Moved to Primary': {'passed': False,\n", + " 'expected': 1,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-primary';\",\n", + " 'details': 'Comparison equals: 0 vs 1'},\n", + " 'Event Description Updated': {'passed': False,\n", + " 'expected': 1,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND description LIKE '%Q4%' AND description LIKE '%planning%' AND description LIKE '%architecture%' \",\n", + " 'details': 'Comparison equals: 0 vs 1'},\n", + " 'Kickoff Event Created': {'passed': False,\n", + " 'expected': 1,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE summary='Q4 Initiative Kickoff – Engineering & UX' AND calendar_id='alice-projects' AND start_datetime LIKE '%2026-01-15 10:00%'\\nAND end_datetime LIKE '%2026-01-15 12:00:%'AND status='confirmed'\",\n", + " 'details': 'Comparison equals: 0 vs 1'},\n", + " 'Verify Old mapping removed': {'passed': True,\n", + " 'expected': 0,\n", + " 'actual': 0,\n", + " 'comparison_type': 'equals',\n", + " 'query': \"SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-projects';\",\n", + " 'details': 'Comparison equals: 0 vs 0'}},\n", + " 'verification_summary': {'total': 5,\n", + " 'passed': 2,\n", + " 'failed': 3,\n", + " 'pass_rate': 0.4},\n", + " 'overall_success': False}],\n", + " 'statistics': {'total_runs': 2,\n", + " 'successful_runs': 0,\n", + " 'overall_success_rate': 0.0,\n", + " 'pass_at_1': 0.0,\n", + " 'verifier_level_pass_rate': 0.6,\n", + " 'total_verifiers_checked': 10,\n", + " 'total_verifiers_passed': 6,\n", + " 'individual_verifier_stats': {'Project Calendar Visible': {'passed': 2,\n", + " 'total': 2,\n", + " 'pass_rate': 1.0},\n", + " 'Event Moved to Primary': {'passed': 1, 'total': 2, 'pass_rate': 0.5},\n", + " 'Event Description Updated': {'passed': 1, 'total': 2, 'pass_rate': 0.5},\n", + " 'Kickoff Event Created': {'passed': 0, 'total': 2, 'pass_rate': 0.0},\n", + " 'Verify Old mapping removed': {'passed': 2, 'total': 2, 'pass_rate': 1.0}},\n", + " 'mean_execution_time_ms': 20168.0,\n", + " 'tool_usage': {'get_calendar_list': 2,\n", + " 'update_calendar_in_list': 1,\n", + " 'list_events': 1,\n", + " 'move_event': 1,\n", + " 'patch_event': 1,\n", + " 'create_event': 1}}}" + ] + }, + "execution_count": 84, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Execute benchmark using CONFIG from cell 5\n", + "# Simply run: await run_benchmark()\n", + "\n", + "async def run_benchmark():\n", + " \"\"\"Execute benchmark using CONFIG dictionary from cell 5\"\"\"\n", + " try:\n", + " # Call main function with CONFIG\n", + " result = await main(CONFIG)\n", + " return result\n", + " except Exception as e:\n", + " logger.error(f\"Benchmark failed: {e}\", exc_info=True)\n", + " raise\n", + "\n", + "await run_benchmark()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "8f1LsMbU8E_e" + }, + "source": [ + "## Results Analysis\n", + "\n", + "After running the benchmark, you can analyze the results using the cells below." + ] + }, + { + "cell_type": "code", + "execution_count": 85, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "id": "flPzhqIQ8E_f", + "outputId": "d7026dc7-be33-4a3b-d49a-5d66822a0dcf" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "File Name: benchmark_results_20260127_174626.json\n", + "{'total_runs': 2, 'successful_runs': 0, 'overall_success_rate': 0.0, 'pass_at_1': 0.0, 'verifier_level_pass_rate': 0.6, 'total_verifiers_checked': 10, 'total_verifiers_passed': 6, 'individual_verifier_stats': {'Project Calendar Visible': {'passed': 2, 'total': 2, 'pass_rate': 1.0}, 'Event Moved to Primary': {'passed': 1, 'total': 2, 'pass_rate': 0.5}, 'Event Description Updated': {'passed': 1, 'total': 2, 'pass_rate': 0.5}, 'Kickoff Event Created': {'passed': 0, 'total': 2, 'pass_rate': 0.0}, 'Verify Old mapping removed': {'passed': 2, 'total': 2, 'pass_rate': 1.0}}, 'mean_execution_time_ms': 20168.0, 'tool_usage': {'get_calendar_list': 2, 'update_calendar_in_list': 1, 'list_events': 1, 'move_event': 1, 'patch_event': 1, 'create_event': 1}}\n", + "{\n", + " \"individual_verifier_stats\": {\n", + " \"Event Description Updated\": {\n", + " \"pass_rate\": 0.5,\n", + " \"passed\": 1,\n", + " \"total\": 2\n", + " },\n", + " \"Event Moved to Primary\": {\n", + " \"pass_rate\": 0.5,\n", + " \"passed\": 1,\n", + " \"total\": 2\n", + " },\n", + " \"Kickoff Event Created\": {\n", + " \"pass_rate\": 0.0,\n", + " \"passed\": 0,\n", + " \"total\": 2\n", + " },\n", + " \"Project Calendar Visible\": {\n", + " \"pass_rate\": 1.0,\n", + " \"passed\": 2,\n", + " \"total\": 2\n", + " },\n", + " \"Verify Old mapping removed\": {\n", + " \"pass_rate\": 1.0,\n", + " \"passed\": 2,\n", + " \"total\": 2\n", + " }\n", + " },\n", + " \"mean_execution_time_ms\": 20168.0,\n", + " \"overall_success_rate\": 0.0,\n", + " \"pass_at_1\": 0.0,\n", + " \"successful_runs\": 0,\n", + " \"tool_usage\": {\n", + " \"create_event\": 1,\n", + " \"get_calendar_list\": 2,\n", + " \"list_events\": 1,\n", + " \"move_event\": 1,\n", + " \"patch_event\": 1,\n", + " \"update_calendar_in_list\": 1\n", + " },\n", + " \"total_runs\": 2,\n", + " \"total_verifiers_checked\": 10,\n", + " \"total_verifiers_passed\": 6,\n", + " \"verifier_level_pass_rate\": 0.6\n", + "}\n" + ] + } + ], + "source": [ + "# Load and display results\n", + "import glob\n", + "import json\n", + "\n", + "# Find most recent results file\n", + "result_files = glob.glob(\"benchmark_results_*.json\")\n", + "if result_files:\n", + " latest_file = max(result_files)\n", + " print(\"File Name: \", latest_file)\n", + " with open(latest_file, 'r') as f:\n", + " results = json.load(f)\n", + "\n", + " print(results['statistics'])\n", + " print(json.dumps(results['statistics'], indent=2, sort_keys=True))\n", + "else:\n", + " print(\"No results files found. Run the benchmark first.\")" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/envs/calendar_env/models.py b/envs/calendar_env/models.py new file mode 100644 index 000000000..e7bca8334 --- /dev/null +++ b/envs/calendar_env/models.py @@ -0,0 +1,102 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Data models for the Calendar MCP Environment. + +These models define the action and observation types used by the OpenEnv +integration for the calendar server. +""" + +from typing import Any, Dict, List, Optional, Literal + +from pydantic import Field + +# Support both in-repo and standalone imports +try: + from openenv.core.env_server.types import Action, Observation +except ImportError: + from openenv.core.env_server.types import Action, Observation + + +class MCPAction(Action): + """ + Generic wrapper action for MCP tool access. + + action_type values: + - "ListToolsAction": list available tools + - "ToolCallAction": execute a tool by name + """ + + action_type: Literal["ListToolsAction", "ToolCallAction"] = Field( + ..., description="Type of action to perform" + ) + tool_name: Optional[str] = Field( + None, description="Name of tool to call (required for ToolCallAction)" + ) + arguments: Optional[Dict[str, Any]] = Field( + default_factory=dict, description="Arguments for the tool" + ) + + +class ListToolsAction(Action): + """Internal action type for listing tools.""" + + pass + + +class ToolCallAction(Action): + """Internal action type for calling a tool.""" + + tool_name: str = Field(..., description="Name of the tool to call") + arguments: Dict[str, Any] = Field( + default_factory=dict, description="Arguments to pass to the tool" + ) + + +class MCPObservation(Observation): + """ + Observation returned by the MCP environment. + + tools_list is populated for ListToolsAction. + tool_result is populated for ToolCallAction. + """ + + success: bool = Field(True, description="Whether the action succeeded") + error_message: Optional[str] = Field(None, description="Error message if action failed") + tools_list: Optional[List[Dict[str, Any]]] = Field( + None, description="List of available tools (for ListToolsAction)" + ) + tool_result: Optional[Dict[str, Any]] = Field( + None, description="Result from tool execution (for ToolCallAction)" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata about the execution" + ) + done: bool = Field(False, description="Whether the episode is complete") + reward: Optional[float] = Field(None, description="Reward for the action") + + +class CalendarAction(MCPAction): + """Action type for the Calendar environment.""" + + pass + + +class CalendarObservation(MCPObservation): + """Observation type for the Calendar environment.""" + + pass + + +__all__ = [ + "MCPAction", + "MCPObservation", + "ListToolsAction", + "ToolCallAction", + "CalendarAction", + "CalendarObservation", +] diff --git a/envs/calendar_env/openenv.yaml b/envs/calendar_env/openenv.yaml new file mode 100644 index 000000000..ceb9e241c --- /dev/null +++ b/envs/calendar_env/openenv.yaml @@ -0,0 +1,6 @@ +spec_version: 1 +name: calendar_env +type: space +runtime: fastapi +app: server.app:app +port: 8004 diff --git a/envs/calendar_env/pyproject.toml b/envs/calendar_env/pyproject.toml new file mode 100644 index 000000000..8ba842a88 --- /dev/null +++ b/envs/calendar_env/pyproject.toml @@ -0,0 +1,47 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-calendar-env" +version = "0.1.0" +description = "Calendar environment for OpenEnv with MCP tool access" +requires-python = ">=3.10" +dependencies = [ + "openenv-core>=0.2.0", + "openenv>=0.1.13", + "fastapi>=0.110.0", + "uvicorn[standard]>=0.27.0", + "pydantic>=2.5.0", + "mcp", + "httpx>=0.25.2", + "python-dotenv>=1.0.0", + "sqlalchemy>=2.0.0", + "python-dateutil>=2.8.0", + "jsonschema>=4.0.0", + "requests>=2.32.5", + "websockets", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +server = "calendar_env.server.app:main" + +[tool.setuptools] +include-package-data = true +packages = ["calendar_env", "calendar_env.server"] +package-dir = { "calendar_env" = ".", "calendar_env.server" = "server" } + +[tool.setuptools.package-data] +calendar_env = ["**/*.yaml", "**/*.yml", "**/*.md"] diff --git a/envs/calendar_env/requirements.txt b/envs/calendar_env/requirements.txt new file mode 100644 index 000000000..3beaa912a --- /dev/null +++ b/envs/calendar_env/requirements.txt @@ -0,0 +1,19 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.5.0 +mcp +httpx>=0.25.2 +python-dotenv>=1.0.0 +sqlalchemy>=2.0.0 +python-dateutil>=2.8.0 +jsonschema>=4.0.0 +requests>=2.32.5 +openenv>=0.1.13 +websockets +openenv-core +fastmcp>=2.0.0 +langchain +langchain-core +langchain-openai +langchain-anthropic +langchain-google-genai \ No newline at end of file diff --git a/envs/calendar_env/scenario_config.json b/envs/calendar_env/scenario_config.json new file mode 100644 index 000000000..ac030c94a --- /dev/null +++ b/envs/calendar_env/scenario_config.json @@ -0,0 +1,82 @@ +{ + "gym_enviornment_url": "http://localhost:8004", + "seed_database_file": "", + "llm_provider": "openai", + "llm_model": "gpt-5.2", + "llm_api_key": "", + "temperature": 0.0, + "max_tokens": 4096, + "system_prompt": "You are a Google Calendar automation agent with full administrative permissions to manage users, meetings, recordings, and messages. Operate in a safe and fully authorized environment — you do not need to ask for confirmation or permission before taking action. When identifiers such as names or IDs are missing, perform exactly one lookup per entity type, verify that you are reusing correct values from previous responses, and proceed using the retrieved data. Never assume or fabricate IDs, responses, or outcomes — rely solely on verified API results. Complete each task in a single, logical, and efficient execution flow.", + "user_prompt": "Help me tidy up my calendars for Q4? First, make sure my Project Management calendar is showing and selected. Then find my \"Sprint Planning & Architecture Review\" meeting and move the latest one to my main calendar so it's easier to track. Update its description to something like \"Updated for Q4 planning with new architecture goals and sprint alignment.\"\nAfter that, add a new event called \"Q4 Initiative Kickoff – Engineering & UX\" to the Project Management calendar for next Thursday from 10AM-12PM. That should cover everything I need!", + "number_of_runs": 1, + "reset_database_between_runs": true, + "context": { + "actingUserId": "alice_manager", + "actingUserEmail": "alice.manager@techcorp.com", + "defaultTimeZone": "America/New_York", + "x-access-token": "ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2.qwErTyUIopASDfGhJkLzXcVbNm12_34-56" + }, + "auth_config": null, + "expected_tools": [ + "get_calendar_list", + "list_events", + "update_calendar_in_list", + "move_event", + "patch_event", + "create_event", + "delete_event" + ], + "restricted_tools": [], + "verifiers": [ + { + "verifier_type": "database_state", + "name": "Project Calendar Visible", + "description": "Ensures Alice's Project Management calendar is visible and selected.", + "validation_config": { + "query": "SELECT COUNT(*) FROM calendars WHERE calendar_id='alice-projects' AND hidden=0 AND selected=1;", + "expected_value": 1, + "comparison_type": "equals" + } + }, + { + "verifier_type": "database_state", + "name": "Event Moved to Primary", + "description": "Checks that the Sprint Planning event is on the primary calendar.", + "validation_config": { + "query": "SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-primary';", + "expected_value": 1, + "comparison_type": "equals" + } + }, + { + "verifier_type": "database_state", + "name": "Event Description Updated", + "description": "Ensures the event description was updated to the new Q4 wording .", + "validation_config": { + "query": "SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND description LIKE '%Q4%' AND description LIKE '%planning%' AND description LIKE '%architecture%' ", + "expected_value": 1, + "comparison_type": "equals" + } + }, + { + "verifier_type": "database_state", + "name": "Kickoff Event Created", + "description": "Ensures that the new Q4 kickoff event was created on Project Management.", + "validation_config": { + "query": "SELECT COUNT(*) FROM events WHERE summary='Q4 Initiative Kickoff – Engineering & UX' AND calendar_id='alice-projects' AND start_datetime LIKE '%2026-01-15 10:00%'\nAND end_datetime LIKE '%2026-01-15 12:00:%'AND status='confirmed'", + "expected_value": 1, + "comparison_type": "equals" + } + }, + { + "verifier_type": "database_state", + "name": "Verify Old mapping removed", + "description": "Verify Old mapping removed", + "validation_config": { + "query": "SELECT COUNT(*) FROM events WHERE event_id='event-corrected-001' AND calendar_id='alice-projects';", + "expected_value": 0, + "comparison_type": "equals" + } + } + ] +} \ No newline at end of file diff --git a/envs/calendar_env/server/Dockerfile b/envs/calendar_env/server/Dockerfile new file mode 100644 index 000000000..bb668fa4c --- /dev/null +++ b/envs/calendar_env/server/Dockerfile @@ -0,0 +1,40 @@ +# Use Python 3.11 slim image as base +FROM python:3.11-slim + +ENV API_PORT=8004 + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create directory for database files +RUN mkdir -p /app/mcp_databases + +# Expose port 8010 +EXPOSE 8004 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8004/health')" || exit 1 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8004"] \ No newline at end of file diff --git a/envs/calendar_env/server/__init__.py b/envs/calendar_env/server/__init__.py new file mode 100644 index 000000000..bcc26e4dc --- /dev/null +++ b/envs/calendar_env/server/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Calendar server package.""" diff --git a/envs/calendar_env/server/apis/__init__.py b/envs/calendar_env/server/apis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/apis/acl/router.py b/envs/calendar_env/server/apis/acl/router.py new file mode 100644 index 000000000..803791c07 --- /dev/null +++ b/envs/calendar_env/server/apis/acl/router.py @@ -0,0 +1,236 @@ +""" +ACL (Access Control List) API endpoints following Google Calendar API v3 structure. +Handles CRUD operations for calendar access rules. +""" + +import logging +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from schemas.acl import ACLRule, ACLRuleInput, Channel, ACLWatchRequest, ACLListResponse, InsertACLRule, PatchACLRuleInput +from database.managers.acl_manager import ACLManager, get_acl_manager +from database.session_manager import CalendarSessionManager +from middleware.auth import get_user_context +from pydantic import ValidationError + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/calendars", tags=["acl"]) +session_manager = CalendarSessionManager() + +def get_acl_manager_instance(database_id: str, user_id: str) -> ACLManager: + session = session_manager.get_session(database_id) + return get_acl_manager(session, user_id) + + +@router.get("/{calendarId}/acl", response_model=ACLListResponse, operation_id="list_acl_rules") +def list_acl_rules( + calendarId: str, + maxResults: Optional[int] = Query(100, ge=1, le=250, description="Maximum number of entries returned on one result page"), + pageToken: Optional[str] = Query(None, description="Token specifying which result page to return"), + showDeleted: bool = Query(False, description="Whether to include deleted ACLs in the result"), + syncToken: Optional[str] = Query(None, description="Token for incremental synchronization"), + user_context: tuple[str, str] = Depends(get_user_context) +): + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + + if not manager.validate_calendar_id(calendarId, user_id): + raise ValueError(f"Calendar {calendarId} not found for user {user_id}") + + # If syncToken is provided, showDeleted must be True (Google Calendar API behavior) + if syncToken and not showDeleted: + showDeleted = True + + result = manager.list_rules( + calendarId, + max_results=maxResults, + page_token=pageToken, + show_deleted=showDeleted, + sync_token=syncToken + ) + return result + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{str(verr)}") + except HTTPException as he: + if he.status_code == 410: # Handle sync token expiration + raise he + raise + except Exception as e: + logger.error(f"Error listing ACL rules: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/{calendarId}/acl/{ruleId}", response_model=ACLRule, operation_id="get_acl_rule") +def get_acl_rule( + calendarId: str, + ruleId: str, + user_context: tuple[str, str] = Depends(get_user_context) +): + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + rule = manager.get_rule(calendarId, ruleId) + if not rule: + raise HTTPException(status_code=404, detail="ACL rule not found") + return rule + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving ACL rule {ruleId}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/{calendarId}/acl", status_code=201, operation_id="insert_acl_rule") +def insert_acl_rule( + calendarId: str, + rule: ACLRuleInput, + sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"), + user_context: tuple[str, str] = Depends(get_user_context) +): + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + + if not manager.validate_calendar_id(calendarId, user_id): + raise ValueError(f"Calendar {calendarId} not found for user {user_id}") + + return manager.insert_rule(calendarId, rule, send_notifications=sendNotifications) + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(f"Error inserting ACL rule: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.put("/{calendarId}/acl/{ruleId}", operation_id="update_acl_rule") +def update_acl_rule( + calendarId: str, + ruleId: str, + rule: ACLRuleInput, + sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"), + user_context: tuple[str, str] = Depends(get_user_context) +): + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + + if not manager.validate_calendar_id(calendarId, user_id): + raise ValueError(f"Calendar {calendarId} not found for user {user_id}") + + updated = manager.update_rule(calendarId, ruleId, rule, send_notifications=sendNotifications) + if not updated: + raise ValueError(f"ACL rule not found") + return updated + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(f"Error updating ACL rule {ruleId}: {e}") + raise HTTPException(status_code=500, detail=f"An error occurred {e}") + + +@router.patch("/{calendarId}/acl/{ruleId}", operation_id="patch_acl_rule") +def patch_acl_rule( + calendarId: str, + ruleId: str, + rule: PatchACLRuleInput, + sendNotifications: bool = Query(True, description="Whether to send notifications about the calendar sharing change"), + user_context: tuple[str, str] = Depends(get_user_context) +): + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + updated = manager.patch_rule(calendarId, ruleId, rule, sendNotifications) + if not updated: + raise ValueError(f"ACL rule not found") + return updated + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(f"Error patching ACL rule {ruleId}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.delete("/{calendarId}/acl/{ruleId}", status_code=status.HTTP_204_NO_CONTENT) +def delete_acl_rule( + calendarId: str, + ruleId: str, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Deletes an ACL rule from the specified calendar. + + DELETE /calendars/{calendarId}/acl/{ruleId} + """ + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + + success = manager.delete_rule(calendarId, ruleId) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"ACL rule '{ruleId}' not found for calendar '{calendarId}'" + ) + + return None # 204 No Content + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting ACL rule {ruleId} from calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.post("/{calendarId}/acl/watch", response_model=Channel, operation_id="watch_acl") +def watch_acl( + calendarId: str, + watch_request: ACLWatchRequest, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Set up watch notifications for ACL changes on the specified calendar. + + POST /calendars/{calendarId}/acl/watch + """ + try: + database_id, user_id = user_context + manager = get_acl_manager_instance(database_id, user_id) + + # Log the received request for debugging + logger.info(f"Received watch request for calendar {calendarId}") + logger.debug(f"Watch request data: {watch_request}") + + # Validate user exists in this database (ensures ownership context) + from database.session_utils import get_session + from database.models.user import User + session = get_session(database_id) + try: + user_row = session.query(User).filter(User.user_id == user_id).first() + if not user_row: + raise HTTPException(status_code=404, detail=f"User not found: {user_id}") + finally: + session.close() + + # Set up watch channel + channel = manager.watch_acl(user_id, calendarId, watch_request) + + return channel + + except ValidationError as e: + logger.error(f"Validation error for calendar {calendarId}: {e.errors()}") + validation_errors = [] + for error in e.errors(): + field = error.get('loc', ['unknown'])[0] if error.get('loc') else 'unknown' + message = error.get('msg', 'validation failed') + validation_errors.append(f"{field}: {message}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Validation errors: - {' - '.join(validation_errors)}" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error setting up ACL watch for calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/envs/calendar_env/server/apis/calendarList/__init__.py b/envs/calendar_env/server/apis/calendarList/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/apis/calendarList/router.py b/envs/calendar_env/server/apis/calendarList/router.py new file mode 100644 index 000000000..9a5518837 --- /dev/null +++ b/envs/calendar_env/server/apis/calendarList/router.py @@ -0,0 +1,802 @@ +""" +CalendarList API endpoints following Google Calendar API v3 structure +Handles user's calendar list operations with exact Google API compatibility +""" + +import logging +import re +from typing import Optional, List +import uuid +from urllib.parse import urlencode, urlparse +from datetime import datetime, timezone, timedelta +from fastapi import APIRouter, HTTPException, Header, Query, status, Depends +from schemas.calendar_list import ( + CalendarListEntry, + CalendarListInsertRequest, + CalendarListUpdateRequest, + CalendarListResponse, + Channel, + WatchRequest, + CalendarListPatchRequest +) +from schemas.common import SuccessResponse +from database.managers.calendar_list_manager import CalendarListManager +from database.session_manager import CalendarSessionManager +from utils.validation import validate_request_colors, validate_and_set_color_id, set_colors_from_color_id +from middleware.auth import get_user_context + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/users/me/calendarList", tags=["calendarList"]) + +# Initialize managers +session_manager = CalendarSessionManager() + + +def get_calendar_list_manager(database_id: str) -> CalendarListManager: + """Get calendar list manager for the specified database""" + return CalendarListManager(database_id) + + +@router.get("", response_model=CalendarListResponse) +async def get_calendar_list( + user_context: tuple[str, str] = Depends(get_user_context), + maxResults: Optional[int] = Query( + None, + gt=0, + le=250, + description="Maximum number of entries returned (0-250). If 0, returns no items." + ), + minAccessRole: Optional[str] = Query( + None, + description="Minimum access role filter", + regex="^(freeBusyReader|reader|writer|owner)$" + ), + pageToken: Optional[str] = Query(None, description="Token for pagination"), + showDeleted: Optional[bool] = Query(False, description="Include deleted calendars"), + showHidden: Optional[bool] = Query(False, description="Include hidden calendars"), + syncToken: Optional[str] = Query(None, description="Token for incremental sync") +): + """ + Returns the calendars on the user's calendar list + + GET /users/me/calendarList + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Validate minAccessRole parameter + if minAccessRole and minAccessRole not in ["freeBusyReader", "reader", "writer", "owner"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid minAccessRole: {minAccessRole}. Must be one of: freeBusyReader, reader, writer, owner" + ) + + # Handle syncToken constraints + if syncToken: + # syncToken cannot be used with minAccessRole + if minAccessRole: + raise ValueError(f"minAccessRole query parameter cannot be specified together with syncToken") + + # When using syncToken, deleted and hidden entries must be included + showDeleted = True + showHidden = True + + # Get calendar entries with pagination and/or sync + try: + entries, next_page_token, next_sync_token = calendar_list_manager.list_calendar_entries( + user_id=user_id, + max_results=maxResults, + min_access_role=minAccessRole, + show_deleted=showDeleted, + show_hidden=showHidden, + page_token=pageToken, + sync_token=syncToken + ) + except ValueError as e: + # Handle expired syncToken + if "expired" in str(e).lower() or "invalid sync token" in str(e).lower(): + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail="Sync token has expired. Client should clear storage and perform full synchronization." + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + return CalendarListResponse( + kind="calendar#calendarList", + etag=f"etag-list-{database_id}", + items=entries, + nextPageToken=next_page_token, + nextSyncToken=next_sync_token + ) + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}") + except Exception as e: + logger.error(f"Error listing calendar list: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/{calendarId}", response_model=CalendarListEntry) +async def get_calendar_from_list( + calendarId: str, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Returns a calendar from the user's calendar list + + GET /users/me/calendarList/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Support keyword 'primary' to fetch the user's primary calendar list entry + if isinstance(calendarId, str) and calendarId.lower() == "primary": + entries, _, _ = calendar_list_manager.list_calendar_entries( + user_id=user_id, + show_hidden=True + ) + entry = next((e for e in entries if e.get("primary") is True), None) + if entry: + # Check ACL permissions for primary calendar + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, entry["id"], ["reader", "writer", "owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{entry['id']}'" + ) + else: + # Check ACL permissions before getting calendar entry + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["reader", "writer", "owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'" + ) + entry = calendar_list_manager.get_calendar_entry(user_id, calendarId) + + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Calendar not found: {calendarId}" + ) + + return entry + + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error getting calendar list entry {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("", response_model=CalendarListEntry, status_code=status.HTTP_201_CREATED) +async def add_calendar_to_list( + calendar_request: CalendarListInsertRequest, + colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"), + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Inserts an existing calendar into the user's calendar list + + POST /users/me/calendarList + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Convert request to dict + entry_data = calendar_request.model_dump(exclude_none=True) + + # Validate colorId if provided (calendar list uses calendar colors) + color_error = validate_request_colors(entry_data, "calendar", database_id) + if color_error: + raise ValueError(color_error) + + # Set backgroundColor and foregroundColor from colorId if they are not provided + color_set_error = set_colors_from_color_id(entry_data, "calendar") + if color_set_error: + raise ValueError(color_set_error) + + # Normalize empty strings: drop to avoid triggering validation + for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]: + if key in entry_data and isinstance(entry_data[key], str) and entry_data[key].strip() == "": + entry_data.pop(key) + + # Enforce colorRgbFormat rules for RGB color fields + has_rgb_value = any( + (k in entry_data and isinstance(entry_data[k], str) and entry_data[k].strip() != "") + for k in ["backgroundColor", "foregroundColor"] + ) + if has_rgb_value: + if not colorRgbFormat: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true" + ) + # Validate hex color pattern #RRGGBB + for key in ["backgroundColor", "foregroundColor"]: + if key in entry_data and isinstance(entry_data[key], str) and entry_data[key].strip() != "": + val = entry_data[key] + if not re.match(r"^#[0-9A-Fa-f]{6}$", val): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid {key}: must be a hex color like #AABBCC" + ) + + # Validate color combination and set colorId if both colors are provided + if ("backgroundColor" in entry_data and entry_data["backgroundColor"] and + "foregroundColor" in entry_data and entry_data["foregroundColor"]): + combination_error = validate_and_set_color_id(entry_data, "calendar") + if combination_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=combination_error + ) + # When RGB colors are provided but no colorId was found/set, remove any existing colorId + elif "colorId" in entry_data: + entry_data.pop("colorId") + calendar_id = entry_data.pop("id") + + # Check ACL permissions before adding calendar to list + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, calendar_id, ["reader", "writer", "owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendar_id}'" + ) + + + # Enforce Google spec: required fields when ADDING reminders/notifications + allowed_reminder_methods = {"email", "popup"} + allowed_notification_types = { + "eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda" + } + + # defaultReminders: each item must include method (enum) and minutes (>=0) + if "defaultReminders" in entry_data and entry_data["defaultReminders"] is not None: + dr = entry_data["defaultReminders"] + if not isinstance(dr, list): + raise HTTPException(status_code=400, detail="defaultReminders must be a list") + for idx, item in enumerate(dr): + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail=f"defaultReminders[{idx}] must be an object") + method = (item.get("method") or "").strip() + minutes = item.get("minutes") + if method == "" or method not in allowed_reminder_methods: + raise HTTPException(status_code=400, detail="defaultReminders[].method is required when adding and must be 'email' or 'popup'") + if not isinstance(minutes, int) or minutes < 0: + raise HTTPException(status_code=400, detail="defaultReminders[].minutes is required when adding and must be >= 0") + + # notificationSettings.notifications: each item must include method ('email') and type (enum) + if "notificationSettings" in entry_data and entry_data["notificationSettings"] is not None: + ns = entry_data["notificationSettings"] + if not isinstance(ns, dict): + raise HTTPException(status_code=400, detail="notificationSettings must be an object") + notifs = ns.get("notifications") + if notifs is not None: + if not isinstance(notifs, list): + raise HTTPException(status_code=400, detail="notificationSettings.notifications must be a list") + for idx, n in enumerate(notifs): + if not isinstance(n, dict): + raise HTTPException(status_code=400, detail=f"notificationSettings.notifications[{idx}] must be an object") + method = (n.get("method") or "").strip() + ntype = (n.get("type") or "").strip() + if method == "" or method != "email": + raise HTTPException(status_code=400, detail="notificationSettings.notifications[].method is required when adding and must be 'email'") + if ntype == "" or ntype not in allowed_notification_types: + raise HTTPException(status_code=400, detail="notificationSettings.notifications[].type is required when adding and must be a valid type") + + # Insert calendar into list + entry = calendar_list_manager.insert_calendar_entry(user_id, calendar_id, entry_data) + + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Calendar not found: {calendar_id}" + ) + + return entry + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}") + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error inserting calendar list entry: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.patch("/{calendarId}", response_model=CalendarListEntry) +async def update_calendar_in_list( + calendarId: str, + calendar_request: CalendarListUpdateRequest, + colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"), + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Updates an entry on the user's calendar list (partial update) + + PATCH /users/me/calendarList/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Support keyword 'primary' for calendarId (resolve to actual primary calendar ID) + if isinstance(calendarId, str) and calendarId.lower() == "primary": + entries, _, _ = calendar_list_manager.list_calendar_entries( + user_id=user_id, + show_hidden=True + ) + primary_entry = next((e for e in entries if e.get("primary") is True), None) + if not primary_entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Primary calendar not found" + ) + calendarId = primary_entry["id"] + + + # Check ACL permissions before updating calendar list entry + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["writer", "owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'" + ) + + # Convert request to dict for PATCH + # Use exclude_unset=True so explicitly provided nulls are preserved (to allow clearing fields) + update_data = calendar_request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update" + ) + + # Validate colorId if provided (calendar list uses calendar colors) + color_error = validate_request_colors(update_data, "calendar", database_id) + if color_error: + raise ValueError(color_error) + + # Set backgroundColor and foregroundColor from colorId if they are not provided + color_set_error = set_colors_from_color_id(update_data, "calendar") + if color_set_error: + raise ValueError(color_set_error) + + # Reject null values for boolean fields in PATCH + if "hidden" in update_data and update_data["hidden"] is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'hidden' cannot be null in PATCH" + ) + if "selected" in update_data and update_data["selected"] is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'selected' cannot be null in PATCH" + ) + + # Normalize empty strings for PATCH: treat as omitted so they don't trigger validation + for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]: + if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() == "": + update_data.pop(key) + + # Enforce colorRgbFormat rules for RGB color fields + has_rgb_value = any( + (k in update_data and isinstance(update_data[k], str) and update_data[k].strip() != "") + for k in ["backgroundColor", "foregroundColor"] + ) + if has_rgb_value: + if not colorRgbFormat: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true" + ) + # Validate hex color pattern #RRGGBB + for key in ["backgroundColor", "foregroundColor"]: + if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() != "": + val = update_data[key] + if not re.match(r"^#[0-9A-Fa-f]{6}$", val): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid {key}: must be a hex color like #AABBCC" + ) + + # Validate color combination and set colorId if both colors are provided + if ("backgroundColor" in update_data and update_data["backgroundColor"] and + "foregroundColor" in update_data and update_data["foregroundColor"]): + combination_error = validate_and_set_color_id(update_data, "calendar") + if combination_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=combination_error + ) + # When RGB colors are provided but no colorId was found/set, remove any existing colorId + elif "colorId" in update_data: + update_data.pop("colorId") + + + # Set backgroundColor and foregroundColor from colorId if they are not provided + color_set_error = set_colors_from_color_id(update_data, "calendar") + if color_set_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=color_set_error + ) + + # Coupling rule: only enforce selected=false when hidden=true. + # When hidden=false, do not override selected; honor client's value if provided. + if "hidden" in update_data and update_data["hidden"] is True: + update_data["selected"] = False + if "hidden" in update_data and update_data["hidden"] is False and "selected" not in update_data: + update_data["selected"] = True + + # Sanitize nested lists for PATCH: drop invalid/empty reminder/notification items; clear when empty arrays provided + if "defaultReminders" in update_data: + dr = update_data["defaultReminders"] + if isinstance(dr, list): + cleaned = [] + for item in dr: + if not isinstance(item, dict): + continue + method = (item.get("method") or "").strip() + minutes = item.get("minutes") + if method == "": + # treat as cleared; skip item + continue + if method in {"email", "popup"} and isinstance(minutes, int) and minutes >= 0: + cleaned.append({"method": method, "minutes": minutes}) + update_data["defaultReminders"] = cleaned if cleaned else None + elif dr in (None, ""): + update_data["defaultReminders"] = None + + if "notificationSettings" in update_data: + ns = update_data["notificationSettings"] + if isinstance(ns, dict): + notifs = ns.get("notifications") + if isinstance(notifs, list): + cleaned_n = [] + for n in notifs: + if not isinstance(n, dict): + continue + method = (n.get("method") or "").strip() + ntype = (n.get("type") or "").strip() + if method == "" and ntype == "": + # cleared + continue + if method == "email" and ntype in {"eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda"}: + cleaned_n.append({"method": method, "type": ntype}) + update_data["notificationSettings"] = {"notifications": cleaned_n} if cleaned_n else None + elif notifs in (None, ""): + update_data["notificationSettings"] = None + elif ns in (None, ""): + update_data["notificationSettings"] = None + + # Update calendar entry + entry = calendar_list_manager.update_calendar_entry(user_id, calendarId, update_data, is_patch=True) + + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Calendar not found: {calendarId}" + ) + + return entry + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}") + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error updating calendar list entry {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.put("/{calendarId}", response_model=CalendarListEntry) +async def replace_calendar_in_list( + calendarId: str, + calendar_request: CalendarListPatchRequest, + colorRgbFormat: Optional[bool] = Query(False, description="Use RGB color fields when writing colors (backgroundColor/foregroundColor)"), + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Updates an entry on the user's calendar list (full update) + + PUT /users/me/calendarList/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Support keyword 'primary' for calendarId (resolve to actual primary calendar ID) + if isinstance(calendarId, str) and calendarId.lower() == "primary": + entries, _, _ = calendar_list_manager.list_calendar_entries( + user_id=user_id, + show_hidden=True + ) + primary_entry = next((e for e in entries if e.get("primary") is True), None) + if not primary_entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Primary calendar not found" + ) + calendarId = primary_entry["id"] + + # Check ACL permissions before updating calendar list entry + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["writer", "owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'" + ) + + # Convert request to dict, including None values for full update + update_data = calendar_request.model_dump() + + # Validate colorId if provided (calendar list uses calendar colors) + color_error = validate_request_colors(update_data, "calendar", database_id) + if color_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=color_error + ) + + # Set backgroundColor and foregroundColor from colorId if they are not provided + color_set_error = set_colors_from_color_id(update_data, "calendar") + if color_set_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=color_set_error + ) + + # Ensure NOT NULL fields have defaults for PUT requests + if "hidden" not in update_data or update_data["hidden"] is None: + update_data["hidden"] = False + if "selected" not in update_data or update_data["selected"] is None: + update_data["selected"] = True + + # Normalize empty strings to None for PUT (clear fields) + for key in ["summaryOverride", "colorId", "backgroundColor", "foregroundColor"]: + if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() == "": + update_data[key] = None + + # Enforce colorRgbFormat rules for RGB color fields + has_rgb_value = any( + (k in update_data and isinstance(update_data[k], str) and update_data[k].strip() != "") + for k in ["backgroundColor", "foregroundColor"] + ) + if has_rgb_value: + if not colorRgbFormat: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="To set backgroundColor/foregroundColor you must set colorRgbFormat=true" + ) + # Validate hex color pattern #RRGGBB + for key in ["backgroundColor", "foregroundColor"]: + if key in update_data and isinstance(update_data[key], str) and update_data[key].strip() != "": + val = update_data[key] + if not re.match(r"^#[0-9A-Fa-f]{6}$", val): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid {key}: must be a hex color like #AABBCC" + ) + + # Validate color combination and set colorId if both colors are provided + if ("backgroundColor" in update_data and update_data["backgroundColor"] and + "foregroundColor" in update_data and update_data["foregroundColor"]): + combination_error = validate_and_set_color_id(update_data, "calendar") + if combination_error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=combination_error + ) + # When RGB colors are provided but no colorId was found/set, remove any existing colorId + elif "colorId" in update_data: + update_data.pop("colorId") + + + if "hidden" in update_data and update_data["hidden"] is True: + update_data["selected"] = False + if "hidden" in update_data and update_data["hidden"] is False and "selected" not in update_data: + update_data["selected"] = True + + # Sanitize nested lists for PUT: drop invalid/empty items; empty array clears + if "defaultReminders" in update_data: + dr = update_data["defaultReminders"] + if isinstance(dr, list): + cleaned = [] + for item in dr: + if not isinstance(item, dict): + continue + method = (item.get("method") or "").strip() + minutes = item.get("minutes") + if method == "": + continue + if method in {"email", "popup"} and isinstance(minutes, int) and minutes >= 0: + cleaned.append({"method": method, "minutes": minutes}) + update_data["defaultReminders"] = cleaned if cleaned else None + elif dr in (None, ""): + update_data["defaultReminders"] = None + + if "notificationSettings" in update_data: + ns = update_data["notificationSettings"] + if isinstance(ns, dict): + notifs = ns.get("notifications") + if isinstance(notifs, list): + cleaned_n = [] + for n in notifs: + if not isinstance(n, dict): + continue + method = (n.get("method") or "").strip() + ntype = (n.get("type") or "").strip() + if method == "" and ntype == "": + continue + if method == "email" and ntype in {"eventCreation", "eventChange", "eventCancellation", "eventResponse", "agenda"}: + cleaned_n.append({"method": method, "type": ntype}) + update_data["notificationSettings"] = {"notifications": cleaned_n} if cleaned_n else None + elif notifs in (None, ""): + update_data["notificationSettings"] = None + elif ns in (None, ""): + update_data["notificationSettings"] = None + + # Update calendar entry + entry = calendar_list_manager.update_calendar_entry(user_id, calendarId, update_data, is_patch=False) + + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Calendar not found: {calendarId}" + ) + + return entry + + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error updating calendar list entry {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.delete("/{calendarId}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_calendar_from_list( + calendarId: str, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Removes a calendar from the user's calendar list + + DELETE /users/me/calendarList/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Support keyword 'primary' for calendarId (resolve to actual primary calendar ID) + if isinstance(calendarId, str) and calendarId.lower() == "primary": + entries, _, _ = calendar_list_manager.list_calendar_entries( + user_id=user_id, + show_hidden=True + ) + primary_entry = next((e for e in entries if e.get("primary") is True), None) + if not primary_entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Primary calendar not found" + ) + calendarId = primary_entry["id"] + + # Check ACL permissions before removing calendar from list + try: + calendar_list_manager.check_calendar_acl_permissions(user_id, calendarId, ["owner"]) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendarId}'" + ) + + # Check if calendar exists first + existing_entry = calendar_list_manager.get_calendar_entry(user_id, calendarId) + if not existing_entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Calendar not found: {calendarId}" + ) + + # Delete calendar entry + try: + success = calendar_list_manager.delete_calendar_entry(user_id, calendarId) + + if not success: + raise HTTPException(status_code=500, detail="Failed to remove calendar from list") + except ValueError as e: + # Handle primary calendar removal attempt + if "Cannot remove primary calendar" in str(e): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot remove primary calendar from calendar list" + ) + raise + + # Return 204 No Content (no response body) + return None + + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error deleting calendar list entry {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/watch") +async def watch_calendar_list( + watch_request: WatchRequest, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Watch for changes to CalendarList resources + + POST /users/me/calendarList/watch + """ + try: + database_id, user_id = user_context + calendar_list_manager = get_calendar_list_manager(database_id) + + # Validate user exists in this database (ensures ownership context) + from database.session_utils import get_session + from database.models.user import User + session = get_session(database_id) + try: + user_row = session.query(User).filter(User.user_id == user_id).first() + if not user_row: + raise HTTPException(status_code=404, detail=f"User not found: {user_id}") + finally: + session.close() + + + # Create the watch channel + channel = calendar_list_manager.watch_calendar_list(watch_request, user_id) + # Return Channel response + return channel + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error setting up calendar list watch: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/envs/calendar_env/server/apis/calendars/__init__.py b/envs/calendar_env/server/apis/calendars/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/apis/calendars/router.py b/envs/calendar_env/server/apis/calendars/router.py new file mode 100644 index 000000000..150f42db7 --- /dev/null +++ b/envs/calendar_env/server/apis/calendars/router.py @@ -0,0 +1,271 @@ +""" +Calendar API endpoints following Google Calendar API v3 structure +Handles CRUD operations for calendars +""" +from fastapi import APIRouter, Body, HTTPException, Depends, status +from schemas.calendar import Calendar, CalendarCreateRequest, CalendarUpdateRequest +from schemas.common import SuccessResponse +from database.managers.calendar_manager import CalendarManager +from database.session_manager import CalendarSessionManager +from utils.validation import validate_request_colors +from middleware.auth import get_user_context +from typing import List +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/calendars", tags=["calendars"]) + +# Initialize managers +session_manager = CalendarSessionManager() + +def get_calendar_manager(database_id: str) -> CalendarManager: + """Get calendar manager for the specified database""" + return CalendarManager(database_id) + +def check_acl_permissions(manager: CalendarManager, user_id: str, calendar_id: str, allowed_roles: List[str]): + calendar = manager.get_calendar_by_id(user_id, calendar_id, allowed_roles) + if not calendar: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks permission on calendar '{calendar_id}'" + ) + return calendar + +@router.get("/{calendarId}", response_model=Calendar) +async def get_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)): + """ + Returns metadata for a calendar + + GET /calendars/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + if calendarId.lower() == "primary": + calendar = calendar_manager.get_primary_calendar(user_id) + if not calendar: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User '{user_id}' has no primary calendar") + formatted_response = calendar_manager._format_calendar_response(calendar) + return Calendar(**formatted_response) + + calendar = check_acl_permissions(calendar_manager, user_id, calendarId, ["reader", "writer", "owner"]) + formatted_response = calendar_manager._format_calendar_response(calendar) + return Calendar(**formatted_response) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error getting calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@router.patch("/{calendarId}", response_model=Calendar) +def patch_calendar(calendarId: str, update: CalendarUpdateRequest = Body(...), user_context: tuple[str, str] = Depends(get_user_context)): + """ + Updates calendar metadata (partial update) + + PATCH /calendars/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + update_data = update.model_dump(exclude_none=True) + if not update_data: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for patch") + + color_error = validate_request_colors(update_data, "calendar", database_id) + if color_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error) + + resolved_calendar_id = calendarId + if calendarId.lower() == "primary": + primary_calendar = calendar_manager.get_primary_calendar(user_id) + if not primary_calendar: + raise HTTPException(status_code=404, detail="Primary calendar not found") + resolved_calendar_id = primary_calendar.calendar_id + + check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["writer", "owner"]) + + updated_calendar = calendar_manager.update_calendar(user_id, resolved_calendar_id, update_data) + if not updated_calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + calendar_manager.session.refresh(updated_calendar) + # Format the response to match the Pydantic schema + formatted_response = calendar_manager._format_calendar_response(updated_calendar) + return Calendar(**formatted_response) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error updating calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@router.put("/{calendarId}", response_model=Calendar) +async def update_calendar(calendarId: str, calendar_request: CalendarUpdateRequest, user_context: tuple[str, str] = Depends(get_user_context)): + """ + Updates calendar metadata (full update) + + PUT /calendars/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + update_data = calendar_request.model_dump(exclude_unset=True, exclude_none=True) + color_error = validate_request_colors(update_data, "calendar", database_id) + if color_error: + raise HTTPException(status_code=400, detail=color_error) + + resolved_calendar_id = calendarId + if calendarId.lower() == "primary": + primary_calendar = calendar_manager.get_primary_calendar(user_id) + if not primary_calendar: + raise HTTPException(status_code=404, detail="Primary calendar not found") + resolved_calendar_id = primary_calendar.calendar_id + + check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["writer", "owner"]) + + logger.debug(f"Update data: {update_data}") + updated_calendar = calendar_manager.update_calendar(user_id, resolved_calendar_id, update_data) + if not updated_calendar: + raise HTTPException(status_code=404, detail="Calendar not found") + + calendar_manager.session.refresh(updated_calendar) + + # Format the response to match the Pydantic schema + formatted_response = calendar_manager._format_calendar_response(updated_calendar) + return Calendar(**formatted_response) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error updating calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@router.post("", response_model=Calendar, status_code=status.HTTP_201_CREATED) +async def create_calendar(calendar_request: CalendarCreateRequest, user_context: tuple[str, str] = Depends(get_user_context)): + """ + Creates a secondary calendar + + POST /calendars + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + from database.managers.user_manager import UserManager + user_manager = UserManager(database_id) + user = user_manager.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + calendar_data = calendar_request.model_dump(exclude_unset=True) + color_error = validate_request_colors(calendar_data, "calendar", database_id) + if color_error: + raise HTTPException(status_code=400, detail=color_error) + + created_calendar = calendar_manager.create_calendar(user_id, calendar_data) + if not created_calendar: + raise HTTPException(status_code=500, detail="Failed to create calendar") + + # Format the response to match the Pydantic schema + formatted_response = calendar_manager._format_calendar_response(created_calendar) + return Calendar(**formatted_response) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error creating calendar: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + +@router.delete("/{calendarId}") +def delete_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)): + """ + Deletes a secondary calendar + + DELETE /calendars/{calendarId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + resolved_calendar_id = calendarId + if calendarId.lower() == "primary": + primary_calendar = calendar_manager.get_primary_calendar(user_id) + if not primary_calendar: + raise HTTPException(status_code=404, detail="Primary calendar not found") + resolved_calendar_id = primary_calendar.calendar_id + + # Check ACL permissions before attempting to delete + check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["owner"]) + + deleted = calendar_manager.delete_calendar(user_id, resolved_calendar_id) + if not deleted: + raise HTTPException(status_code=404, detail="Calendar not found or already deleted") + + return {"message": "Calendar deleted successfully"} + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except Exception as e: + logger.error(f"Error deleting calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.delete("/{calendarId}/clear", response_model=int) +def clear_calendar(calendarId: str, user_context: tuple[str, str] = Depends(get_user_context)): + """ + Clears a primary calendar (deletes all events) + + POST /calendars/{calendarId}/clear + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + + resolved_calendar_id = calendarId + if calendarId.lower() == "primary": + primary_calendar = calendar_manager.get_primary_calendar(user_id) + if not primary_calendar: + raise HTTPException(status_code=404, detail="Primary calendar not found") + resolved_calendar_id = primary_calendar.calendar_id + + # Check ACL permissions before attempting to clear + check_acl_permissions(calendar_manager, user_id, resolved_calendar_id, ["owner", "writer"]) + + cleared_count = calendar_manager.clear_calendar(user_id, resolved_calendar_id) + return cleared_count + except HTTPException: + raise + except PermissionError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied: {str(e)}" + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error clearing calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/envs/calendar_env/server/apis/colors/__init__.py b/envs/calendar_env/server/apis/colors/__init__.py new file mode 100644 index 000000000..ee6b20c63 --- /dev/null +++ b/envs/calendar_env/server/apis/colors/__init__.py @@ -0,0 +1 @@ +"""Colors API module""" \ No newline at end of file diff --git a/envs/calendar_env/server/apis/colors/data.py b/envs/calendar_env/server/apis/colors/data.py new file mode 100644 index 000000000..62a344dc0 --- /dev/null +++ b/envs/calendar_env/server/apis/colors/data.py @@ -0,0 +1,219 @@ +""" +Google Calendar API v3 Colors Data +Static color definitions for calendars and events +""" + +from datetime import datetime, timezone +from typing import Dict, Any + +# Google Calendar color definitions based on official API +CALENDAR_COLORS = { + "1": { + "background": "#ac725e", + "foreground": "#1d1d1d" + }, + "2": { + "background": "#d06b64", + "foreground": "#1d1d1d" + }, + "3": { + "background": "#f83a22", + "foreground": "#1d1d1d" + }, + "4": { + "background": "#fa57c4", + "foreground": "#1d1d1d" + }, + "5": { + "background": "#9fc6e7", + "foreground": "#1d1d1d" + }, + "6": { + "background": "#9a9cff", + "foreground": "#1d1d1d" + }, + "7": { + "background": "#4986e7", + "foreground": "#1d1d1d" + }, + "8": { + "background": "#9aa116", + "foreground": "#1d1d1d" + }, + "9": { + "background": "#ef6c00", + "foreground": "#1d1d1d" + }, + "10": { + "background": "#ff7537", + "foreground": "#1d1d1d" + }, + "11": { + "background": "#42d692", + "foreground": "#1d1d1d" + }, + "12": { + "background": "#16a765", + "foreground": "#1d1d1d" + }, + "13": { + "background": "#7bd148", + "foreground": "#1d1d1d" + }, + "14": { + "background": "#b3dc6c", + "foreground": "#1d1d1d" + }, + "15": { + "background": "#fbe983", + "foreground": "#1d1d1d" + }, + "16": { + "background": "#fad165", + "foreground": "#1d1d1d" + }, + "17": { + "background": "#92e1c0", + "foreground": "#1d1d1d" + }, + "18": { + "background": "#9fe1e7", + "foreground": "#1d1d1d" + }, + "19": { + "background": "#9fc6e7", + "foreground": "#1d1d1d" + }, + "20": { + "background": "#4986e7", + "foreground": "#1d1d1d" + }, + "21": { + "background": "#9aa116", + "foreground": "#1d1d1d" + }, + "22": { + "background": "#16a765", + "foreground": "#1d1d1d" + }, + "23": { + "background": "#ff7537", + "foreground": "#1d1d1d" + }, + "24": { + "background": "#ffad46", + "foreground": "#1d1d1d" + } +} + +EVENT_COLORS = { + "1": { + "background": "#a4bdfc", + "foreground": "#1d1d1d" + }, + "2": { + "background": "#7ae7bf", + "foreground": "#1d1d1d" + }, + "3": { + "background": "#dbadff", + "foreground": "#1d1d1d" + }, + "4": { + "background": "#ff887c", + "foreground": "#1d1d1d" + }, + "5": { + "background": "#fbd75b", + "foreground": "#1d1d1d" + }, + "6": { + "background": "#ffb878", + "foreground": "#1d1d1d" + }, + "7": { + "background": "#46d6db", + "foreground": "#1d1d1d" + }, + "8": { + "background": "#e1e1e1", + "foreground": "#1d1d1d" + }, + "9": { + "background": "#5484ed", + "foreground": "#1d1d1d" + }, + "10": { + "background": "#51b749", + "foreground": "#1d1d1d" + }, + "11": { + "background": "#dc2127", + "foreground": "#1d1d1d" + } +} + +def get_colors_response() -> Dict[str, Any]: + """ + Get the complete colors response in Google Calendar API v3 format + + Returns: + Dict containing calendar and event colors with metadata + """ + return { + "kind": "calendar#colors", + "updated": "2023-01-01T00:00:00.000Z", + "calendar": CALENDAR_COLORS, + "event": EVENT_COLORS + } + +def get_color_by_id(color_type: str, color_id: str) -> Dict[str, str]: + """ + Get a specific color by type and ID + + Args: + color_type: Either 'calendar' or 'event' + color_id: The color ID (e.g., '1', '2', etc.) + + Returns: + Dict containing background and foreground colors + + Raises: + ValueError: If color_type or color_id is invalid + """ + if color_type == "calendar": + colors = CALENDAR_COLORS + elif color_type == "event": + colors = EVENT_COLORS + else: + raise ValueError(f"Invalid color type: {color_type}. Must be 'calendar' or 'event'") + + if color_id not in colors: + raise ValueError(f"Invalid color ID: {color_id} for type {color_type}") + + return colors[color_id] + +def get_all_calendar_colors() -> Dict[str, Dict[str, str]]: + """Get all calendar colors""" + return CALENDAR_COLORS.copy() + +def get_all_event_colors() -> Dict[str, Dict[str, str]]: + """Get all event colors""" + return EVENT_COLORS.copy() + +def validate_color_id(color_type: str, color_id: str) -> bool: + """ + Validate if a color ID exists for the given type + + Args: + color_type: Either 'calendar' or 'event' + color_id: The color ID to validate + + Returns: + True if color ID exists, False otherwise + """ + try: + get_color_by_id(color_type, color_id) + return True + except ValueError: + return False \ No newline at end of file diff --git a/envs/calendar_env/server/apis/colors/router.py b/envs/calendar_env/server/apis/colors/router.py new file mode 100644 index 000000000..36f6031ae --- /dev/null +++ b/envs/calendar_env/server/apis/colors/router.py @@ -0,0 +1,46 @@ +""" +Colors API endpoints following Google Calendar API v3 structure +Provides color definitions for calendars and events +""" + +import logging +from fastapi import APIRouter, HTTPException, Header, status +from typing import Dict, Any +from database.managers.color_manager import ColorManager + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/colors", tags=["colors"]) + + +def get_color_manager(database_id: str) -> ColorManager: + """Get color manager for the specified database""" + return ColorManager(database_id) + + +@router.get("", response_model=Dict[str, Any]) +async def get_colors(x_database_id: str = Header(alias="x-database-id")): + """ + Returns the color definitions for calendars and events + + GET /colors + + Returns a global palette of color definitions for calendars and events. + Color data is dynamically loaded from database with exact Google Calendar API v3 format. + Colors are global/shared across all users - no user_id required. + Use POST /api/load-sample-colors to populate database with Google's color definitions. + """ + try: + color_manager = get_color_manager(x_database_id) + + colors_response = color_manager.get_colors_response() + + logger.info("Retrieved calendar and event color definitions from database") + return colors_response + + except Exception as e: + logger.error(f"Error getting color definitions: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) \ No newline at end of file diff --git a/envs/calendar_env/server/apis/core_apis.py b/envs/calendar_env/server/apis/core_apis.py new file mode 100644 index 000000000..684781453 --- /dev/null +++ b/envs/calendar_env/server/apis/core_apis.py @@ -0,0 +1,19 @@ +""" +Core API endpoints +Handles health check and other core endpoints for Calendar API clone +""" + +import logging +from fastapi import APIRouter + +# Configure logging +logger = logging.getLogger(__name__) + +# Create router for core APIs +router = APIRouter(tags=["core"]) + + +@router.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "service": "calendar-env"} \ No newline at end of file diff --git a/envs/calendar_env/server/apis/database_router.py b/envs/calendar_env/server/apis/database_router.py new file mode 100644 index 000000000..581aec522 --- /dev/null +++ b/envs/calendar_env/server/apis/database_router.py @@ -0,0 +1,625 @@ +""" +Database Management API endpoints for Calendar API +Handles database operations like seeding, viewing, and schema management +""" + +import logging +import os +import shutil +import sqlite3 +import time +import random +import string +from datetime import datetime +from fastapi import APIRouter, HTTPException, Header +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from typing import Any, Dict, Optional + +from data.google_colors import GOOGLE_CALENDAR_COLORS +from data.multi_user_sample import get_multi_user_sql +from database.session_manager import CalendarSessionManager +from database.base_manager import BaseManager +from database.session_utils import _session_manager +from database.managers.calendar_manager import CalendarManager +from database.seed_database import SeedData, get_seed_session, init_seed_database + +logger = logging.getLogger(__name__) + +# Initialize session manager +calendar_session_manager = CalendarSessionManager() + +router = APIRouter(prefix="/api", tags=["database"]) + + +@router.get("/sample-data") +async def get_sample_data(): + """Get multi-user Calendar sample data as SQL script for download/inspection""" + try: + sql_content = get_multi_user_sql() + + return { + "success": True, + "message": "Sample calendar data.", + "format": "sql", + "sql_content": sql_content, + } + except Exception as e: + logger.error(f"Error retrieving sample data: {e}") + raise HTTPException(status_code=500, detail=f"Failed to retrieve sample data: {str(e)}") + + +@router.post("/seed-database") +async def seed_database(body: dict): + """Seed database with user-provided SQL content or default sample data""" + try: + database_id = body.get("database_id") + if not database_id: + raise HTTPException(status_code=400, detail="database_id is required") + sql_content = body.get("sql_content") + + if not sql_content: + raise HTTPException(status_code=400, detail="sql_content is required") + + # Initialize database + calendar_session_manager.init_database(database_id, create_tables=True) + db_path = calendar_session_manager.get_db_path(database_id) + + # Execute SQL content + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + + # Drop all existing tables to ensure fresh schema from SQLAlchemy models + # Get all table names dynamically + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + tables = cursor.fetchall() + + # Drop all tables to get fresh schema + for table in tables: + table_name = table[0] + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + + conn.commit() + conn.close() + + # Reinitialize database with fresh schema from SQLAlchemy models + calendar_session_manager.init_database(database_id, create_tables=True) + + # Reconnect to database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Execute custom SQL statements individually + statements = [] + for line in sql_content.split("\n"): + line = line.strip() + if line and not line.startswith("--"): + statements.append(line) + + # Join and split by semicolon + full_sql = " ".join(statements) + individual_statements = [stmt.strip() for stmt in full_sql.split(";") if stmt.strip()] + + # Execute each statement + for statement in individual_statements: + try: + # Skip empty statements + if not statement.strip(): + continue + cursor.execute(statement) + except Exception as e: + logger.error(f"Error executing statement: {statement[:100]}...") + logger.error(f"Error details: {e}") + raise e + + conn.commit() + + # Enforce primary calendar constraints and ensure ACL entries exist + try: + calendar_manager = CalendarManager(database_id) + calendar_manager.enforce_primary_calendar_constraint_for_all_users() + + # Ensure ACL entries exist for all calendar owners + # This is important for the new ACL validation system + logger.info("Ensuring ACL entries exist for all calendar owners...") + + # Get all calendars and ensure their owners have ACL entries + cursor.execute("SELECT calendar_id, user_id FROM calendars") + calendars = cursor.fetchall() + + for calendar_row in calendars: + calendar_id = calendar_row[0] + user_id = calendar_row[1] + + # Check if ACL entry already exists for this calendar owner + cursor.execute(""" + SELECT COUNT(*) as count FROM acls a + JOIN scopes s ON a.scope_id = s.id + JOIN users u ON u.email = s.value + WHERE a.calendar_id = ? AND u.user_id = ? AND a.role = 'owner' + """, (calendar_id, user_id)) + + acl_exists = cursor.fetchone()[0] > 0 + + if not acl_exists: + # Get user email + cursor.execute("SELECT email FROM users WHERE user_id = ?", (user_id,)) + user_result = cursor.fetchone() + + if user_result: + user_email = user_result[0] + + # Create scope for this user if it doesn't exist + scope_id = f"scope-{user_id}" + cursor.execute("INSERT OR IGNORE INTO scopes (id, type, value) VALUES (?, 'user', ?)", + (scope_id, user_email)) + + # Create ACL entries for calendar owner (both owner and writer roles) + owner_acl_id = f"acl-{calendar_id}-owner" + writer_acl_id = f"acl-{calendar_id}-writer" + + cursor.execute(""" + INSERT OR IGNORE INTO acls (id, calendar_id, user_id, role, scope_id, etag, created_at, updated_at) + VALUES (?, ?, ?, 'owner', ?, ?, datetime('now'), datetime('now')) + """, (owner_acl_id, calendar_id, user_id, scope_id, f'etag-{owner_acl_id}')) + + cursor.execute(""" + INSERT OR IGNORE INTO acls (id, calendar_id, user_id, role, scope_id, etag, created_at, updated_at) + VALUES (?, ?, ?, 'writer', ?, ?, datetime('now'), datetime('now')) + """, (writer_acl_id, calendar_id, user_id, scope_id, f'etag-{writer_acl_id}')) + + logger.info(f"Created owner and writer ACL entries for calendar {calendar_id} owner {user_email}") + + conn.commit() + logger.info("ACL validation completed successfully") + + except Exception as constraint_error: + logger.warning(f"Error enforcing constraints and ACL validation: {constraint_error}") + + # Store SQL content in seed_data table for future resets + sql_stored = False + try: + seed_db_session = get_seed_session() + try: + existing_seed = seed_db_session.query(SeedData).filter( + SeedData.database_id == database_id + ).first() + + name = body.get("name", f"Database {database_id}") + description = body.get("description", "") + + if existing_seed: + existing_seed.sql_content = sql_content + existing_seed.name = name + existing_seed.description = description + logger.info(f"Updated seed SQL for database {database_id}") + else: + seed_entry = SeedData( + database_id=database_id, + name=name, + description=description, + sql_content=sql_content + ) + seed_db_session.add(seed_entry) + logger.info(f"Stored new seed SQL for database {database_id}") + + seed_db_session.commit() + sql_stored = True + finally: + seed_db_session.close() + except Exception as e: + logger.warning(f"Failed to store seed SQL in database: {e}") + sql_stored = False + + result = { + "success": True, + "message": f"Database seeded successfully", + "database_id": database_id, + "sql_stored": sql_stored, + } + + logger.info(f"Successfully seeded database {database_id}") + return result + + finally: + conn.close() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error seeding database: {e}") + raise HTTPException(status_code=500, detail=f"Failed to seed database: {str(e)}") + + +@router.get("/database-state") +async def get_database_state(x_database_id: str = Header(alias="x-database-id")): + """Get current database state and all data for frontend tabular display""" + try: + database_id = x_database_id + + # Initialize database if needed + calendar_session_manager.init_database(database_id) + db_path = calendar_session_manager.get_db_path(database_id) + + if not os.path.exists(db_path): + return { + "success": True, + "message": "Database not initialized yet", + "database_info": {"status": "not_created"}, + "table_counts": {}, + "table_data": {}, + } + + # Connect to database + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + try: + cursor = conn.cursor() + + # Get all user-defined tables + cursor.execute( + """ + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + """ + ) + tables = cursor.fetchall() + + # Get data from each table + table_data = {} + table_counts = {} + + for table in tables: + table_name = table["name"] + + # Get count + cursor.execute(f"SELECT COUNT(*) as count FROM '{table_name}'") + count = cursor.fetchone()["count"] + table_counts[table_name] = count + + # Get data (limit to 100 rows for performance) + cursor.execute(f"SELECT * FROM '{table_name}' LIMIT 100") + rows = cursor.fetchall() + + # Convert to list of dictionaries + table_data[table_name] = [dict(row) for row in rows] + + # Get database info + db_file_size = os.path.getsize(db_path) + db_modified = datetime.fromtimestamp(os.path.getmtime(db_path)).isoformat() + + return { + "success": True, + "database_id": database_id, + "service": "calendar-api-server", + "database_info": { + "path": db_path, + "size_bytes": db_file_size, + "size_mb": round(db_file_size / (1024 * 1024), 2), + "last_modified": db_modified, + "total_tables": len(tables), + }, + "table_counts": table_counts, + "table_data": table_data, + "timestamp": datetime.now().isoformat(), + } + + finally: + conn.close() + + except Exception as e: + logger.error(f"Error getting database state: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get database state: {str(e)}") + + +@router.get("/schema") +async def get_schema(): + """Get database schema as JSON""" + try: + # Return static schema definition from SQLAlchemy models + return { + "success": True, + "message": "Database schema retrieved successfully", + "schema": calendar_session_manager.get_database_schema(), + } + + except Exception as e: + logger.error(f"Error getting database schema: {e}") + raise HTTPException(status_code=500, detail=f"Failed to retrieve database schema: {str(e)}") + + +@router.get("/download-db-file") +async def download_database_file(x_database_id: str = Header(default="default", alias="x-database-id")): + """Download the actual SQLite database file (.db)""" + try: + database_id = x_database_id.strip() + + # Basic input validation + if not database_id or database_id == "": + raise HTTPException(status_code=400, detail="Database ID cannot be empty") + + logger.info(f"-------Download DB File for {database_id}------") + + # Initialize database if needed + calendar_session_manager.init_database(database_id) + db_path = calendar_session_manager.get_db_path(database_id) + + # Check if file exists and is readable + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail=f"Database with ID '{database_id}' not found") + + if not os.path.isfile(db_path): + raise HTTPException(status_code=400, detail=f"Database path '{db_path}' is not a valid file") + + if not os.access(db_path, os.R_OK): + raise HTTPException(status_code=403, detail=f"Database file is not readable") + + # Generate filename for download with sanitized database_id + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_database_id = "".join(c for c in database_id if c.isalnum() or c in "_-") + filename = f"ola_database_{safe_database_id}_{timestamp}.db" + + logger.info(f"Serving database file: {db_path} as {filename}") + + # Return the actual database file + return FileResponse( + path=db_path, + filename=filename, + media_type="application/x-sqlite3", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error downloading database file: {e}") + raise HTTPException(status_code=500, detail=f"Failed to download database file: {str(e)}") + + +class SQLQueryRequest(BaseModel): + """Request model for executing SQL queries""" + + query: str = Field(..., min_length=1, description="SQL query to execute") + limit: Optional[int] = Field(100, ge=1, le=1000, description="Maximum number of rows to return") + + +@router.post("/sql-runner", response_model=Dict[str, Any]) +def execute_custom_query(request: SQLQueryRequest, x_database_id: str = Header(...)): + """Execute a custom SQL query with limit""" + try: + # Ensure database exists + calendar_session_manager.init_database(x_database_id) + db_path = calendar_session_manager.get_db_path(x_database_id) + + # Use BaseManager to execute the query + manager = BaseManager(db_path) + + # Add LIMIT clause to query if not present + query = request.query.strip().rstrip(";") + + # Execute the query + data = manager.execute_query(query) + + return { + "success": True, + "message": "Query executed successfully", + "data": data, + "row_count": len(data), + "query": query, + } + except Exception as e: + logger.error(f"Error executing custom query: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/reset-database") +async def reset_database(body: dict): + """Reset a database to its original seeded state.""" + try: + database_id = body.get("database_id") + if not database_id: + raise HTTPException(status_code=400, detail="database_id is required") + + sql_content = body.get("sql_content") + + # Get or store seed SQL + seed_session = get_seed_session() + try: + seed_data = seed_session.query(SeedData).filter(SeedData.database_id == database_id).first() + + if seed_data: + sql_content = seed_data.sql_content + logger.info(f"Using stored SQL for database {database_id}") + elif sql_content: + new_seed = SeedData( + database_id=database_id, + name=f"calendar_{database_id}", + description="Seed SQL for Calendar database (lazy migration)", + sql_content=sql_content + ) + seed_session.add(new_seed) + seed_session.commit() + logger.info(f"Stored SQL via lazy migration for database {database_id}") + else: + raise HTTPException( + status_code=400, + detail="No stored SQL found and no sql_content provided. Cannot reset database." + ) + finally: + seed_session.close() + + # Close existing session and get db path + calendar_session_manager.close_session(database_id) + db_path = calendar_session_manager.get_db_path(database_id) + + # Delete the database file completely (faster than dropping tables) + if os.path.exists(db_path): + os.remove(db_path) + logger.info(f"Deleted database file: {db_path}") + + # Reinitialize with fresh tables + calendar_session_manager.init_database(database_id, create_tables=True) + + # Execute seed SQL in one go using executescript + conn = sqlite3.connect(db_path) + try: + # executescript is much faster than executing statements one by one + conn.executescript(sql_content) + conn.commit() + logger.info(f"Database {database_id} reset successfully") + + return { + "success": True, + "message": "Database reset successfully", + "database_id": database_id + } + finally: + conn.close() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error resetting database: {e}") + raise HTTPException(status_code=500, detail=f"Failed to reset database: {str(e)}") + + +@router.post("/clone-database") +async def clone_database(body: dict): + """ + Clone an existing database to a new database. + Returns the new database_id. + """ + try: + # Accept both "database_id" and "source_database_id" for compatibility + source_database_id = body.get("database_id") or body.get("source_database_id") + if not source_database_id: + raise HTTPException(status_code=400, detail="database_id is required") + + logger.info(f"Clone database request for: {source_database_id}") + + # Get source database path + source_db_path = calendar_session_manager.get_db_path(source_database_id) + + if not os.path.exists(source_db_path): + raise HTTPException(status_code=404, detail=f"Source database '{source_database_id}' not found") + + # Generate new database ID + timestamp = int(time.time() * 1000) + random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=9)) + new_db_id = f"db_clone_{timestamp}_{random_suffix}" + + # Get new database path + new_db_path = calendar_session_manager.get_db_path(new_db_id) + + # Close any existing session on source to ensure file is not locked + calendar_session_manager.close_session(source_database_id) + + # Clone the database file + shutil.copy2(source_db_path, new_db_path) + + # Copy associated files (WAL, SHM, journal) if they exist + for ext in ['-wal', '-shm', '-journal']: + source_extra = source_db_path + ext + if os.path.exists(source_extra): + dest_extra = new_db_path + ext + shutil.copy2(source_extra, dest_extra) + logger.debug(f"Copied {source_extra} to {dest_extra}") + + # Initialize the session for the cloned database + calendar_session_manager.init_database(new_db_id, create_tables=False) + + # Get cloned database size + cloned_size_bytes = os.path.getsize(new_db_path) if os.path.exists(new_db_path) else 0 + + result = { + "success": True, + "message": "Database cloned successfully", + "source_database_id": source_database_id, + "cloned_database_id": new_db_id, + "cloned_db_path": new_db_path, + "cloned_size_bytes": cloned_size_bytes, + } + + logger.info(f"Successfully cloned database '{source_database_id}' to '{new_db_id}'") + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error cloning database: {e}") + raise HTTPException(status_code=500, detail=f"Failed to clone database: {str(e)}") + + +@router.delete("/delete-database") +async def delete_database(body: dict): + """ + Delete a cloned database. + WARNING: This permanently deletes the database and cannot be undone. + """ + try: + database_id = body.get("database_id") + if not database_id: + raise HTTPException(status_code=400, detail="database_id is required in request body") + + logger.info(f"Delete database request for: {database_id}") + + # Safety check: don't allow deleting the default database + if database_id == "default": + raise HTTPException(status_code=400, detail="Cannot delete the default database") + + # Get database path + db_path = calendar_session_manager.get_db_path(database_id) + + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail=f"Database with ID '{database_id}' not found") + + # Close any existing sessions + calendar_session_manager.close_session(database_id) + + # Get file size before deletion + db_size = os.path.getsize(db_path) + + # Delete the database file + os.remove(db_path) + + # Delete associated files (WAL, SHM, journal) if they exist + for ext in ['-wal', '-shm', '-journal']: + extra_file = db_path + ext + if os.path.exists(extra_file): + os.remove(extra_file) + logger.debug(f"Deleted {extra_file}") + + # Also remove from seed_data table if exists + try: + seed_db_session = get_seed_session() + try: + seed_entry = seed_db_session.query(SeedData).filter( + SeedData.database_id == database_id + ).first() + if seed_entry: + seed_db_session.delete(seed_entry) + seed_db_session.commit() + logger.info(f"Deleted seed data for database {database_id}") + finally: + seed_db_session.close() + except Exception as e: + logger.warning(f"Failed to delete seed data: {e}") + + result = { + "success": True, + "message": "Database deleted successfully", + "database_id": database_id, + "deleted_size_bytes": db_size, + } + + logger.info(f"Successfully deleted database {database_id}") + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting database: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete database: {str(e)}") diff --git a/envs/calendar_env/server/apis/events/router.py b/envs/calendar_env/server/apis/events/router.py new file mode 100644 index 000000000..5495bc3fa --- /dev/null +++ b/envs/calendar_env/server/apis/events/router.py @@ -0,0 +1,893 @@ +""" +Events API endpoints following Google Calendar API v3 structure +Handles all 11 Events operations with exact Google API compatibility +""" + +import logging +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Header, Query, status, Depends +from pydantic import ValidationError +from schemas.event import ( + Event, + EventListResponse, + EventCreateRequest, + EventUpdateRequest, + EventMoveRequest, + EventQuickAddRequest, + EventInstancesResponse, + Channel, + EventWatchRequest, + OrderByEnum, + EventTypesEnum, +) +from schemas.import_event import ( + EventImportRequest, + EventImportResponse, + EventImportQueryParams, + EventImportError, +) +from database.managers.event_manager import EventManager +from database.session_manager import CalendarSessionManager +from utils.validation import validate_request_colors +from middleware.auth import get_user_context +from database.managers.calendar_manager import CalendarManager +from apis.calendars.router import get_calendar_manager +from database.models.user import User +from database.session_utils import get_session +import re + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/calendars/{calendarId}/events", tags=["events"]) + +# Initialize managers +session_manager = CalendarSessionManager() + + +def get_event_manager(database_id: str) -> EventManager: + """Get event manager for the specified database""" + return EventManager(database_id) + +def check_event_acl_permissions( + calendar_manager: CalendarManager, + user_id: str, + calendar_id: str, + allowed_roles: list[str], + operation: str = "access" +): + """Check ACL permissions for event operations""" + calendar = calendar_manager.get_calendar_by_id(user_id, calendar_id, allowed_roles) + if not calendar: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User '{user_id}' lacks required roles {allowed_roles} for {operation} on calendar '{calendar_id}'" + ) + return calendar + + +@router.get("", response_model=EventListResponse) +async def list_events( + calendarId: str, + user_context: tuple[str, str] = Depends(get_user_context), + 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"), + 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."), + 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."), + 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."), + 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."), + pageToken: Optional[str] = Query(None, description="Token specifying which result page to return. Optional."), + 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."), + 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."), + 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."), + 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."), + showHiddenInvitations: Optional[bool] = Query(False, description="Whether to include hidden invitations in the result. Optional. The default is False."), + 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."), + 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."), + 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."), + 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."), + timeZone: Optional[str] = Query(None, description="Time zone used in the response. Optional. The default is the time zone of the calendar."), + 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."), +): + """ + Returns events on the specified calendar + ACL Required: reader, writer, or owner role + + GET /calendars/{calendarId}/events + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require at least reader role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["reader", "writer", "owner"], "list events" + ) + + # Validate iCalUID + if iCalUID: + pattern = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(pattern, iCalUID): + raise ValueError("Invalid iCalUID format. Expected something like 'abcd123@google.com'.") + + # Validate pageToken + if pageToken: + try: + page_token_int = int(pageToken) + if not page_token_int >= 0: + raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer") + except: + raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer") + + # Convert eventTypes enum list to comma-separated string for the manager + event_types_str = None + if eventTypes: + event_types_str = ",".join([event_type.value for event_type in eventTypes]) + + response = event_manager.list_events( + user_id=user_id, + calendar_id=calendarId, + event_types=event_types_str, + ical_uid=iCalUID, + max_attendees=maxAttendees, + max_results=maxResults, + order_by=orderBy, + page_token=pageToken, + private_extended_property=privateExtendedProperty, + q=q, + shared_extended_property=sharedExtendedProperty, + show_deleted=showDeleted, + show_hidden_invitations=showHiddenInvitations, + single_events=singleEvents, + sync_token=syncToken, + time_max=timeMax, + time_min=timeMin, + time_zone=timeZone, + updated_min=updatedMin, + ) + + return response + + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail= f"{str(e)}") + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error listing events for calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("", response_model=Event, status_code=status.HTTP_201_CREATED) +async def create_event( + calendarId: str, + event_request: EventCreateRequest, + user_context: tuple[str, str] = Depends(get_user_context), + conferenceDataVersion: Optional[int] = Query( + None, + ge=0, + le=1, + description="Version number of conference data supported by API client" + ), + maxAttendees: Optional[int] = Query( + None, + gt=0, + description="The maximum number of attendees to include in the response" + ), + 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)"), + supportsAttachments: Optional[bool] = Query( + False, + description="Whether API client performing operation supports event attachments" + ), +): + """ + Creates an event + ACL Required: writer or owner role + + The sendUpdates parameter controls notification behavior: + - "all": Notifications are sent to all guests + - "externalOnly": Notifications are sent to non-Google Calendar guests only + - "none": No notifications are sent (default) + + POST /calendars/{calendarId}/events + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "create events" + ) + + # Validate sendUpdates parameter + if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Validate colorId if provided (events use event colors) + event_data = event_request.model_dump(exclude_none=True) + color_error = validate_request_colors(event_data, "event", database_id) + if color_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error) + + # Validate required fields per Google API specification + if not event_request.start or not event_request.end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Both 'start' and 'end' fields are required" + ) + + # Create query parameters object for event creation + query_params = { + 'conferenceDataVersion': conferenceDataVersion, + 'maxAttendees': maxAttendees, + 'sendUpdates': sendUpdates, + 'supportsAttachments': supportsAttachments + } + + event = event_manager.create_event(user_id, calendarId, event_request, query_params) + + if not event: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to create event") + + # Filter attendees based on maxAttendees parameter + if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees: + event.attendees = event.attendees[:maxAttendees] + event.attendeesOmitted = True + + return event + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr)) + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors()) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error creating event in calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/{eventId}", response_model=Event) +async def get_event( + calendarId: str, + eventId: str, + user_context: tuple[str, str] = Depends(get_user_context), + timeZone: Optional[str] = Query(None, description="Time zone for returned times"), + 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."), +): + """ + Returns an event + + GET /calendars/{calendarId}/events/{eventId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require at least reader role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["reader", "writer", "owner"], "read event" + ) + + event = event_manager.get_event(user_id, calendarId, eventId, timeZone, maxAttendees) + + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + return event + + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error getting event {eventId} from calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.patch("/{eventId}", response_model=Event) +async def patch_event( + calendarId: str, + eventId: str, + event_request: EventUpdateRequest, + user_context: tuple[str, str] = Depends(get_user_context), + conferenceDataVersion: Optional[int] = Query( + None, + ge=0, + le=1, + description="Version number of conference data supported by API client" + ), + maxAttendees: Optional[int] = Query( + None, + gt=0, + description="The maximum number of attendees to include in the response" + ), + sendUpdates: Optional[str] = Query("none", description="Guests who should receive notifications (all, externalOnly, none)"), + supportsAttachments: Optional[bool] = Query( + False, + description="Whether API client performing operation supports event attachments" + ), +): + """ + Updates an event (partial update) following Google Calendar API v3 specification. + + This method supports patch semantics and only updates the fields that are explicitly provided. + To do a full update, use PUT which replaces the entire event resource. + + ACL Required: writer or owner role + + PATCH /calendars/{calendarId}/events/{eventId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "update event" + ) + + # Validate sendUpdates parameter + if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Get update data, excluding None values for partial update + update_data = event_request.model_dump(exclude_none=True) + + if not update_data: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields provided for update") + + # Validate colorId if provided (events use event colors) + color_error = validate_request_colors(update_data, "event", database_id) + if color_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error) + + # Create query parameters object for event update + query_params = { + 'conferenceDataVersion': conferenceDataVersion, + 'maxAttendees': maxAttendees, + 'sendUpdates': sendUpdates, + 'supportsAttachments': supportsAttachments + } + + event = event_manager.update_event(user_id, calendarId, eventId, event_request, is_patch=True, query_params=query_params) + + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + # Filter attendees based on maxAttendees parameter + if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees: + event.attendees = event.attendees[:maxAttendees] + event.attendeesOmitted = True + + return event + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr)) + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors()) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error updating event {eventId} in calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.put("/{eventId}", response_model=Event) +async def update_event( + calendarId: str, + eventId: str, + event_request: EventUpdateRequest, + user_context: tuple[str, str] = Depends(get_user_context), + conferenceDataVersion: Optional[int] = Query( + None, + ge=0, + le=1, + description="Version number of conference data supported by API client" + ), + maxAttendees: Optional[int] = Query( + None, + gt=0, + description="The maximum number of attendees to include in the response" + ), + sendUpdates: Optional[str] = Query("none", description="Guests who should receive notifications (all, externalOnly, none)"), + supportsAttachments: Optional[bool] = Query( + False, + description="Whether API client performing operation supports event attachments" + ), +): + """ + Updates an event (full update) following Google Calendar API v3 specification. + + This method does not support patch semantics and always updates the entire event resource. + To do a partial update, perform a get followed by an update using etags to ensure atomicity. + + PUT /calendars/{calendarId}/events/{eventId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "update event" + ) + + # For PUT operations, validate that start and end are provided (required per Google API v3) + if not event_request.start or not event_request.end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Both 'start' and 'end' fields are required for PUT operations" + ) + + # Validate sendUpdates parameter + if sendUpdates and sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Ensure required fields have defaults for PUT requests + update_data = event_request.model_dump() + if not update_data.get("status"): + event_request.status = "confirmed" + if not update_data.get("visibility"): + event_request.visibility = "default" + + # Validate colorId if provided (events use event colors) + color_error = validate_request_colors(update_data, "event", database_id) + if color_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error) + + # Create query parameters object for event update + query_params = { + 'conferenceDataVersion': conferenceDataVersion, + 'maxAttendees': maxAttendees, + 'sendUpdates': sendUpdates, + 'supportsAttachments': supportsAttachments + } + + event = event_manager.update_event(user_id, calendarId, eventId, event_request, is_patch=False, query_params=query_params) + + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + # Filter attendees based on maxAttendees parameter + if maxAttendees is not None and event.attendees and len(event.attendees) > maxAttendees: + event.attendees = event.attendees[:maxAttendees] + event.attendeesOmitted = True + + return event + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(verr)) + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors()) + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error updating event {eventId} in calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.delete("/{eventId}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_event( + calendarId: str, + eventId: str, + user_context: tuple[str, str] = Depends(get_user_context), + 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)"), +): + """ + Deletes an event + ACL Required: writer or owner role (owner required for events created by others) + + DELETE /calendars/{calendarId}/events/{eventId} + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Validate sendUpdates parameter + if sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "delete event" + ) + + # Check if event exists first + existing_event = event_manager.get_event(user_id, calendarId, eventId) + if not existing_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + # Delete event with sendUpdates parameter + success = event_manager.delete_event(user_id, calendarId, eventId, sendUpdates) + + if not success: + raise HTTPException(status_code=500, detail="Failed to delete event") + + # Return 204 No Content (no response body) + return None + + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error deleting event {eventId} from calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/{eventId}/move", response_model=Event) +async def move_event( + calendarId: str, + eventId: str, + user_context: tuple[str, str] = Depends(get_user_context), + destination: str = Query(..., description="Calendar identifier of the target calendar"), + sendUpdates: Optional[str] = Query("all", description="Guests who should receive notifications"), +): + """ + Moves an event to another calendar + ACL Required: + - writer or owner role on source calendar + - writer or owner role on destination calendar + + POST /calendars/{calendarId}/events/{eventId}/move + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Validate sendUpdates parameter + if sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Check ACL permissions on source calendar + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "move event from" + ) + + # Check if event exists and validate event type for move operation + existing_event = event_manager.get_event(user_id, calendarId, eventId) + if not existing_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + # Only default events can be moved - validate event type + if existing_event.eventType and existing_event.eventType != "default": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + 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." + ) + + move_request = EventMoveRequest( + destination=destination, sendUpdates=sendUpdates + ) + + event = event_manager.move_event(user_id, calendarId, eventId, move_request) + + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event not found: {eventId}") + + return event + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error moving event {eventId} from calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/quickAdd", response_model=Event, status_code=status.HTTP_201_CREATED) +async def quick_add_event( + calendarId: str, + user_context: tuple[str, str] = Depends(get_user_context), + text: str = Query(..., description="The text describing the event to be created"), + sendUpdates: Optional[str] = Query("all", description="Guests who should receive notifications"), +): + """ + Creates an event based on a simple text string + + POST /calendars/{calendarId}/events/quickAdd + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Validate sendUpdates parameter + if sendUpdates not in ["all", "externalOnly", "none"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sendUpdates value '{sendUpdates}'. Acceptable values are: 'all', 'externalOnly', 'none'" + ) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "quick add event" + ) + + quick_add_request = EventQuickAddRequest( + text=text, sendUpdates=sendUpdates + ) + + event = event_manager.quick_add_event(user_id, calendarId, quick_add_request) + + return event + + except HTTPException: + raise + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors()) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error quick adding event to calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/import", response_model=Event, status_code=status.HTTP_201_CREATED) +async def import_event( + calendarId: str, + event_request: EventImportRequest, + user_context: tuple[str, str] = Depends(get_user_context), + conferenceDataVersion: Optional[int] = Query( + None, + ge=0, + le=1, + description="Version number of conference data supported by API client" + ), + supportsAttachments: Optional[bool] = Query( + False, + description="Whether API client performing operation supports event attachments" + ), +): + """ + Imports an event. This operation is used to add a private copy of an + existing event to a calendar. + + Requires authorization with at least one of the following scopes: + - https://www.googleapis.com/auth/calendar + - https://www.googleapis.com/auth/calendar.events + - https://www.googleapis.com/auth/calendar.app.created + - https://www.googleapis.com/auth/calendar.events.owned + + POST /calendars/{calendarId}/events/import + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require writer or owner role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["writer", "owner"], "import event" + ) + + # Validate required fields + if not event_request.start or not event_request.end: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Both 'start' and 'end' fields are required for event import" + ) + + # Validate iCalUID + if not event_request.iCalUID: + raise ValueError("iCalUID is required field") + + # Validate attendees email addresses exist in database + if event_request.attendees: + session = get_session(database_id) + try: + for attendee in event_request.attendees: + if attendee.email: + user = session.query(User).filter(User.email == attendee.email).first() + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Attendee email '{attendee.email}' not found in database. All attendee emails must be valid and present in the system." + ) + finally: + session.close() + + # Validate colorId if provided (events use event colors) + event_data = event_request.model_dump(exclude_none=True) + color_error = validate_request_colors(event_data, "event", database_id) + if color_error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=color_error) + + # Create query params object + query_params = EventImportQueryParams( + conferenceDataVersion=conferenceDataVersion, + supportsAttachments=supportsAttachments + ) + # Import the event as a private copy + imported_event = event_manager.import_event( + user_id=user_id, + calendar_id=calendarId, + event_request=event_request, + query_params=query_params + ) + + if not imported_event: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to import event" + ) + + return imported_event + + except HTTPException: + raise + except ValidationError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.errors()) + except ValueError as e: + logger.error(f"Validation error importing event to calendar {calendarId}: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error importing event to calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/{eventId}/instances", response_model=EventInstancesResponse) +async def get_event_instances( + calendarId: str, + eventId: str, + user_context: tuple[str, str] = Depends(get_user_context), + 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."), + 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), + originalStart: Optional[str] = Query(None, description="The original start time of the instance in the result. Optional."), + pageToken: Optional[str] = Query(None, description="Token specifying which result page to return. Optional."), + 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."), + 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."), + 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."), + timeZone: Optional[str] = Query(None, description="Time zone used in the response. Optional. The default is the time zone of the calendar."), +): + """ + Returns instances of the specified recurring event + + GET /calendars/{calendarId}/events/{eventId}/instances + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + # Check ACL permissions - require at least reader role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["reader", "writer", "owner"], "read event instances" + ) + + # Validate pageToken + if pageToken: + try: + page_token_int = int(pageToken) + if not page_token_int >= 0: + raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer") + except: + raise ValueError("Please enter a valid pageToken value. Page token must be greater than equal to 0 and must be string integer") + + + response = event_manager.get_event_instances( + user_id=user_id, + calendar_id=calendarId, + event_id=eventId, + max_attendees=maxAttendees, + max_results=maxResults, + original_start=originalStart, + page_token=pageToken, + show_deleted=showDeleted, + time_min=timeMin, + time_max=timeMax, + time_zone=timeZone, + ) + + return response + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error getting instances for event {eventId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/watch", response_model=Channel) +async def watch_events( + calendarId: str, + watch_request: EventWatchRequest, + user_context: tuple[str, str] = Depends(get_user_context), + 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.") +): + """ + Watch for changes to Events resources + + POST /calendars/{calendarId}/events/watch + """ + try: + database_id, user_id = user_context + calendar_manager = get_calendar_manager(database_id) + event_manager = get_event_manager(database_id) + + if eventTypes and eventTypes not in ["birthday", "default", "focusTime", "fromGmail", "outOfOffice", "workingLocation"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid eventType value '{eventTypes}'. Acceptable values are: 'birthday', 'default', 'focusTime', 'fromGmail', 'outOfOffice', 'workingLocation'" + ) + + # Validate user exists in this database (ensures ownership context) + from database.session_utils import get_session + from database.models.user import User + session = get_session(database_id) + + try: + user_row = session.query(User).filter(User.user_id == user_id).first() + if not user_row: + raise HTTPException(status_code=404, detail=f"User not found: {user_id}") + finally: + session.close() + + + # Check ACL permissions - require at least reader role + check_event_acl_permissions( + calendar_manager, user_id, calendarId, + ["reader", "writer", "owner"], "watch events" + ) + + # Set up watch channel with event types filter + channel = event_manager.watch_events(user_id, calendarId, watch_request, eventTypes) + return channel + + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(e)}") + except HTTPException: + raise + except PermissionError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except Exception as e: + logger.error(f"Error setting up events watch for calendar {calendarId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/envs/calendar_env/server/apis/freebusy/__init__.py b/envs/calendar_env/server/apis/freebusy/__init__.py new file mode 100644 index 000000000..11ee369cd --- /dev/null +++ b/envs/calendar_env/server/apis/freebusy/__init__.py @@ -0,0 +1,7 @@ +""" +FreeBusy API module +""" + +from .router import router + +__all__ = ["router"] \ No newline at end of file diff --git a/envs/calendar_env/server/apis/freebusy/router.py b/envs/calendar_env/server/apis/freebusy/router.py new file mode 100644 index 000000000..bd0f08c5b --- /dev/null +++ b/envs/calendar_env/server/apis/freebusy/router.py @@ -0,0 +1,80 @@ +""" +FreeBusy API endpoints following Google Calendar API v3 structure +Handles FreeBusy query operations with exact Google API compatibility +""" + +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Header, Query, status, Depends +from pydantic import ValidationError +from schemas.freebusy import ( + FreeBusyQueryRequest, + FreeBusyQueryResponse +) +from database.managers.freebusy_manager import FreeBusyManager +from database.session_manager import CalendarSessionManager +from middleware.auth import get_user_context + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/freebusy", tags=["freebusy"]) + +# Initialize managers +session_manager = CalendarSessionManager() + + +def get_freebusy_manager(database_id: str) -> FreeBusyManager: + """Get freebusy manager for the specified database""" + return FreeBusyManager(database_id) + + +@router.post("/query", response_model=FreeBusyQueryResponse) +async def query_freebusy( + request: FreeBusyQueryRequest, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Returns free/busy information for a set of calendars + + POST /freeBusy + """ + try: + database_id, user_id = user_context + freebusy_manager = get_freebusy_manager(database_id) + + # Validate request + if not request.items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one calendar item is required" + ) + + if len(request.items) > 50: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Maximum 50 calendars allowed per query" + ) + + response = freebusy_manager.query_freebusy(user_id, request) + + return response + + except ValidationError as e: + # Handle pydantic validation errors (timezone, schema validation) + logger.error(f"Schema validation error in FreeBusy query: {e}") + error_messages = [] + for error in e.errors(): + field = " -> ".join(str(loc) for loc in error["loc"]) + msg = error["msg"] + error_messages.append(f"{field}: {msg}") + detail = "Validation failed: " + "; ".join(error_messages) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) + except ValueError as e: + # Handle business logic validation errors (calendar ID existence, time range, etc.) + logger.error(f"Business validation error in FreeBusy query: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.error(f"Unexpected error processing FreeBusy query: {e}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error occurred while processing the request") + diff --git a/envs/calendar_env/server/apis/mcp/__init__.py b/envs/calendar_env/server/apis/mcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/apis/mcp/router.py b/envs/calendar_env/server/apis/mcp/router.py new file mode 100644 index 000000000..ac1ea225e --- /dev/null +++ b/envs/calendar_env/server/apis/mcp/router.py @@ -0,0 +1,31 @@ +""" +MCP (Model Context Protocol) API Router +Handles MCP protocol messages ONLY for Calendar API +Database management APIs are in apis.database_router +""" + +import logging +from fastapi import APIRouter, Request, Response +from handlers.mcp_handler import handle_mcp_request + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/mcp") +async def handle_mcp(request: Request): + """Handle MCP protocol messages""" + response = await handle_mcp_request(request) + if response is None: + # For notifications, return 204 No Content + return Response(content="", status_code=204, headers={"Content-Length": "0"}) + + # Check if response is already a dict or needs to be converted + if hasattr(response, "model_dump"): + return response.model_dump() + else: + return response + + +# MCP protocol endpoint only - all other routes are at root level (no /api prefix) \ No newline at end of file diff --git a/envs/calendar_env/server/apis/settings/router.py b/envs/calendar_env/server/apis/settings/router.py new file mode 100644 index 000000000..0c26d6ed6 --- /dev/null +++ b/envs/calendar_env/server/apis/settings/router.py @@ -0,0 +1,116 @@ +""" +Calendar Settings API endpoints following Google Calendar API v3 structure +Handles GET and LIST operations for calendar settings +""" + +import logging +from schemas.settings import SettingItem, SettingsListResponse, SettingsWatchRequest, Channel +from fastapi import APIRouter, HTTPException, Query, status, Depends +from database.managers.settings_manager import SettingManager +from database.session_manager import CalendarSessionManager +from middleware.auth import get_user_context + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/settings", tags=["settings"]) + +# Initialize managers +session_manager = CalendarSessionManager() + +def get_setting_manager(database_id: str) -> SettingManager: + return SettingManager(database_id) + + +@router.get("", response_model=SettingsListResponse, operation_id="list_settings") +async def list_settings(user_context: tuple[str, str] = Depends(get_user_context)): + """ + Lists all user-visible settings + + GET /settings + """ + try: + database_id, user_id = user_context + manager = get_setting_manager(database_id) + + # Pass user_id to manager + settings = manager.list_settings(user_id=user_id) + + return SettingsListResponse(items=settings, etag="settings-collection-etag") + except Exception as e: + logger.error(f"Error listing settings: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.get("/{settingId}", response_model=SettingItem, operation_id="get_settings") +async def get_settings( + settingId: str, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Returns a setting for the user + + GET /settings/{settingId} + """ + try: + database_id, user_id = user_context + manager = get_setting_manager(database_id) + + logger.info(f"Fetching setting {settingId} for user {user_id}") + setting = manager.get_setting_by_id(settingId, user_id=user_id) + + if not setting: + raise HTTPException(status_code=404, detail=f"Setting {settingId} not found for user {user_id}") + return setting + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting setting {settingId}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@router.post("/watch", response_model=Channel, operation_id="watch_settings") +async def watch_settings( + watch_request: SettingsWatchRequest, + user_context: tuple[str, str] = Depends(get_user_context) +): + """ + Watch for changes to settings + + POST /settings/watch + + Sets up a notification channel to receive updates when settings change. + Following the Google Calendar API v3 pattern. + """ + try: + database_id, user_id = user_context + manager = get_setting_manager(database_id) + + logger.info(f"Setting up settings watch for user {user_id} with channel {watch_request.id}") + + # Validate the watch request + if not watch_request.address: + raise HTTPException( + status_code=400, + detail="Webhook address is required" + ) + + if not watch_request.id: + raise HTTPException( + status_code=400, + detail="Channel ID is required" + ) + + # Create the watch channel + channel = manager.watch_settings(watch_request, user_id) + + logger.info(f"Successfully created settings watch channel {watch_request.id} for user {user_id}") + return channel + + except ValueError as verr: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{str(verr)}") + except HTTPException: + raise + except Exception as e: + logger.error(f"Error setting up settings watch: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + diff --git a/envs/calendar_env/server/apis/users/router.py b/envs/calendar_env/server/apis/users/router.py new file mode 100644 index 000000000..8bad8e5b8 --- /dev/null +++ b/envs/calendar_env/server/apis/users/router.py @@ -0,0 +1,95 @@ +""" +User API endpoints for user management operations +""" + +import logging +from fastapi import APIRouter, HTTPException, status, Header, Depends +from database.managers.user_manager import UserManager +from middleware.auth import authenticate_user_with_token + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/users", tags=["users"]) + +# Separate router for API endpoints that don't follow the "/users" pattern +api_router = APIRouter(tags=["user-info"]) + + +def get_user_manager(database_id: str) -> UserManager: + """Get user manager for the specified database""" + return UserManager(database_id) + + +@router.get("/email/{email}") +async def get_user_by_email(email: str, x_database_id: str = Header(alias="x-database-id")): + """ + Get user details by email address + + GET /users/email/{email} + """ + try: + if not x_database_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Missing required header: x-database-id" + ) + + user_manager = get_user_manager(x_database_id) + user_info = user_manager.get_user_by_email(email) + + if not user_info: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User not found with email: {email}") + + return user_info + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting user by email {email}: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +@api_router.get("/api/user-info") +async def get_authenticated_user_info(auth_context: tuple[str, str] = Depends(authenticate_user_with_token)): + """ + Get authenticated user information using access token + + GET /api/user-info + + Headers: + - x-database-id: Database identifier + - x-access-token: User access token + + Returns: + - user_id: User identifier + - name: User's display name + - email: User's email address + """ + try: + database_id, user_id = auth_context + + user_manager = get_user_manager(database_id) + user_info = user_manager.get_user_by_id(user_id) + + if not user_info: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Return only the required fields: user_id, name, email + response = { + "user_id": user_info["id"], + "name": user_info["name"], + "email": user_info["email"] + } + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting authenticated user info: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {str(e)}" + ) diff --git a/envs/calendar_env/server/app.py b/envs/calendar_env/server/app.py new file mode 100644 index 000000000..0dc3a1296 --- /dev/null +++ b/envs/calendar_env/server/app.py @@ -0,0 +1,56 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +FastAPI application entry point for the Calendar environment. + +This module re-exports the existing FastAPI app from main.py and provides +the standard server entry point used by OpenEnv tooling. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +SERVER_DIR = Path(__file__).resolve().parent +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + +try: + REPO_ROOT = SERVER_DIR.parents[3] +except IndexError: + REPO_ROOT = None + +if REPO_ROOT is not None: + SRC_DIR = REPO_ROOT / "src" + if SRC_DIR.is_dir() and str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +try: + from . import main as _main +except ImportError: + import importlib + + _main = importlib.import_module("main") + +app = _main.app + + +def main(host: str = "0.0.0.0", port: int | None = None): + """Run the Calendar environment server with uvicorn.""" + + import uvicorn + + if port is None: + port = int(os.getenv("API_PORT", "8004")) + + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/envs/calendar_env/server/calendar_environment.py b/envs/calendar_env/server/calendar_environment.py new file mode 100644 index 000000000..74b5057b5 --- /dev/null +++ b/envs/calendar_env/server/calendar_environment.py @@ -0,0 +1,23 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Calendar Environment wrapper. + +This file provides the standard environment class name expected in OpenEnv +layouts while reusing the existing MCP environment implementation. +""" + +from .openenv_wrapper.mcp_env_environment import MCPEnvironment + + +class CalendarEnvironment(MCPEnvironment): + """Calendar environment backed by MCP tools.""" + + pass + + +__all__ = ["CalendarEnvironment"] diff --git a/envs/calendar_env/server/calendar_mcp/__init__.py b/envs/calendar_env/server/calendar_mcp/__init__.py new file mode 100644 index 000000000..38ca682e8 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/__init__.py @@ -0,0 +1,3 @@ +""" +MCP (Model Context Protocol) module for Calendar API +""" \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/__init__.py b/envs/calendar_env/server/calendar_mcp/tools/__init__.py new file mode 100644 index 000000000..7e4289298 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/__init__.py @@ -0,0 +1,40 @@ +""" +MCP Tools Module - Calendar API Tools + +This module aggregates all MCP tools for the Calendar API endpoints. +Includes calendar management tools: +- Calendars (create, read, update, delete, clear, list) +- CalendarList (list, get, insert, patch, put, delete, watch) +- Events (list, create, get, patch, update, delete, move, quickAdd, import, instances, watch) +- Colors (get color definitions) +- FreeBusy (query) +- ACL (access control list management) +- Users (user management) +- Settings (calendar settings) + +Each tool corresponds to Calendar API endpoints +""" + +# Import calendar tool categories +from .acl import ACL_TOOLS +from .calendars import CALENDARS_TOOLS +from .calendar_list import CALENDAR_LIST_TOOLS +from .events import EVENTS_TOOLS +from .colors import COLORS_TOOLS +from .users import USERS_TOOLS +from .settings import SETTINGS_TOOLS +from .freebusy import FREEBUSY_TOOLS + +# Combine all tools into the main MCP_TOOLS list +MCP_TOOLS = [] +MCP_TOOLS.extend(CALENDARS_TOOLS) +MCP_TOOLS.extend(CALENDAR_LIST_TOOLS) +MCP_TOOLS.extend(EVENTS_TOOLS) +MCP_TOOLS.extend(COLORS_TOOLS) +MCP_TOOLS.extend(USERS_TOOLS) +MCP_TOOLS.extend(SETTINGS_TOOLS) +MCP_TOOLS.extend(ACL_TOOLS) +MCP_TOOLS.extend(FREEBUSY_TOOLS) + + +print(f"📦 MCP Tools Module Loaded: {len(MCP_TOOLS)} calendar API tools across 8 modules") \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/acl.py b/envs/calendar_env/server/calendar_mcp/tools/acl.py new file mode 100644 index 000000000..a41aa77fc --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/acl.py @@ -0,0 +1,351 @@ +""" +ACL Tools Module + +This module contains tools related to ACL. +Follows Google Calendar API v3 structure for ACL operations. +""" +ACL_TOOLS = [ + { + "name": "get_acl_rule", + "description": """Retrieve an access control rule by ID. + + Fetches the ACL rule for a calendar by rule ID. + Follows the structure of Google Calendar API v3 `/calendars/{calendarId}/acl/{ruleId}`. + + Required Parameters: + - calendarId: Calendar identifier + - ruleId: ACL rule identifier + + Response Structure: + - kind: "calendar#aclRule" + - etag: ETag + - id: ACL rule ID + - scope: { type, value } + - role: ACL role (e.g., "reader", "writer") + + Status Codes: + - 200: Success + - 404: Not Found + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "ruleId": {"type": "string", "minLength": 1} + }, + "required": ["calendarId", "ruleId"] + } + }, + { + "name": "list_acl_rules", + "description": """List ACL rules for a calendar with pagination and filtering support. + + Returns access control rules for the given calendar with support for pagination and incremental synchronization. + Follows the structure of Google Calendar API v3 `/calendars/{calendarId}/acl`. + + Required Parameters: + - calendarId: Calendar identifier + + Optional Parameters: + - maxResults: Maximum number of entries returned on one result page (1-250, default 100) + - pageToken: Token specifying which result page to return + - showDeleted: Whether to include deleted ACLs (role="none") in the result (default False) + - syncToken: Token for incremental synchronization, returns only entries changed since token + + Response Structure: + - kind: "calendar#acl" + - etag: ETag of the ACL collection + - items: Array of ACL rules + - nextPageToken: Token for next page (if more results available) + - nextSyncToken: Token for next sync operation + + Synchronization: + - When syncToken is provided, showDeleted is automatically set to True + - Deleted ACLs are always included in sync responses + - If syncToken expires, server responds with 410 GONE + + Status Codes: + - 200: Success + - 400: Bad Request (invalid parameters) + - 410: Gone (sync token expired) + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "maxResults": { + "type": "integer", + "minimum": 1, + "maximum": 250, + "default": 100, + "description": "Maximum number of entries returned on one result page" + }, + "pageToken": { + "type": "string", + "description": "Token specifying which result page to return" + }, + "showDeleted": { + "type": "boolean", + "default": False, + "description": "Whether to include deleted ACLs in the result" + }, + "syncToken": { + "type": "string", + "description": "Token for incremental synchronization" + } + }, + "required": ["calendarId"] + } + }, + { + "name": "insert_acl_rule", + "description": """Insert a new access control rule. + + Adds a new ACL rule to the specified calendar. + Equivalent to: POST /calendars/{calendarId}/acl + + Required Parameters: + - calendarId: Calendar identifier + - rule: ACL rule input (scope, role) + + Optional Parameters: + - sendNotifications: Whether to send notifications about the calendar sharing change (default: True) + + Response Structure: + - kind: "calendar#aclRule" + - etag: ETag + - id: ACL rule ID + - scope: { type, value } + - role: ACL role + + Status Codes: + - 201: Created + - 400: Bad Request + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "scope": { + "type": "object", + "properties": { + "type": {"type": "string", "minLength": 1}, + "value": {"type": "string", "minLength": 1} + }, + "required": ["type"] + }, + "role": {"type": "string", "minLength": 1}, + "sendNotifications": { + "type": "boolean", + "default": True, + "description": "Whether to send notifications about the calendar sharing change" + } + }, + "required": ["calendarId", "scope", "role"] + } + }, + { + "name": "update_acl_rule", + "description": """Fully update an existing ACL rule. + + Replaces an ACL rule with a new one. + Equivalent to: PUT /calendars/{calendarId}/acl/{ruleId} + + Required Parameters: + - calendarId: Calendar identifier + - ruleId: ACL rule ID + - rule: Complete rule replacement (scope, role) + + Optional Parameters: + - sendNotifications: Whether to send notifications about the calendar sharing change (default: True) + + Response: + - Updated ACL rule object + + Status Codes: + - 200: Success + - 404: Not Found + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "ruleId": {"type": "string", "minLength": 1}, + "scope": { + "type": "object", + "properties": { + "type": {"type": "string", "minLength": 1}, + "value": {"type": "string", "minLength": 1} + }, + "required": ["type"] + }, + "role": {"type": "string", "minLength": 1}, + "sendNotifications": { + "type": "boolean", + "default": True, + "description": "Whether to send notifications about the calendar sharing change" + } + }, + "required": ["calendarId", "ruleId", "scope"] + } + }, + { + "name": "patch_acl_rule", + "description": """Partially update an ACL rule. + + Allows modifying select fields of an ACL rule. + Equivalent to: PATCH /calendars/{calendarId}/acl/{ruleId} + + Required Parameters: + - calendarId: Calendar identifier + - ruleId: ACL rule ID + - rule: Partial updates (any of: scope, role) + + Optional Parameters: + - sendNotifications: Whether to send notifications about the calendar sharing change (default: True) + + Response: + - Updated ACL rule object + + Status Codes: + - 200: Success + - 404: Not Found + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "ruleId": {"type": "string", "minLength": 1}, + "scope": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "value": {"type": "string"} + } + }, + "role": {"type": "string"}, + "sendNotifications": { + "type": "boolean", + "default": True, + "description": "Whether to send notifications about the calendar sharing change" + }, + }, + "required": ["calendarId", "ruleId"] + } + }, + { + "name": "delete_acl_rule", + "description": """Delete an ACL rule. + + Deletes a specific ACL rule from the calendar. + Equivalent to: DELETE /calendars/{calendarId}/acl/{ruleId} + + Required Parameters: + - calendarId: Calendar identifier + - ruleId: ACL rule ID + + Response: + - No content (204) on success + + Status Codes: + - 204: No Content + - 404: Not Found + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": {"type": "string", "minLength": 1}, + "ruleId": {"type": "string", "minLength": 1}, + }, + "required": ["calendarId", "ruleId"] + } + }, + { + "name": "watch_acl", + "description": """Set up a webhook to receive notifications when ACL rules change. + + Sets up webhook notifications for ACL rule changes following Google Calendar API v3 structure. + Returns a channel for managing the watch. Monitors ACL rules in the specified calendar for changes. + + Equivalent to: POST /calendars/{calendarId}/acl/watch + + Required Parameters: + - calendarId: Calendar identifier to watch for ACL changes + - id: Unique channel identifier + - address: Webhook URL to receive notifications + - type: Channel type (default: "web_hook") + + + Optional Parameters: + - token: Optional token for webhook authentication + - params: Additional channel parameters + + Response Structure: + - Returns Channel resource with Google Calendar API v3 format: + * kind: "api#channel" + * id: Channel identifier + * resourceId: Resource being watched + * resourceUri: Resource URI path + * token: Authentication token (if provided) + * expiration: Channel expiration time (if set) + + Channel Management: + - Each watch creates a unique notification channel + - Channels can expire (set expiration time) + - Multiple channels can watch the same calendar ACL + - Use unique channel IDs to avoid conflicts + + Status Codes: + - 200: Success - Watch channel created successfully + - 400: Bad Request - Invalid channel configuration + - 404: Not Found - Calendar not found + - 500: Internal Server Error + """, + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "minLength": 1, + "description": "Calendar identifier to watch for ACL changes" + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Unique channel identifier" + }, + "type": { + "type": "string", + "default": "web_hook", + "description": "Channel type (only 'web_hook' supported)" + }, + "address": { + "type": "string", + "minLength": 1, + "description": "Webhook URL to receive notifications" + }, + "token": { + "type": "string", + "description": "Optional token for webhook authentication" + }, + "params": { + "type": "object", + "description": "Additional channel parameters", + "properties": { + "ttl": { + "type": "string", + "description": "Time to live in seconds (string)." + } + } + } + }, + "required": ["calendarId", "id", "type", "address"] + } + } +] diff --git a/envs/calendar_env/server/calendar_mcp/tools/calendar_list.py b/envs/calendar_env/server/calendar_mcp/tools/calendar_list.py new file mode 100644 index 000000000..e56bad9b9 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/calendar_list.py @@ -0,0 +1,704 @@ +""" +CalendarList Tools Module + +This module contains tools related to calendar list management. +Covers all 7 Google Calendar API v3 CalendarList endpoints for user calendar list operations. +""" + +CALENDAR_LIST_TOOLS = [ + { + "name": "get_calendar_list", + "description": """Returns the calendars on the user's calendar list. + + Lists all calendars in the user's calendar list with their display settings and access permissions. + Returns calendars with user-specific customizations like colors, visibility, and notification settings. + Supports pagination through pageToken and incremental synchronization through syncToken. + + Request Body Requirements: + - maxResults: Optional. Maximum number of entries returned (pagination). If 0, returns no items. + - minAccessRole: Optional. Minimum access role filter (freeBusyReader, reader, writer, owner) + - pageToken: Optional. Token specifying which result page to return (for pagination) + - showDeleted: Optional. Include deleted calendars in results (default: false) + - showHidden: Optional. Include hidden calendars in results (default: false) + - syncToken: Optional. Token for incremental synchronization (returns only changed entries) + + Pagination Support: + - Use maxResults to limit the number of calendars returned per page + - Use pageToken to retrieve subsequent pages of results + - Check nextPageToken in response to determine if more results are available + - Pass nextPageToken as pageToken in the next request to get the next page + + Incremental Synchronization: + - Use syncToken to get only entries that have changed since the last request + - syncToken cannot be used together with minAccessRole parameter + - When using syncToken, deleted and hidden entries are automatically included + - If syncToken expires, server returns 410 GONE and client should perform full sync + - Use nextSyncToken from response for subsequent incremental sync requests + + Response Structure: + - Returns calendar list collection with Google Calendar API v3 format: + * kind: "calendar#calendarList" + * etag: ETag of the collection + * items: Array of CalendarListEntry objects + * nextPageToken: Token for next page (if more results available) + * nextSyncToken: Token for incremental sync (always provided when items are returned) + + CalendarListEntry Structure: + - Each item contains calendar metadata plus user-specific settings: + * id: Calendar identifier + * summary: Display title (with summaryOverride if set) + * accessRole: User's permission level + * primary: Whether it's the user's primary calendar + * backgroundColor/foregroundColor: Display colors + * hidden: Whether hidden from calendar list + * selected: Whether selected in UI + * defaultReminders: User-specific default reminders + * notificationSettings: Notification preferences + + Status Codes: + - 200: Success - Calendar list retrieved successfully + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "maxResults": { + "type": "integer", + "description": "Maximum number of entries returned. If 0, returns no items.", + "minimum": 0, + "maximum": 250, + "default": 100 + }, + "minAccessRole": { + "type": "string", + "description": "Minimum access role filter", + "enum": ["freeBusyReader", "reader", "writer", "owner"] + }, + "pageToken": { + "type": "string", + "description": "Token specifying which result page to return (for pagination)" + }, + "showDeleted": { + "type": "boolean", + "description": "Include deleted calendars in results", + "default": False + }, + "showHidden": { + "type": "boolean", + "description": "Include hidden calendars in results", + "default": False + }, + "syncToken": { + "type": "string", + "description": "Token for incremental synchronization (returns only changed entries since last sync)" + } + }, + "required": [] + } + }, + { + "name": "get_calendar_from_list", + "description": """Returns a calendar from the user's calendar list. + + Retrieves a specific calendar entry from the user's calendar list with all user-specific settings. + Shows how the calendar appears in the user's list with customizations and access permissions. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary' to refer to the user's primary calendar + + Response Structure: + - Returns CalendarListEntry with Google Calendar API v3 format: + * kind: "calendar#calendarListEntry" + * etag: ETag of the resource + * id: Calendar identifier + * summary: Display title (with summaryOverride if set) + * description: Calendar description + * location: Calendar location + * timeZone: Calendar timezone + * summaryOverride: Custom title override for this user + * colorId: Calendar color ID + * backgroundColor: Background color (hex) + * foregroundColor: Foreground color (hex) + * hidden: Whether hidden from calendar list + * selected: Whether selected in UI + * accessRole: User's permission level (freeBusyReader, reader, writer, owner) + * defaultReminders: User-specific default reminders + * notificationSettings: Notification preferences + * primary: Whether it's the user's primary calendar + * deleted: Whether the calendar is deleted + + Status Codes: + - 200: Success - Calendar entry retrieved successfully + - 404: Not Found - Calendar not found in user's list + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1 + } + }, + "required": ["calendarId"] + } + }, + { + "name": "add_calendar_to_list", + "description": """Inserts an existing calendar into the user's calendar list. + + Adds an existing calendar to the user's calendar list with custom display settings. + The calendar must already exist - this endpoint only adds it to the user's list with personalization. + + Request Body Requirements: + - id: Required. Calendar ID to add to user's list + - summaryOverride: Optional. Custom calendar title override + - colorId: Optional. Calendar color ID + - backgroundColor: Optional. Background color (hex format like #FF5733) + - foregroundColor: Optional. Foreground color (hex format like #FFFFFF) + - hidden: Optional. Whether calendar is hidden from list (default: false) + - selected: Optional. Whether calendar is selected in UI (default: true) + - defaultReminders: Optional. Array of default reminder settings + - notificationSettings: Optional. Notification preferences + + Calendar Must Exist: + - The calendar with the specified ID must already exist in the database + - This endpoint does not create new calendars, only adds them to user's list + - Use create_calendar tool first if the calendar doesn't exist + + Response Structure: + - Returns the created CalendarListEntry with Google Calendar API v3 format + - Includes all user-specific customizations applied + + Colors: + - To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true + - When RGB fields are provided, colorId (if present) is ignored + + Status Codes: + - 201: Created - Calendar added to list successfully + - 404: Not Found - Calendar with specified ID not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Calendar ID to add to user's list (UUID)", + "minLength": 1 + }, + "colorRgbFormat": { + "type": "boolean", + "description": "Query param: if true, allows writing backgroundColor/foregroundColor", + "default": False + }, + "summaryOverride": { + "type": "string", + "description": "Custom calendar title override", + "maxLength": 255 + }, + "colorId": { + "type": "string", + "description": "Calendar color ID", + "maxLength": 50 + }, + "backgroundColor": { + "type": "string", + "description": "Background color (hex format like #FF5733)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "foregroundColor": { + "type": "string", + "description": "Foreground color (hex format like #FFFFFF)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "hidden": { + "type": "boolean", + "description": "Whether calendar is hidden from list", + "default": False + }, + "selected": { + "type": "boolean", + "description": "Whether calendar is selected in UI", + "default": True + }, + "defaultReminders": { + "type": "array", + "description": "Default reminder settings", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["email", "popup"], + "description": "Reminder delivery method" + }, + "minutes": { + "type": "integer", + "description": "Minutes before event to trigger reminder", + "minimum": 0 + } + }, + "required": ["method", "minutes"] + } + }, + "notificationSettings": { + "type": "object", + "description": "Notification preferences", + "properties": { + "notifications": { + "type": "array", + "description": "List of notification settings", + "items": { + "type": "object", + "description": "Individual notification setting", + "properties": { + "method": { + "type": "string", + "enum": ["email"], + "description": "Notification delivery method (only 'email' supported)" + }, + "type": { + "type": "string", + "enum": [ + "eventCreation", + "eventChange", + "eventCancellation", + "eventResponse", + "agenda" + ], + "description": "Notification type" + } + }, + "required": ["method", "type"] + } + } + } + } + }, + "required": ["id"] + } + }, + { + "name": "update_calendar_in_list", + "description": """Updates an entry on the user's calendar list (partial update). + + Partially updates calendar list entry settings. Only provided fields will be updated, + others remain unchanged. This allows fine-grained control over calendar display settings. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or 'primary' + + Request Body (all optional): + - summaryOverride: Custom calendar title override + - colorId: Calendar color ID + - backgroundColor: Background color (hex format like #FF5733) + - foregroundColor: Foreground color (hex format like #FFFFFF) + - hidden: Whether calendar is hidden from list + - selected: Whether calendar is selected in UI + - defaultReminders: Array of default reminder settings + - notificationSettings: Notification preferences + + Partial Update Behavior: + - Only fields provided in request body are updated + - Null values will clear the field (set to null) for string/complex fields + - 'hidden' and 'selected' cannot be null (booleans are NOT NULL in our DB) + - Missing fields are left unchanged + - At least one field must be provided for update + + Response Structure: + - Returns updated CalendarListEntry with Google Calendar API v3 format + - Shows all current settings including unchanged fields + + Colors: + - To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true + - When RGB fields are provided, colorId (if present) is ignored + + Behavioral coupling: + - If hidden=true, the server will force selected=false + - If hidden=false, the server will force selected=true (matches observed UI behavior) + + Status Codes: + - 200: Success - Calendar list entry updated successfully + - 400: Bad Request - No fields provided for update + - 404: Not Found - Calendar not found in user's list + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1 + }, + "colorRgbFormat": { + "type": "boolean", + "description": "Query param: if true, allows writing backgroundColor/foregroundColor", + "default": False + }, + "summaryOverride": { + "type": "string", + "description": "Custom calendar title override", + "maxLength": 255 + }, + "colorId": { + "type": "string", + "description": "Calendar color ID", + "maxLength": 50 + }, + "backgroundColor": { + "type": "string", + "description": "Background color (hex format like #FF5733)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "foregroundColor": { + "type": "string", + "description": "Foreground color (hex format like #FFFFFF)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "hidden": { + "type": "boolean", + "description": "Whether calendar is hidden from list", + "default": False + }, + "selected": { + "type": "boolean", + "description": "Whether calendar is selected in UI", + "default": True + }, + "defaultReminders": { + "type": "array", + "description": "Default reminder settings", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["email", "popup"], + "description": "Reminder delivery method" + }, + "minutes": { + "type": "integer", + "description": "Minutes before event to trigger reminder", + "minimum": 0 + } + }, + "required": ["method", "minutes"] + } + }, + "notificationSettings": { + "type": "object", + "description": "Notification preferences", + "properties": { + "notifications": { + "type": "array", + "description": "List of notification settings", + "items": { + "type": "object", + "description": "Individual notification setting", + "properties": { + "method": { + "type": "string", + "enum": ["email"], + "description": "Notification delivery method (only 'email' supported)" + }, + "type": { + "type": "string", + "enum": [ + "eventCreation", + "eventChange", + "eventCancellation", + "eventResponse", + "agenda" + ], + "description": "Notification type" + } + }, + "required": ["method", "type"] + } + } + } + } + }, + "required": ["calendarId"] + } + }, + { + "name": "replace_calendar_in_list", + "description": """Updates an entry on the user's calendar list (full update). + + Fully updates calendar list entry settings. All fields are replaced with provided values. + Fields not provided will be set to their default values (full replacement). + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or 'primary' + + Request Body (all optional, but null/missing values will be set to defaults): + - summaryOverride: Custom calendar title override (null to clear) + - colorId: Calendar color ID (null to clear) + - backgroundColor: Background color (hex format like #FF5733, null to clear) + - foregroundColor: Foreground color (hex format like #FFFFFF, null to clear) + - hidden: Whether calendar is hidden from list (default: false) + - selected: Whether calendar is selected in UI (default: true) + - defaultReminders: Array of default reminder settings (null to clear) + - notificationSettings: Notification preferences (null to clear) + - conferenceProperties: Conference properties for this calendar (null to clear) + + Full Update Behavior: + - All fields are replaced (full replacement operation) + - Missing optional fields are set to null/defaults + - Required fields (hidden, selected) get default values if not provided + - This is different from PATCH which only updates provided fields + + Response Structure: + - Returns updated CalendarListEntry with Google Calendar API v3 format + - Shows all current settings after full update + + Colors: + - To set backgroundColor/foregroundColor you must pass query param colorRgbFormat=true + - When RGB fields are provided, colorId (if present) is ignored + + Behavioral coupling: + - If hidden=true, the server will force selected=false + - If hidden=false, the server will force selected=true (matches observed UI behavior) + + Status Codes: + - 200: Success - Calendar list entry updated successfully + - 404: Not Found - Calendar not found in user's list + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1 + }, + "colorRgbFormat": { + "type": "boolean", + "description": "Query param: if true, allows writing backgroundColor/foregroundColor", + "default": False + }, + "summaryOverride": { + "type": "string", + "description": "Custom calendar title override", + "maxLength": 255 + }, + "colorId": { + "type": "string", + "description": "Calendar color ID", + "maxLength": 50 + }, + "backgroundColor": { + "type": "string", + "description": "Background color (hex format like #FF5733)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "foregroundColor": { + "type": "string", + "description": "Foreground color (hex format like #FFFFFF)", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "hidden": { + "type": "boolean", + "description": "Whether calendar is hidden from list", + "default": False + }, + "selected": { + "type": "boolean", + "description": "Whether calendar is selected in UI", + "default": True + }, + "defaultReminders": { + "type": "array", + "description": "Default reminder settings", + "items": { + "type": "object", + "properties": { + "method": { + "type": "string", + "description": "Reminder delivery method (email, popup). Empty string allowed to clear" + }, + "minutes": { + "type": "integer", + "description": "Minutes before event to trigger reminder", + "minimum": 0 + } + } + } + }, + "notificationSettings": { + "type": "object", + "description": "Notification preferences", + "properties": { + "notifications": { + "type": "array", + "description": "List of notification settings", + "items": { + "type": "object", + "description": "Individual notification setting", + "properties": { + "method": { + "type": "string", + "description": "Notification delivery method. Empty string allowed to clear" + }, + "type": { + "type": "string", + "description": "Notification type. Empty string allowed to clear" + } + } + } + } + } + }, + "conferenceProperties": { + "type": "object", + "description": "Conference properties for this calendar", + "properties": { + "allowedConferenceSolutionTypes": { + "type": "array", + "description": "The types of conference solutions that are supported for this calendar", + "items": { + "type": "string", + "enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"], + "description": "Conference solution type" + } + } + } + } + }, + "required": ["calendarId"] + } + }, + { + "name": "remove_calendar_from_list", + "description": """Removes a calendar from the user's calendar list. + + Removes a calendar from the user's calendar list (soft delete). + The calendar itself remains in the database but is no longer visible in the user's list. + Primary calendars cannot be removed from the calendar list. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) + + Primary Calendar Protection: + - Primary calendars cannot be removed from the calendar list + - Attempting to remove primary calendar returns 400 Bad Request + - Primary calendars are always part of the user's list + + Operation Details: + - Calendar is soft-deleted (marked as deleted, not physically removed) + - Calendar data remains in database but is hidden from list + - Calendar can potentially be re-added to list later + - Does not affect the underlying calendar data or events + + Response Structure: + - Returns 204 No Content on successful removal + - No response body as per Google Calendar API v3 + + Status Codes: + - 204: No Content - Calendar removed from list successfully + - 400: Bad Request - Attempted to remove primary calendar from list + - 404: Not Found - Calendar not found in user's list + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1 + } + }, + "required": ["calendarId"] + } + }, + { + "name": "watch_calendar_list", + "description": """Watch for changes to CalendarList resources. + + Sets up webhook notifications (Channel) for changes to the user's calendar list. + Monitors for additions, removals, and updates to calendar list entries. + + Request Body (Channel): + - id: Required. Unique channel identifier for this watch + - type: Channel type (only "web_hook" (or "webhook") supported) + - address: Required. HTTPS URL where notifications will be sent + - token: Optional. Verification token for webhook security + + Webhook Notifications (simplified): + - Server will send POST requests to the specified address + - Notifications triggered by calendar list changes: + * Calendar added to or removed from list + * Calendar list entry settings updated + * Calendar permissions changed + + Channel Response: + - Returns Channel object with fields: kind, id, resourceId, resourceUri, token, expiration, type, address + - resourceUri is the collection path: /users/me/calendarList + - resourceId is generated by the server + + Channel Management: + - Each watch creates a unique notification channel + - Channels can expire (set expiration time) + - Multiple channels can watch the same resource + - Use unique channel IDs to avoid conflicts + + Response Structure: + - Returns Channel resource with Google Calendar API v3 format: + * kind: "api#channel" + * id: Channel identifier + * resourceId: Resource being watched + * resourceUri: Resource URI path + * token: Verification token (if provided) + * expiration: Channel expiration time (if set) + * type: Channel type "web_hook" (or "webhook"). + * address: Notification delivery address + + Status Codes: + - 200: Success - Watch channel created successfully + - 400: Bad Request - Invalid channel configuration or query parameters + - 500: Internal Server Error + + Note: This is a simplified implementation. In production, you would need: + - Webhook endpoint verification + - Channel management and cleanup + - Actual change detection and notification dispatch""", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique channel identifier for this watch", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Channel type (only web_hook supported; 'webhook' accepted as alias)", + "enum": ["web_hook", "webhook"], + "default": "web_hook" + }, + "address": { + "type": "string", + "description": "HTTPS URL where notifications will be sent", + "format": "uri", + "minLength": 1 + }, + "token": { + "type": "string", + "description": "Verification token for webhook security", + "maxLength": 256 + }, + "params": { + "type": "object", + "description": "Optional parameters (Google spec supports 'ttl' in seconds as string)", + "properties": { + "ttl": { + "type": "string", + "description": "Time to live in seconds (string). Server computes expiration = now + ttl" + } + } + } + }, + "required": ["id", "type", "address"] + } + } +] \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/calendars.py b/envs/calendar_env/server/calendar_mcp/tools/calendars.py new file mode 100644 index 000000000..ae7b47a78 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/calendars.py @@ -0,0 +1,353 @@ +""" +Calendars Tools Module + +This module contains tools related to calendar management. +Covers calendar CRUD operations, clearing, and listing functionality. +""" + +CALENDARS_TOOLS = [ + { + "name": "create_calendar", + "description": """Create a new secondary calendar. + + Creates a new calendar following Google Calendar API v3 structure. This endpoint + strictly creates a secondary calendar. It does not create or promote a primary calendar. + The user must already exist; otherwise a 404 is returned. + + Request Body Requirements: + - summary: Required. Calendar title (1-255 characters) + - description: Optional. Calendar description (max 1000 characters) + - location: Optional. Geographic location (max 500 characters) + - timeZone: Optional. Calendar timezone in IANA format (default: UTC) + - conferenceProperties: Optional. Conference solution settings + + Notes: + - This operation cannot create a primary calendar. Use account provisioning or separate tooling to ensure a primary exists. + + Response Structure: + - Returns the created calendar with Google Calendar API v3 format: + * kind: "calendar#calendar" + * etag: ETag of the resource + * id: Unique calendar identifier (UUID) + * summary: Calendar title + * description: Calendar description (if provided) + * location: Calendar location (if provided) + * timeZone: Calendar timezone + * conferenceProperties: Conference settings (if provided) + + Status Codes: + - 201: Created - Calendar created successfully + - 400: Bad Request - Invalid calendar data + - 404: Not Found - User not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Calendar title (1-255 characters)", + "minLength": 1, + "maxLength": 255, + }, + "description": { + "type": "string", + "description": "Calendar description (max 1000 characters)", + "maxLength": 1000, + }, + "location": { + "type": "string", + "description": "Geographic location (max 500 characters)", + "maxLength": 500, + }, + "timeZone": { + "type": "string", + "description": "Calendar timezone in IANA format (default: UTC)", + "default": "UTC", + }, + "conferenceProperties": { + "type": "object", + "description": "Conference solution settings", + "properties": { + "allowedConferenceSolutionTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"] + }, + "description": "Allowed conference solution types" + } + } + } + }, + "required": ["summary"] + } + }, + { + "name": "get_calendar", + "description": """Retrieve a specific calendar by its ID (supports 'primary'). + + Returns calendar metadata following Google Calendar API v3 structure. + Supports using the special keyword 'primary' as the calendar identifier to + target the user's primary calendar. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary' + + Response Structure: + - Returns calendar with Google Calendar API v3 format: + * kind: "calendar#calendar" + * etag: ETag of the resource + * id: Unique calendar identifier + * summary: Calendar title + * description: Calendar description (if present) + * location: Calendar location (if present) + * timeZone: Calendar timezone + * conferenceProperties: Conference settings (if present) + + Status Codes: + - 200: Success - Calendar retrieved successfully + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID) or the keyword 'primary'", + "minLength": 1, + } + }, + "required": ["calendarId"] + } + }, + { + "name": "patch_calendar", + "description": """Partially update calendar metadata (cannot change which calendar is primary). + + Partially updates calendar metadata following Google Calendar API v3 structure. + Only provided fields will be updated, others remain unchanged. + You can update both primary and secondary calendars. + Supports using the special keyword 'primary' as the calendar identifier. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary' + + Request Body (all optional): + - summary: Calendar title (1-255 characters) + - description: Calendar description (max 1000 characters) + - location: Geographic location (max 500 characters) + - timeZone: Calendar timezone in IANA format + - conferenceProperties: Conference solution settings + + Restrictions: + - Cannot modify which calendar is primary (the is_primary flag is immutable via PATCH) + - At least one field must be provided for update + - Primary status is automatically assigned and protected + + Response Structure: + - Returns updated calendar with Google Calendar API v3 format + + Status Codes: + - 200: Success - Calendar updated successfully + - 400: Bad Request - No fields provided or attempt to modify primary status + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID) or the keyword 'primary'", + "minLength": 1, + }, + "summary": { + "type": "string", + "description": "Calendar title (1-255 characters)", + "minLength": 1, + "maxLength": 255, + }, + "description": { + "type": "string", + "description": "Calendar description (max 1000 characters)", + "maxLength": 1000, + }, + "location": { + "type": "string", + "description": "Geographic location (max 500 characters)", + "maxLength": 500, + }, + "timeZone": { + "type": "string", + "description": "Calendar timezone in IANA format", + }, + "conferenceProperties": { + "type": "object", + "description": "Conference solution settings", + "properties": { + "allowedConferenceSolutionTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"] + }, + "description": "Allowed conference solution types" + } + } + } + }, + "required": ["calendarId"] + } + }, + { + "name": "update_calendar", + "description": """Fully update calendar metadata (cannot change which calendar is primary). + + Completely updates calendar metadata following Google Calendar API v3 structure. + All provided fields replace existing values. Optional fields omitted will remain unchanged + in the current implementation. You can update both primary and secondary calendars. + Primary calendar status (which calendar is primary) cannot be modified via this endpoint. + Supports using the special keyword 'primary' as the calendar identifier. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) or the keyword 'primary' + + Request Body (all optional; null clears for description/location/conferenceProperties): + - summary: Calendar title (1-255 characters) + - description: Calendar description (max 1000 characters) - null to clear + - location: Geographic location (max 500 characters) - null to clear + - timeZone: Calendar timezone in IANA format + - conferenceProperties: Conference solution settings - null to clear + + Restrictions: + - Cannot modify which calendar is primary (the is_primary flag is immutable via PUT) + + Response Structure: + - Returns updated calendar with Google Calendar API v3 format + + Status Codes: + - 200: Success - Calendar updated successfully + - 400: Bad Request - Invalid update data or attempt to modify primary status + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID) or the keyword 'primary'", + "minLength": 1 + }, + "summary": { + "type": "string", + "description": "Calendar title (1-255 characters)", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": "string", + "description": "Calendar description (max 1000 characters)", + "maxLength": 1000 + }, + "location": { + "type": "string", + "description": "Geographic location (max 500 characters)", + "maxLength": 500 + }, + "timeZone": { + "type": "string", + "description": "Calendar timezone in IANA format" + }, + "conferenceProperties": { + "type": "object", + "description": "Conference solution settings", + "properties": { + "allowedConferenceSolutionTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["eventHangout", "eventNamedHangout", "hangoutsMeet"] + }, + "description": "Allowed conference solution types" + } + } + } + }, + "required": ["calendarId"] + } + }, + { + "name": "delete_calendar", + "description": """Delete a secondary calendar (cannot delete primary calendar). + + Deletes a calendar following Google Calendar API v3 behavior. + Primary calendars cannot be deleted - use clear operation instead. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) + + Primary Calendar Protection: + - Primary calendars cannot be deleted + - Attempting to delete primary calendar returns 400 Bad Request + - Use clear_calendar tool to remove events from primary calendar + + Cascade Behavior: + - Deleting a calendar also deletes all associated events + - This operation is irreversible + + Response Structure: + - Returns 204 No Content on successful deletion + - No response body as per Google Calendar API v3 + + Status Codes: + - 204: No Content - Calendar deleted successfully + - 400: Bad Request - Attempted to delete primary calendar + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1, + } + }, + "required": ["calendarId"] + } + }, + { + "name": "clear_calendar", + "description": """Clear all events from a calendar (useful for primary calendars). + + Clears all events from a calendar following Google Calendar API v3 behavior. + This is the recommended way to "reset" a primary calendar since primary calendars cannot be deleted. + + Request Body Requirements: + - calendarId: Required. Unique calendar identifier (UUID) + + Operation Details: + - Removes all events from the specified calendar + - Calendar metadata remains unchanged + - Useful for primary calendars that cannot be deleted + - Can be used on any calendar (primary or secondary) + + Response: + - Returns 204 No Content on successful clear (no response body) + + Status Codes: + - 204: No Content - Calendar cleared successfully + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Unique calendar identifier (UUID)", + "minLength": 1, + } + }, + "required": ["calendarId"] + } + }, +] \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/colors.py b/envs/calendar_env/server/calendar_mcp/tools/colors.py new file mode 100644 index 000000000..dc0c86137 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/colors.py @@ -0,0 +1,57 @@ +""" +Colors Tools Module + +This module contains tools related to Google Calendar color definitions. +Provides static color palettes for calendars and events. +""" + +COLORS_TOOLS = [ + { + "name": "get_colors", + "description": """Retrieve the color definitions for calendars and events. + + Returns the global palette of color definitions used by Google Calendar. + This endpoint provides static color data and does not require authentication + or user context as it returns predefined color schemes. + + Color Structure: + - calendar: Object mapping color IDs to calendar color definitions + - event: Object mapping color IDs to event color definitions + + Each color definition contains: + - background: The background color (hex format) + - foreground: The foreground color for text (hex format) + + Available Calendar Colors: 24 predefined colors (IDs 1-24) + Available Event Colors: 11 predefined colors (IDs 1-11) + + Usage Examples: + - Get all available colors for UI color pickers + - Validate color IDs before setting calendar/event colors + - Display color options to users + + Response Structure: + - Returns colors with Google Calendar API v3 format: + * kind: "calendar#colors" + * updated: Last modification timestamp + * calendar: Object with calendar color definitions + * event: Object with event color definitions + + Color Examples: + - Calendar Color 1: Background "#ac725e" (brown), Foreground "#1d1d1d" (dark) + - Event Color 1: Background "#a4bdfc" (light blue), Foreground "#1d1d1d" (dark) + - Event Color 11: Background "#dc2127" (red), Foreground "#1d1d1d" (dark) + + API Endpoint: GET /colors + + Status Codes: + - 200: Success - Color definitions retrieved + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False + } + } +] \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/events.py b/envs/calendar_env/server/calendar_mcp/tools/events.py new file mode 100644 index 000000000..8f9dd901f --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/events.py @@ -0,0 +1,2503 @@ +""" +Events MCP tools for Google Calendar API v3 compatibility +All 11 Events API endpoints with clean tool definitions +""" + +EVENTS_TOOLS = [ + { + "name": "list_events", + "description": """List events on the specified calendar. + + Returns events from a calendar following Google Calendar API v3 structure. + Supports filtering by time range, search terms, and other parameters to retrieve relevant events. + + Request Body Requirements: + - calendarId: Required. Calendar identifier to list events from + + Optional Parameters: + - eventTypes: Event types to return (string: default, birthday, focusTime, fromGmail, outOfOffice, workingLocation) + - iCalUID: Specifies an event ID in the iCalendar format to retrieve (string) + - maxAttendees: Maximum number of attendees to include in response (integer) + - maxResults: Maximum number of events returned (1-2500, default 250) + - orderBy: Order of events returned (startTime or updated) + - pageToken: Token for pagination + - privateExtendedProperty: Extended properties constraint for private properties (string, format: propertyName=value) + - q: Text search terms to find events (mutually exclusive with iCalUID) + - sharedExtendedProperty: Extended properties constraint for shared properties (string, format: propertyName=value) + - showDeleted: Include deleted events (boolean) + - showHiddenInvitations: Whether to include hidden invitations (boolean) + - singleEvents: Expand recurring events into instances (boolean) + - syncToken: Token for incremental sync + - timeMax: Upper bound for event start time (RFC3339 timestamp) + - timeMin: Lower bound for event start time (RFC3339 timestamp) + - timeZone: Time zone used in the response (string, IANA timezone) + - updatedMin: Lower bound for last modification time (RFC3339 timestamp) + + Response Structure: + - Returns events collection with Google Calendar API v3 format: + * kind: "calendar#events" + * etag: ETag of the collection + * items: Array of Event objects + * nextPageToken: Token for next page (if applicable) + * nextSyncToken: Token for incremental sync (if applicable) + + Status Codes: + - 200: Success - Events retrieved successfully + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventTypes": { + "type": "string", + "description": "Event types to return. Possible values are: 'default' - Events that don't match any of the events below, 'outOfOffice' - Out of office events, 'focusTime' - Focus time events, 'workingLocation' - Working location events, 'fromGmail' - Events from Gmail (deprecated), 'birthday' - Birthday events. Optional. Multiple event types can be provided using repeated parameter instances" + }, + "iCalUID": { + "type": "string", + "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." + }, + "maxAttendees": { + "type": "integer", + "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." + }, + "maxResults": { + "type": "integer", + "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.", + "minimum": 1, + "maximum": 2500 + }, + "orderBy": { + "type": "string", + "description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order.", + "enum": ["startTime", "updated"] + }, + "pageToken": { + "type": "string", + "description": "Token specifying which result page to return. Optional." + }, + "privateExtendedProperty": { + "type": "string", + "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." + }, + "q": { + "type": "string", + "description": "Free text search terms to find events that match these terms in any field, except for extended properties. Optional." + }, + "sharedExtendedProperty": { + "type": "string", + "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." + }, + "showDeleted": { + "type": "boolean", + "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." + }, + "showHiddenInvitations": { + "type": "boolean", + "description": "Whether to include hidden invitations in the result. Optional. The default is False." + }, + "singleEvents": { + "type": "boolean", + "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." + }, + "syncToken": { + "type": "string", + "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." + }, + "timeMax": { + "type": "string", + "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." + }, + "timeMin": { + "type": "string", + "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." + }, + "timeZone": { + "type": "string", + "description": "Time zone used in the response. Optional. The default is the time zone of the calendar." + }, + "updatedMin": { + "type": "string", + "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." + } + }, + "required": ["calendarId"] + } + }, + { + "name": "create_event", + "description": """Create a new event in the specified calendar following Google Calendar API v3 specification. + + Creates a new event with full Google Calendar API v3 compatibility. Supports all standard event properties + including attendees, attachments, conference data, reminders, and event type-specific properties. + + Required Properties: + - calendarId: Calendar identifier where event will be created + - end: Event end time (dateTime/date object with optional timeZone) + - start: Event start time (dateTime/date object with optional timeZone) + + Optional Properties (Google Calendar API v3 compliant): + - attachments: File attachments for the event + - attendees: List of event attendees with email, displayName, responseStatus + - colorId: Color ID of the event (1-11 for event colors) + - conferenceData: Conference/meeting data for video calls + - description: Event description text + - eventType: Event type (default, outOfOffice, focusTime, workingLocation) + - extendedProperties: Private and shared extended properties + - focusTimeProperties: Focus time properties for focusTime events + - guestsCanInviteOthers: Whether guests can invite others + - guestsCanModify: Whether guests can modify the event + - guestsCanSeeOtherGuests: Whether guests can see other guests + - hangoutLink: Hangout video call link + - iCalUID: iCalendar UID for external integration + - location: Geographic location of the event + - locked: Whether the event is locked against changes + - originalStartTime: Original start time for recurring event instances (must match start values) + - outOfOfficeProperties: Out of office properties for outOfOffice events + - privateCopy: Whether this is a private copy of the event + - recurrence: List of RRULE, EXRULE, RDATE and EXDATE lines + - reminders: Reminder settings with useDefault and overrides + - sequence: iCalendar sequence number + - source: Source from which the event was created + - status: Event status (confirmed, tentative, cancelled) + - summary: Event title/summary + - transparency: Whether event blocks time (opaque, transparent) + - visibility: Event visibility (default, public, private, confidential) + - workingLocationProperties: Working location properties for workingLocation events + + Query Parameters: + - conferenceDataVersion: Conference data version supported (0-1) + - maxAttendees: Maximum number of attendees to include in response + - sendUpdates: Guests who should receive notifications (all, externalOnly, none) + - supportsAttachments: Whether client supports event attachments + + Response Structure: + - Returns created event with complete Google Calendar API v3 format + - Includes generated event ID, timestamps, and all provided properties + - Attendees list may be limited by maxAttendees parameter + + Status Codes: + - 201: Created - Event created successfully + - 400: Bad Request - Invalid event data or missing required fields + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier where event will be created" + }, + "end": { + "type": "object", + "description": "Event end time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "start": { + "type": "object", + "description": "Event start time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "attachments": { + "type": "array", + "description": "File attachments for the event", + "items": { + "type": "object", + "properties": { + "fileUrl": {"type": "string", "description": "URL of attached file"}, + }, + "required": ["fileUrl"] + } + }, + "attendees": { + "type": "array", + "description": "List of event attendees", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "Attendee email address"}, + "displayName": {"type": "string", "description": "Attendee display name"}, + "optional": {"type": "boolean", "default":False, "description": "Whether attendee is optional"}, + "resource": {"type": "boolean", "default":False, "description": "Whether attendee is a resource"}, + "responseStatus": {"type": "string", "description": "Response status: needsAction, declined, tentative, accepted"}, + "comment": {"type": "string", "description": "Attendee comment"}, + "additionalGuests": {"type": "integer", "description": "Number of additional guests"} + }, + "required": ["email"] + } + }, + "colorId": { + "type": "string", + "description": "Color ID of the event (1-11 for event colors)" + }, + "conferenceData": { + "type": "object", + "description": "Conference/meeting data for video calls", + "properties": { + "conferenceId": { + "type": "string", + "description": "Conference ID" + }, + "conferenceSolution": { + "type": "object", + "description": "Conference solution details", + "properties": { + "iconUri": { + "type": "string", + "description": "Icon URI for the conference solution" + }, + "key": { + "type": "object", + "description": "Conference solution key", + "properties": { + "type": { + "type": "string", + "description": "Conference solution type" + } + } + }, + "name": { + "type": "string", + "description": "Name of the conference solution" + } + } + }, + "createRequest": { + "type": "object", + "description": "Conference create request details", + "properties": { + "conferenceSolutionKey": { + "type": "object", + "description": "Conference solution for the create request", + "properties": { + "type": { + "type": "string", + "description": "Conference solution key type" + } + } + }, + "requestId": { + "type": "string", + "description": "Request ID for creating the conference" + } + } + }, + "entryPoints": { + "type": "array", + "description": "Conference entry points", + "items": { + "type": "object", + "properties": { + "accessCode": { + "type": "string", + "description": "Access code for the conference" + }, + "entryPointType": { + "type": "string", + "description": "Type of entry point" + }, + "label": { + "type": "string", + "description": "Label for the entry point" + }, + "meetingCode": { + "type": "string", + "description": "Meeting code for the conference" + }, + "passcode": { + "type": "string", + "description": "Passcode for the conference" + }, + "password": { + "type": "string", + "description": "Password for the conference" + }, + "pin": { + "type": "string", + "description": "PIN for the conference" + }, + "uri": { + "type": "string", + "description": "URI for the conference entry point" + } + } + } + }, + "notes": { + "type": "string", + "description": "Conference notes" + }, + "signature": { + "type": "string", + "description": "Conference signature" + } + } + }, + "description": { + "type": "string", + "description": "Event description text" + }, + "birthdayProperties": { + "type": "object", + "description": "Birthday properties for birthday events", + "properties": { + "type": { + "type": "string", + "enum": ["birthday"], + "description": "Type of birthday event, must be 'birthday'. Cannot be changed after event creation." + } + }, + "required": ["type"] + }, + "eventType": { + "type": "string", + "description": "Event type: default, birthday, outOfOffice, focusTime, workingLocation", + "enum": ["default", "birthday", "outOfOffice", "focusTime", "workingLocation"] + }, + "extendedProperties": { + "type": "object", + "description": "Extended properties", + "properties": { + "private": {"type": "object", "description": "Private extended properties"}, + "shared": {"type": "object", "description": "Shared extended properties"} + } + }, + "focusTimeProperties": { + "type": "object", + "description": "Focus time properties for focusTime events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "chatStatus": { + "type": "string", + "description": "The status to mark the user in Chat and related products", + "enum": ["available", "doNotDisturb"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "guestsCanInviteOthers": { + "type": "boolean", + "default":True, + "description": "Whether guests can invite others" + }, + "guestsCanModify": { + "type": "boolean", + "default":False, + "description": "Whether guests can modify the event" + }, + "guestsCanSeeOtherGuests": { + "type": "boolean", + "default":True, + "description": "Whether guests can see other guests" + }, + "iCalUID": { + "type": "string", + "description": "iCalendar UID for external integration" + }, + "location": { + "type": "string", + "description": "Geographic location of the event" + }, + "originalStartTime": { + "type": "object", + "description": "Original start time for recurring event instances (must match start field values)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "outOfOfficeProperties": { + "type": "object", + "description": "Out of office properties for outOfOffice events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "recurrence": { + "type": "array", + "description": """List of RRULE, EXRULE, RDATE and EXDATE lines for recurring events following RFC 5545 (iCalendar) standard. + + Supported Recurrence Types: + + RRULE (Recurrence Rule) - Defines the pattern for recurring events: + • FREQ: Frequency (DAILY, WEEKLY, MONTHLY, YEARLY) + • INTERVAL: Interval between occurrences (e.g., INTERVAL=2 for every 2 weeks) + • COUNT: Maximum number of occurrences + • UNTIL: End date (format: YYYYMMDDTHHMMSSZ) + • BYDAY: Days of week (MO, TU, WE, TH, FR, SA, SU) + • BYMONTHDAY: Days of month (1-31) + • BYMONTH: Months (1-12) + • BYSETPOS: Position in set (e.g., 1st, 2nd, -1 for last) + + EXDATE (Exception Dates) - Exclude specific occurrences: + • Format: EXDATE:YYYYMMDDTHHMMSSZ or EXDATE;VALUE=DATE:YYYYMMDD + • Use timezone format for timed events, date format for all-day events + + RDATE (Recurrence Dates) - Add specific occurrences: + • Format: RDATE:YYYYMMDDTHHMMSSZ or RDATE;VALUE=DATE:YYYYMMDD + + Common Examples: + • Daily: ["RRULE:FREQ=DAILY"] + • Every weekday: ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"] + • Weekly on specific days: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH"] + • Monthly on specific date: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=15"] + • First Monday of month: ["RRULE:FREQ=MONTHLY;BYDAY=1MO"] + • Last Friday of month: ["RRULE:FREQ=MONTHLY;BYDAY=-1FR"] + • Yearly: ["RRULE:FREQ=YEARLY"] + • Every 2 weeks: ["RRULE:FREQ=WEEKLY;INTERVAL=2"] + • 10 times only: ["RRULE:FREQ=WEEKLY;COUNT=10"] + • Until specific date: ["RRULE:FREQ=DAILY;UNTIL=20231231T235959Z"] + + Complex Examples: + • Weekly with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH", "EXDATE:20231024T100000Z"] + • Monthly with additional dates: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=1", "RDATE:20231215T100000Z"] + • Every other month on 2nd Tuesday: ["RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU"] + + All-Day Event Examples: + • Daily all-day: ["RRULE:FREQ=DAILY"] + • Weekly all-day with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE;VALUE=DATE:20231030"] + + Note: For all-day events, use VALUE=DATE format for EXDATE/RDATE. For timed events, use full timestamp format.""", + "items": {"type": "string"} + }, + "reminders": { + "type": "object", + "description": "Reminder settings", + "properties": { + "useDefault": {"type": "boolean", "description": "Whether to use default reminders"}, + "overrides": { + "type": "array", + "description": "Custom reminder overrides", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["email", "popup"], "description": "Reminder method"}, + "minutes": {"type": "integer", "description": "Minutes before event"} + }, + "required": ["method", "minutes"] + } + } + } + }, + "sequence": { + "type": "integer", + "description": "iCalendar sequence number" + }, + "source": { + "type": "object", + "description": "Source from which the event was created", + "properties": { + "url": {"type": "string", "description": "Source URL"}, + "title": {"type": "string", "description": "Source title"} + } + }, + "status": { + "type": "string", + "description": "Event status: confirmed, tentative, cancelled", + "enum": ["confirmed", "tentative", "cancelled"] + }, + "summary": { + "type": "string", + "description": "Event title/summary" + }, + "transparency": { + "type": "string", + "description": "Whether event blocks time: opaque, transparent", + "enum": ["opaque", "transparent"] + }, + "visibility": { + "type": "string", + "description": "Event visibility: default, public, private, confidential", + "enum": ["default", "public", "private", "confidential"] + }, + "workingLocationProperties": { + "type": "object", + "description": "Working location properties for workingLocation events", + "properties": { + "type": { + "type": "string", + "enum": ["homeOffice", "officeLocation", "customLocation"], + "description": "Type of the working location. Required when adding working location properties" + }, + "customLocation": { + "type": "object", + "description": "If present, specifies that the user is working from a custom location", + "properties": { + "label": { + "type": "string", + "description": "An optional extra label for additional information" + } + } + }, + "homeOffice": { + "type": "object", + "description": "If present, specifies that the user is working at home", + "properties": { + "address_1": { + "type": "string", + "description": "Home Office address 1" + }, + "address_2": { + "type": "string", + "description": "Home Office address 2" + }, + "city": { + "type": "string", + "description": "City located" + }, + "state": { + "type": "string", + "description": "State of the home office" + }, + "postal_code": { + "type": "string", + "description": "Postal code of the home office" + }, + }, + "additionalProperties": True + }, + "officeLocation": { + "type": "object", + "description": "If present, specifies that the user is working from an office", + "properties": { + "buildingId": { + "type": "string", + "description": "An optional building identifier. This should reference a building ID in the organization's Resources database" + }, + "deskId": { + "type": "string", + "description": "An optional desk identifier" + }, + "floorId": { + "type": "string", + "description": "An optional floor identifier" + }, + "floorSectionId": { + "type": "string", + "description": "An optional floor section identifier" + }, + "label": { + "type": "string", + "description": "The office name that's displayed in Calendar Web and Mobile clients. We recommend you reference a building name in the organization's Resources database" + } + } + } + }, + "required": ["type"] + }, + "conferenceDataVersion": { + "type": "integer", + "description": "Conference data version supported (0-1)", + "minimum": 0, + "maximum": 1 + }, + "maxAttendees": { + "type": "integer", + "description": "Maximum number of attendees to include in response" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications: all, externalOnly, none", + "enum": ["all", "externalOnly", "none"] + }, + "supportsAttachments": { + "type": "boolean", + "default":False, + "description": "Whether client supports event attachments" + } + }, + "required": ["calendarId", "end", "start"] + } + }, + { + "name": "get_event", + "description": """Retrieve a specific event by its ID from the specified calendar. + + Returns event details following Google Calendar API v3 structure. + Provides complete event information including attendees, reminders, and recurrence settings. + + Request Body Requirements: + - calendarId: Required. Calendar identifier + - eventId: Required. Event identifier + - timeZone: Optional. Time zone for returned times (IANA timezone) + + Response Structure: + - Returns event with Google Calendar API v3 format: + * kind: "calendar#event" + * etag: ETag of the resource + * id: Unique event identifier + * summary: Event title + * description: Event description (if present) + * location: Event location (if present) + * start: Event start time with timezone + * end: Event end time with timezone + * recurrence: Recurrence rules (if recurring) + * status: Event status + * visibility: Event visibility + * attendees: List of attendees (if present) + * reminders: Reminder settings + + Status Codes: + - 200: Success - Event retrieved successfully + - 404: Not Found - Event or calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Event identifier" + }, + "timeZone": { + "type": "string", + "description": "Time zone for returned times (IANA timezone)" + }, + "maxAttendees": { + "type": "integer", + "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." + } + }, + "required": ["calendarId", "eventId"] + } + }, + { + "name": "patch_event", + "description": """Partially update an event (only specified fields are modified) following Google Calendar API v3. + + Updates an event with partial data using Google Calendar API v3 structure. + Only provided fields will be updated, others remain unchanged. + Use for incremental updates without affecting other event properties. + + Request Body Requirements: + - calendarId: Required. Calendar identifier + - eventId: Required. Event identifier + + Optional Update Fields (Google Calendar API v3 compliant): + - attachments: File attachments for the event + - attendees: List of event attendees with email, displayName, responseStatus + - colorId: Color ID of the event (1-11 for event colors) + - conferenceData: Conference/meeting data for video calls + - description: Event description text + - end: Event end time (dateTime/date object with optional timeZone) + - eventType: Event type (default, outOfOffice, focusTime, workingLocation) + - extendedProperties: Private and shared extended properties + - focusTimeProperties: Focus time properties for focusTime events + - guestsCanInviteOthers: Whether guests can invite others + - guestsCanModify: Whether guests can modify the event + - guestsCanSeeOtherGuests: Whether guests can see other guests + - hangoutLink: Hangout video call link + - iCalUID: iCalendar UID for external integration + - location: Geographic location of the event + - locked: Whether the event is locked against changes + - outOfOfficeProperties: Out of office properties for outOfOffice events + - privateCopy: Whether this is a private copy of the event + - recurrence: List of RRULE, EXRULE, RDATE and EXDATE lines + - reminders: Reminder settings with useDefault and overrides + - sequence: iCalendar sequence number + - source: Source from which the event was created + - start: Event start time (dateTime/date object with optional timeZone) + - status: Event status (confirmed, tentative, cancelled) + - summary: Event title/summary + - transparency: Whether event blocks time (opaque, transparent) + - visibility: Event visibility (default, public, private, confidential) + - workingLocationProperties: Working location properties for workingLocation events + + Query Parameters: + - sendUpdates: Guests who should receive notifications (all, externalOnly, none) + + Partial Update Behavior: + - Only fields provided in request body are updated + - Missing fields are left unchanged + - At least one field should be provided for update + + Response Structure: + - Returns updated event with Google Calendar API v3 format + + Status Codes: + - 200: Success - Event updated successfully + - 400: Bad Request - Invalid update data + - 404: Not Found - Event or calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Event identifier" + }, + "summary": { + "type": "string", + "description": "Event title" + }, + "description": { + "type": "string", + "description": "Event description" + }, + "location": { + "type": "string", + "description": "Event location" + }, + "attachments": { + "type": "array", + "description": "File attachments for the event", + "items": { + "type": "object", + "properties": { + "fileUrl": {"type": "string", "description": "URL of attached file"}, + } + } + }, + "attendees": { + "type": "array", + "description": "List of event attendees", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "Attendee email address"}, + "displayName": {"type": "string", "description": "Attendee display name"}, + "optional": {"type": "boolean", "description": "Whether attendee is optional"}, + "resource": {"type": "boolean", "default":False, "description": "Whether attendee is a resource"}, + "responseStatus": {"type": "string", "description": "Response status"}, + "comment": {"type": "string", "description": "Attendee comment"}, + "additionalGuests": {"type": "integer", "description": "Number of additional guests"} + }, + "required": ["email"] + } + }, + "colorId": { + "type": "string", + "description": "Color ID of the event(1-11 for event colors)" + }, + "conferenceData": { + "type": "object", + "description": "Conference/meeting data for video calls", + "properties": { + "conferenceId": { + "type": "string", + "description": "Conference ID" + }, + "conferenceSolution": { + "type": "object", + "description": "Conference solution details", + "properties": { + "iconUri": { + "type": "string", + "description": "Icon URI for the conference solution" + }, + "key": { + "type": "object", + "description": "Conference solution key", + "properties": { + "type": { + "type": "string", + "description": "Conference solution type" + } + } + }, + "name": { + "type": "string", + "description": "Name of the conference solution" + } + } + }, + "createRequest": { + "type": "object", + "description": "Conference create request details", + "properties": { + "conferenceSolutionKey": { + "type": "object", + "description": "Conference solution for the create request", + "properties": { + "type": { + "type": "string", + "description": "Conference solution key type" + } + } + }, + "requestId": { + "type": "string", + "description": "Request ID for creating the conference" + } + } + }, + "entryPoints": { + "type": "array", + "description": "Conference entry points", + "items": { + "type": "object", + "properties": { + "accessCode": { + "type": "string", + "description": "Access code for the conference" + }, + "entryPointType": { + "type": "string", + "description": "Type of entry point" + }, + "label": { + "type": "string", + "description": "Label for the entry point" + }, + "meetingCode": { + "type": "string", + "description": "Meeting code for the conference" + }, + "passcode": { + "type": "string", + "description": "Passcode for the conference" + }, + "password": { + "type": "string", + "description": "Password for the conference" + }, + "pin": { + "type": "string", + "description": "PIN for the conference" + }, + "uri": { + "type": "string", + "description": "URI for the conference entry point" + } + } + } + }, + "notes": { + "type": "string", + "description": "Conference notes" + }, + "signature": { + "type": "string", + "description": "Conference signature" + } + } + }, + "birthdayProperties": { + "type": "object", + "description": "Birthday properties for birthday events", + "properties": { + "type": { + "type": "string", + "enum": ["birthday"], + "description": "Type of birthday event, must be 'birthday'. Cannot be changed after event creation." + } + }, + "required": ["type"] + }, + "description": { + "type": "string", + "description": "Event description" + }, + "end": { + "type": "object", + "description": "Event end time", + "properties": { + "dateTime": {"type": "string", "description": "RFC3339 timestamp"}, + "date": {"type": "string", "description": "Date in YYYY-MM-DD format"}, + "timeZone": {"type": "string", "description": "IANA timezone"} + } + }, + "eventType": { + "type": "string", + "description": "Event type", + "enum": ["default", "birthday", "outOfOffice", "focusTime", "workingLocation"] + }, + "extendedProperties": { + "type": "object", + "description": "Extended properties", + "properties": { + "private": {"type": "object", "description": "Private extended properties"}, + "shared": {"type": "object", "description": "Shared extended properties"} + } + }, + "focusTimeProperties": { + "type": "object", + "description": "Focus time properties for focusTime events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "chatStatus": { + "type": "string", + "description": "The status to mark the user in Chat and related products", + "enum": ["available", "doNotDisturb"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "guestsCanInviteOthers": { + "type": "boolean", + "default":True, + "description": "Whether guests can invite others" + }, + "guestsCanModify": { + "type": "boolean", + "default":False, + "description": "Whether guests can modify the event" + }, + "guestsCanSeeOtherGuests": { + "type": "boolean", + "default":True, + "description": "Whether guests can see other guests" + }, + "hangoutLink": { + "type": "string", + "description": "Hangout link" + }, + "iCalUID": { + "type": "string", + "description": "iCalendar UID" + }, + "location": { + "type": "string", + "description": "Event location" + }, + "outOfOfficeProperties": { + "type": "object", + "description": "Out of office properties for outOfOffice events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "locked": { + "type": "boolean", + "description": "Whether event is locked" + }, + "privateCopy": { + "type": "boolean", + "description": "Whether this is a private copy" + }, + "recurrence": { + "type": "array", + "description": """List of RRULE, EXRULE, RDATE and EXDATE lines for recurring events following RFC 5545 (iCalendar) standard. + + Supported Recurrence Types: + + RRULE (Recurrence Rule) - Defines the pattern for recurring events: + • FREQ: Frequency (DAILY, WEEKLY, MONTHLY, YEARLY) + • INTERVAL: Interval between occurrences (e.g., INTERVAL=2 for every 2 weeks) + • COUNT: Maximum number of occurrences + • UNTIL: End date (format: YYYYMMDDTHHMMSSZ) + • BYDAY: Days of week (MO, TU, WE, TH, FR, SA, SU) + • BYMONTHDAY: Days of month (1-31) + • BYMONTH: Months (1-12) + • BYSETPOS: Position in set (e.g., 1st, 2nd, -1 for last) + + EXDATE (Exception Dates) - Exclude specific occurrences: + • Format: EXDATE:YYYYMMDDTHHMMSSZ or EXDATE;VALUE=DATE:YYYYMMDD + • Use timezone format for timed events, date format for all-day events + + RDATE (Recurrence Dates) - Add specific occurrences: + • Format: RDATE:YYYYMMDDTHHMMSSZ or RDATE;VALUE=DATE:YYYYMMDD + + Common Examples: + • Daily: ["RRULE:FREQ=DAILY"] + • Every weekday: ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"] + • Weekly on specific days: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH"] + • Monthly on specific date: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=15"] + • First Monday of month: ["RRULE:FREQ=MONTHLY;BYDAY=1MO"] + • Last Friday of month: ["RRULE:FREQ=MONTHLY;BYDAY=-1FR"] + • Yearly: ["RRULE:FREQ=YEARLY"] + • Every 2 weeks: ["RRULE:FREQ=WEEKLY;INTERVAL=2"] + • 10 times only: ["RRULE:FREQ=WEEKLY;COUNT=10"] + • Until specific date: ["RRULE:FREQ=DAILY;UNTIL=20231231T235959Z"] + + Complex Examples: + • Weekly with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH", "EXDATE:20231024T100000Z"] + • Monthly with additional dates: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=1", "RDATE:20231215T100000Z"] + • Every other month on 2nd Tuesday: ["RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU"] + + All-Day Event Examples: + • Daily all-day: ["RRULE:FREQ=DAILY"] + • Weekly all-day with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE;VALUE=DATE:20231030"] + + Note: For all-day events, use VALUE=DATE format for EXDATE/RDATE. For timed events, use full timestamp format.""", + "items": {"type": "string"} + }, + "reminders": { + "type": "object", + "description": "Reminder settings", + "properties": { + "useDefault": {"type": "boolean", "description": "Whether to use default reminders"}, + "overrides": { + "type": "array", + "description": "Custom reminder overrides", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["email", "popup"], "description": "Reminder method"}, + "minutes": {"type": "integer", "description": "Minutes before event"} + }, + "required": ["method", "minutes"] + } + } + } + }, + "sequence": { + "type": "integer", + "description": "Sequence number" + }, + "source": { + "type": "object", + "description": "Source from which the event was created", + "properties": { + "url": {"type": "string", "description": "Source URL"}, + "title": {"type": "string", "description": "Source title"} + } + }, + "start": { + "type": "object", + "description": "Event start time", + "properties": { + "dateTime": {"type": "string", "description": "RFC3339 timestamp"}, + "date": {"type": "string", "description": "Date in YYYY-MM-DD format"}, + "timeZone": {"type": "string", "description": "IANA timezone"} + } + }, + "status": { + "type": "string", + "description": "Event status: confirmed, tentative, cancelled", + "enum": ["confirmed", "tentative", "cancelled"] + }, + "summary": { + "type": "string", + "description": "Event title" + }, + "transparency": { + "type": "string", + "description": "Event transparency", + "enum": ["opaque", "transparent"] + }, + "visibility": { + "type": "string", + "description": "Event visibility", + "enum": ["default", "public", "private", "confidential"] + }, + "workingLocationProperties": { + "type": "object", + "description": "Working location properties for workingLocation events", + "properties": { + "type": { + "type": "string", + "enum": ["homeOffice", "officeLocation", "customLocation"], + "description": "Type of the working location. Required when adding working location properties" + }, + "customLocation": { + "type": "object", + "description": "If present, specifies that the user is working from a custom location", + "properties": { + "label": { + "type": "string", + "description": "An optional extra label for additional information" + } + } + }, + "homeOffice": { + "type": "object", + "description": "If present, specifies that the user is working at home", + "properties": { + "address_1": { + "type": "string", + "description": "Home Office address 1" + }, + "address_2": { + "type": "string", + "description": "Home Office address 2" + }, + "city": { + "type": "string", + "description": "City located" + }, + "state": { + "type": "string", + "description": "State of the home office" + }, + "postal_code": { + "type": "string", + "description": "Postal code of the home office" + }, + }, + "additionalProperties": True + }, + "officeLocation": { + "type": "object", + "description": "If present, specifies that the user is working from an office", + "properties": { + "buildingId": { + "type": "string", + "description": "An optional building identifier. This should reference a building ID in the organization's Resources database" + }, + "deskId": { + "type": "string", + "description": "An optional desk identifier" + }, + "floorId": { + "type": "string", + "description": "An optional floor identifier" + }, + "floorSectionId": { + "type": "string", + "description": "An optional floor section identifier" + }, + "label": { + "type": "string", + "description": "The office name that's displayed in Calendar Web and Mobile clients. We recommend you reference a building name in the organization's Resources database" + } + } + } + }, + "required": ["type"] + }, + "conferenceDataVersion": { + "type": "integer", + "description": "Conference data version supported (0-1)", + "minimum": 0, + "maximum": 1 + }, + "maxAttendees": { + "type": "integer", + "description": "Maximum number of attendees to include in response" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications", + "enum": ["all", "externalOnly", "none"] + }, + "supportsAttachments": { + "type": "boolean", + "default":False, + "description": "Whether client supports event attachments" + } + }, + "required": ["calendarId", "eventId"] + } + }, + { + "name": "update_event", + "description": """Fully update an event (complete replacement) following Google Calendar API v3 specification. + + Updates an event following Google Calendar API v3 structure. This method does not support patch + semantics and always updates the entire event resource. To do a partial update, perform a get + followed by an update using etags to ensure atomicity. + + Request Body Requirements: + - calendarId: Required. Calendar identifier + - eventId: Required. Event identifier + - start: Required. Event start time (dateTime/date object with optional timeZone) + - end: Required. Event end time (dateTime/date object with optional timeZone) + + Optional Properties (Google Calendar API v3 compliant): + - attachments: File attachments for the event + - attendees: List of event attendees with email, displayName, responseStatus + - birthdayProperties: Birthday properties for birthday events + - colorId: Color ID of the event (1-11 for event colors) + - conferenceData: Conference/meeting data for video calls + - description: Event description text + - extendedProperties: Private and shared extended properties + - focusTimeProperties: Focus time properties for focusTime events + - guestsCanInviteOthers: Whether guests can invite others + - guestsCanModify: Whether guests can modify the event + - guestsCanSeeOtherGuests: Whether guests can see other guests + - hangoutLink: Hangout video call link + - iCalUID: iCalendar UID for external integration + - location: Geographic location of the event + - locked: Whether the event is locked against changes + - outOfOfficeProperties: Out of office properties for outOfOffice events + - privateCopy: Whether this is a private copy of the event + - recurrence: List of RRULE, EXRULE, RDATE and EXDATE lines + - reminders: Reminder settings with useDefault and overrides + - sequence: iCalendar sequence number + - source: Source from which the event was created + - status: Event status (confirmed, tentative, cancelled) + - summary: Event title/summary + - transparency: Whether event blocks time (opaque, transparent) + - visibility: Event visibility (default, public, private, confidential) + - workingLocationProperties: Working location properties for workingLocation events + + Query Parameters: + - conferenceDataVersion: Conference data version supported (0-1) + - maxAttendees: Maximum number of attendees to include in response + - sendUpdates: Guests who should receive notifications (all, externalOnly, none) + - supportsAttachments: Whether client supports event attachments + + Full Update Behavior: + - All fields are replaced (full replacement operation) + - Missing optional fields are set to null/defaults + - start and end are required for PUT operations + - This is different from PATCH which only updates provided fields + + Response Structure: + - Returns updated event with complete Google Calendar API v3 format + + Status Codes: + - 200: Success - Event updated successfully + - 400: Bad Request - Invalid update data or missing required fields + - 404: Not Found - Event or calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Event identifier" + }, + "start": { + "type": "object", + "description": "Event start time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "end": { + "type": "object", + "description": "Event end time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "summary": { + "type": "string", + "description": "Event title/summary" + }, + "description": { + "type": "string", + "description": "Event description" + }, + "location": { + "type": "string", + "description": "Event location" + }, + "attachments": { + "type": "array", + "description": "File attachments for the event", + "items": { + "type": "object", + "properties": { + "fileUrl": {"type": "string", "description": "URL of attached file"}, + } + } + }, + "attendees": { + "type": "array", + "description": "List of event attendees", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "Attendee email address"}, + "displayName": {"type": "string", "description": "Attendee display name"}, + "optional": {"type": "boolean", "description": "Whether attendee is optional"}, + "resource": {"type": "boolean", "description": "Whether attendee is a resource"}, + "responseStatus": {"type": "string", "description": "Response status"}, + "comment": {"type": "string", "description": "Attendee comment"}, + "additionalGuests": {"type": "integer", "description": "Number of additional guests"} + }, + "required": ["email"] + } + }, + "eventType": { + "type": "string", + "description": "Event type", + "enum": ["default", "birthday", "outOfOffice", "focusTime", "workingLocation"] + }, + "birthdayProperties": { + "type": "object", + "description": "Birthday properties for birthday events", + "properties": { + "type": { + "type": "string", + "enum": ["birthday"], + "description": "Type of birthday event, must be 'birthday'. Cannot be changed after event creation." + } + }, + "required": ["type"] + }, + "colorId": { + "type": "string", + "description": "Color ID of the event" + }, + "conferenceData": { + "type": "object", + "description": "Conference/meeting data for video calls", + "properties": { + "conferenceId": { + "type": "string", + "description": "Conference ID" + }, + "conferenceSolution": { + "type": "object", + "description": "Conference solution details", + "properties": { + "iconUri": { + "type": "string", + "description": "Icon URI for the conference solution" + }, + "key": { + "type": "object", + "description": "Conference solution key", + "properties": { + "type": { + "type": "string", + "description": "Conference solution type" + } + } + }, + "name": { + "type": "string", + "description": "Name of the conference solution" + } + } + }, + "createRequest": { + "type": "object", + "description": "Conference create request details", + "properties": { + "conferenceSolutionKey": { + "type": "object", + "description": "Conference solution for the create request", + "properties": { + "type": { + "type": "string", + "description": "Conference solution key type" + } + } + }, + "requestId": { + "type": "string", + "description": "Request ID for creating the conference" + } + } + }, + "entryPoints": { + "type": "array", + "description": "Conference entry points", + "items": { + "type": "object", + "properties": { + "accessCode": { + "type": "string", + "description": "Access code for the conference" + }, + "entryPointType": { + "type": "string", + "description": "Type of entry point" + }, + "label": { + "type": "string", + "description": "Label for the entry point" + }, + "meetingCode": { + "type": "string", + "description": "Meeting code for the conference" + }, + "passcode": { + "type": "string", + "description": "Passcode for the conference" + }, + "password": { + "type": "string", + "description": "Password for the conference" + }, + "pin": { + "type": "string", + "description": "PIN for the conference" + }, + "uri": { + "type": "string", + "description": "URI for the conference entry point" + } + } + } + }, + "notes": { + "type": "string", + "description": "Conference notes" + }, + "signature": { + "type": "string", + "description": "Conference signature" + } + } + }, + "extendedProperties": { + "type": "object", + "description": "Extended properties", + "properties": { + "private": {"type": "object", "description": "Private extended properties"}, + "shared": {"type": "object", "description": "Shared extended properties"} + } + }, + "focusTimeProperties": { + "type": "object", + "description": "Focus time properties for focusTime events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "chatStatus": { + "type": "string", + "description": "The status to mark the user in Chat and related products", + "enum": ["available", "doNotDisturb"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "guestsCanInviteOthers": { + "type": "boolean", + "default":True, + "description": "Whether guests can invite others" + }, + "guestsCanModify": { + "type": "boolean", + "default":False, + "description": "Whether guests can modify the event" + }, + "guestsCanSeeOtherGuests": { + "type": "boolean", + "default":True, + "description": "Whether guests can see other guests" + }, + "hangoutLink": { + "type": "string", + "description": "Hangout link" + }, + "iCalUID": { + "type": "string", + "description": "iCalendar UID" + }, + "locked": { + "type": "boolean", + "description": "Whether event is locked" + }, + "outOfOfficeProperties": { + "type": "object", + "description": "Out of office properties for outOfOffice events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "privateCopy": { + "type": "boolean", + "description": "Whether this is a private copy" + }, + "recurrence": { + "type": "array", + "description": """List of RRULE, EXRULE, RDATE and EXDATE lines for recurring events following RFC 5545 (iCalendar) standard. + + Supported Recurrence Types: + + RRULE (Recurrence Rule) - Defines the pattern for recurring events: + • FREQ: Frequency (DAILY, WEEKLY, MONTHLY, YEARLY) + • INTERVAL: Interval between occurrences (e.g., INTERVAL=2 for every 2 weeks) + • COUNT: Maximum number of occurrences + • UNTIL: End date (format: YYYYMMDDTHHMMSSZ) + • BYDAY: Days of week (MO, TU, WE, TH, FR, SA, SU) + • BYMONTHDAY: Days of month (1-31) + • BYMONTH: Months (1-12) + • BYSETPOS: Position in set (e.g., 1st, 2nd, -1 for last) + + EXDATE (Exception Dates) - Exclude specific occurrences: + • Format: EXDATE:YYYYMMDDTHHMMSSZ or EXDATE;VALUE=DATE:YYYYMMDD + • Use timezone format for timed events, date format for all-day events + + RDATE (Recurrence Dates) - Add specific occurrences: + • Format: RDATE:YYYYMMDDTHHMMSSZ or RDATE;VALUE=DATE:YYYYMMDD + + Common Examples: + • Daily: ["RRULE:FREQ=DAILY"] + • Every weekday: ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"] + • Weekly on specific days: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH"] + • Monthly on specific date: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=15"] + • First Monday of month: ["RRULE:FREQ=MONTHLY;BYDAY=1MO"] + • Last Friday of month: ["RRULE:FREQ=MONTHLY;BYDAY=-1FR"] + • Yearly: ["RRULE:FREQ=YEARLY"] + • Every 2 weeks: ["RRULE:FREQ=WEEKLY;INTERVAL=2"] + • 10 times only: ["RRULE:FREQ=WEEKLY;COUNT=10"] + • Until specific date: ["RRULE:FREQ=DAILY;UNTIL=20231231T235959Z"] + + Complex Examples: + • Weekly with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH", "EXDATE:20231024T100000Z"] + • Monthly with additional dates: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=1", "RDATE:20231215T100000Z"] + • Every other month on 2nd Tuesday: ["RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU"] + + All-Day Event Examples: + • Daily all-day: ["RRULE:FREQ=DAILY"] + • Weekly all-day with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE;VALUE=DATE:20231030"] + + Note: For all-day events, use VALUE=DATE format for EXDATE/RDATE. For timed events, use full timestamp format.""", + "items": {"type": "string"} + }, + "reminders": { + "type": "object", + "description": "Reminder settings", + "properties": { + "useDefault": {"type": "boolean", "description": "Whether to use default reminders"}, + "overrides": { + "type": "array", + "description": "Custom reminder overrides", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["email", "popup"], "description": "Reminder method"}, + "minutes": {"type": "integer", "description": "Minutes before event"} + }, + "required": ["method", "minutes"] + } + } + } + }, + "sequence": { + "type": "integer", + "description": "Sequence number" + }, + "source": { + "type": "object", + "description": "Source from which the event was created", + "properties": { + "url": {"type": "string", "description": "Source URL"}, + "title": {"type": "string", "description": "Source title"} + } + }, + "status": { + "type": "string", + "description": "Event status", + "enum": ["confirmed", "tentative", "cancelled"] + }, + "transparency": { + "type": "string", + "description": "Event transparency", + "enum": ["opaque", "transparent"] + }, + "visibility": { + "type": "string", + "description": "Event visibility", + "enum": ["default", "public", "private", "confidential"] + }, + "workingLocationProperties": { + "type": "object", + "description": "Working location properties for workingLocation events", + "properties": { + "type": { + "type": "string", + "enum": ["homeOffice", "officeLocation", "customLocation"], + "description": "Type of the working location. Required when adding working location properties" + }, + "customLocation": { + "type": "object", + "description": "If present, specifies that the user is working from a custom location", + "properties": { + "label": { + "type": "string", + "description": "An optional extra label for additional information" + } + } + }, + "homeOffice": { + "type": "object", + "description": "If present, specifies that the user is working at home", + "properties": { + "address_1": { + "type": "string", + "description": "Home Office address 1" + }, + "address_2": { + "type": "string", + "description": "Home Office address 2" + }, + "city": { + "type": "string", + "description": "City located" + }, + "state": { + "type": "string", + "description": "State of the home office" + }, + "postal_code": { + "type": "string", + "description": "Postal code of the home office" + }, + }, + "additionalProperties": True + }, + "officeLocation": { + "type": "object", + "description": "If present, specifies that the user is working from an office", + "properties": { + "buildingId": { + "type": "string", + "description": "An optional building identifier. This should reference a building ID in the organization's Resources database" + }, + "deskId": { + "type": "string", + "description": "An optional desk identifier" + }, + "floorId": { + "type": "string", + "description": "An optional floor identifier" + }, + "floorSectionId": { + "type": "string", + "description": "An optional floor section identifier" + }, + "label": { + "type": "string", + "description": "The office name that's displayed in Calendar Web and Mobile clients. We recommend you reference a building name in the organization's Resources database" + } + } + } + }, + "required": ["type"] + }, + "conferenceDataVersion": { + "type": "integer", + "description": "Conference data version supported (0-1)", + "minimum": 0, + "maximum": 1 + }, + "maxAttendees": { + "type": "integer", + "description": "Maximum number of attendees to include in response" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications: all, externalOnly, none", + "enum": ["all", "externalOnly", "none"] + }, + "supportsAttachments": { + "type": "boolean", + "description": "Whether client supports event attachments" + } + }, + "required": ["calendarId", "eventId", "start", "end"] + } + }, + { + "name": "delete_event", + "description": """Delete an event from the specified calendar. + + Permanently deletes an event following Google Calendar API v3 behavior. + This action cannot be undone. Use with caution. + + Request Body Requirements: + - calendarId: Required. Calendar identifier + - eventId: Required. Event identifier to delete + - sendUpdates: Optional. Guests who should receive notifications (all, externalOnly, none) + + Deletion Behavior: + - Event is permanently removed from the calendar + - This operation is irreversible + - Notifications can be sent to attendees if specified + + Response Structure: + - Returns 204 No Content on successful deletion + - No response body as per Google Calendar API v3 + + Status Codes: + - 204: No Content - Event deleted successfully + - 404: Not Found - Event or calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Event identifier to delete" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications: all, externalOnly, none" + } + }, + "required": ["calendarId", "eventId"] + } + }, + { + "name": "move_event", + "description": """Move an event from one calendar to another. + + Moves an event between calendars following Google Calendar API v3 behavior. + The event retains its properties but changes calendar ownership. + + Request Body Requirements: + - calendarId: Required. Source calendar identifier + - eventId: Required. Event identifier to move + - destination: Required. Target calendar identifier where event will be moved + - sendUpdates: Optional. Guests who should receive notifications (all, externalOnly, none) + + Move Operation: + - Event is transferred from source to destination calendar + - Event properties (title, time, attendees, etc.) remain unchanged + - Event ID may change during the move operation + - Original event is removed from source calendar + + Response Structure: + - Returns moved event with Google Calendar API v3 format + - Event will have the same content but potentially new ID + + Status Codes: + - 200: Success - Event moved successfully + - 404: Not Found - Event, source calendar, or destination calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Source calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Event identifier to move" + }, + "destination": { + "type": "string", + "description": "Target calendar identifier where event will be moved" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications: all, externalOnly, none" + } + }, + "required": ["calendarId", "eventId", "destination"] + } + }, + { + "name": "quick_add_event", + "description": """Create an event using natural language text parsing. + + Creates an event from natural language text following Google Calendar API v3 structure. + Automatically extracts date, time, and other details from the provided text. + Similar to Quick Add feature in Google Calendar. + + Request Body Requirements: + - calendarId: Required. Calendar identifier where event will be created + + Required Query Parameters: + - text: Required. Natural language text describing the event (e.g., 'Meeting tomorrow at 2pm') + + Request Body Requirements: + - sendUpdates: Optional. Guests who should receive notifications (all, externalOnly, none) + + Text Parsing: + - Automatically extracts event title from text + - Identifies date and time information + - Recognizes common patterns like 'tomorrow', 'next week', 'at 3pm' + - May identify location if mentioned in text + - Creates a properly formatted event from parsed information + + Response Structure: + - Returns created event with Google Calendar API v3 format + - Event will contain parsed information from the text + + Status Codes: + - 201: Created - Event created successfully from text + - 400: Bad Request - Unable to parse text or invalid calendar + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier where event will be created" + }, + "text": { + "type": "string", + "description": "Natural language text describing the event (e.g., 'Meeting tomorrow at 2pm')" + }, + "sendUpdates": { + "type": "string", + "description": "Guests who should receive notifications: all, externalOnly, none" + } + }, + "required": ["calendarId", "text"] + } + }, + { + "name": "import_event", + "description": """Import an event as a private copy to the specified calendar. + + This operation is used to add a private copy of an existing event to a calendar, following + Google Calendar API v3 import specification. Supports comprehensive event data including + type conversion, iCalUID handling, and attachment processing. + + Google Calendar API Import Features: + - Creates a private copy of the event in the target calendar + - Handles event type conversion (non-default types may be converted to default) + - Manages iCalUID uniqueness and conflict resolution + - Processes attendees, attachments, and conference data based on client capabilities + - Supports all Google Calendar event types: default, birthday, focusTime, fromGmail, outOfOffice, workingLocation + + Request Body Requirements: + - calendarId: Required. Calendar identifier where event will be imported + - iCalUID: iCalendar UID for external calendar integration + - start: Required. Event start time (dateTime or date with optional timeZone) + - end: Required. Event end time (dateTime or date with optional timeZone) + + Advanced Import Properties: + - attendees: Array of attendee objects with email, displayName, optional, responseStatus + - conferenceData: Conference/meeting data (processed based on conferenceDataVersion) + - attachments: File attachments (processed if supportsAttachments=true) + - extendedProperties: Private and shared extended properties + - focusTimeProperties: Focus time settings (for focusTime events) + - outOfOfficeProperties: Out of office details (for outOfOffice events) + - recurrence: Array of RRULE strings for recurring events + - reminders: Reminder configuration with useDefault and overrides + - source: Source information with title and URL + - transparency: Event transparency (opaque/transparent) + - visibility: Event visibility (default, public, private, confidential) + - sequence: iCalendar sequence number + - sequence: iCalendar sequence number + - colorId: Color ID of the event (1-11 for event colors) + - originalStartTime: Original start time for recurring event instances (must match start values) + + Query Parameters: + - conferenceDataVersion: Conference data version support (0 or 1) + - supportsAttachments: Whether client supports event attachments + + Import Processing: + - Validates event data and user permissions + - Converts unsupported event types to 'default' with warnings + - Generates new event ID while preserving iCalUID if provided + - Creates comprehensive event with all related objects (attendees, attachments, etc.) + - Handles all-day events vs timed events automatically + + Response Structure: + - Returns EventImportResponse with Google Calendar API v3 format: + * Complete event data with generated IDs + * Creator and organizer information + * Conversion warnings if event type was changed + * All imported properties and relationships + + Status Codes: + - 201: Created - Event imported successfully as private copy + - 400: Bad Request - Invalid import data or missing required fields + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier where event will be imported" + }, + "summary": { + "type": "string", + "description": "Event title/summary (required)" + }, + "description": { + "type": "string", + "description": "Event description" + }, + "location": { + "type": "string", + "description": "Event location" + }, + "start": { + "type": "object", + "description": "Event start time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "end": { + "type": "object", + "description": "Event end time (required)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "organizer": { + "type": "object", + "description": "The organizer of the event. If the organizer is also an attendee, this is indicated with a separate entry in attendees with the organizer field set to True. To change the organizer, use the move operation. Read-only, except when importing an event.", + "properties": { + "email": { + "type": "string", + "description": "The organizer's email address. Must be a valid email address as per RFC5322." + }, + "displayName": { + "type": "string", + "description": "The organizer's name, if available." + } + }, + "required": ["email"] + }, + "status": { + "type": "string", + "description": "Event status: confirmed, tentative, cancelled", + "enum": ["confirmed", "tentative", "cancelled"] + }, + "transparency": { + "type": "string", + "description": "Event transparency: opaque, transparent", + "enum": ["opaque", "transparent"] + }, + "visibility": { + "type": "string", + "description": "Event visibility: default, public, private, confidential", + "enum": ["default", "public", "private", "confidential"] + }, + "attendees": { + "type": "array", + "description": "Event attendees", + "items": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "Attendee email address"}, + "displayName": {"type": "string", "description": "Attendee display name"}, + "optional": {"type": "boolean", "default": False, "description": "Whether attendee is optional"}, + "resource": {"type": "boolean", "default": False, "description": "Whether attendee is a resource"}, + "responseStatus": {"type": "string", "default": "needsAction", "description": "Response status"}, + "comment": {"type": "string", "description": "Attendee comment"}, + "additionalGuests": {"type": "integer", "default": 0, "description": "Number of additional guests"} + }, + "required": ["email", "responseStatus"] + } + }, + "attendeesOmitted": { + "type": "boolean", + "default": False, + "description": "Whether attendees may have been omitted from the event's representation" + }, + "colorId": { + "type": "string", + "description": "Color ID of the event (1-11 for event colors)" + }, + "recurrence": { + "type": "array", + "description": """List of RRULE, EXRULE, RDATE and EXDATE lines for recurring events following RFC 5545 (iCalendar) standard. + + Supported Recurrence Types: + + RRULE (Recurrence Rule) - Defines the pattern for recurring events: + • FREQ: Frequency (DAILY, WEEKLY, MONTHLY, YEARLY) + • INTERVAL: Interval between occurrences (e.g., INTERVAL=2 for every 2 weeks) + • COUNT: Maximum number of occurrences + • UNTIL: End date (format: YYYYMMDDTHHMMSSZ) + • BYDAY: Days of week (MO, TU, WE, TH, FR, SA, SU) + • BYMONTHDAY: Days of month (1-31) + • BYMONTH: Months (1-12) + • BYSETPOS: Position in set (e.g., 1st, 2nd, -1 for last) + + EXDATE (Exception Dates) - Exclude specific occurrences: + • Format: EXDATE:YYYYMMDDTHHMMSSZ or EXDATE;VALUE=DATE:YYYYMMDD + • Use timezone format for timed events, date format for all-day events + + RDATE (Recurrence Dates) - Add specific occurrences: + • Format: RDATE:YYYYMMDDTHHMMSSZ or RDATE;VALUE=DATE:YYYYMMDD + + Common Examples: + • Daily: ["RRULE:FREQ=DAILY"] + • Every weekday: ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"] + • Weekly on specific days: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH"] + • Monthly on specific date: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=15"] + • First Monday of month: ["RRULE:FREQ=MONTHLY;BYDAY=1MO"] + • Last Friday of month: ["RRULE:FREQ=MONTHLY;BYDAY=-1FR"] + • Yearly: ["RRULE:FREQ=YEARLY"] + • Every 2 weeks: ["RRULE:FREQ=WEEKLY;INTERVAL=2"] + • 10 times only: ["RRULE:FREQ=WEEKLY;COUNT=10"] + • Until specific date: ["RRULE:FREQ=DAILY;UNTIL=20231231T235959Z"] + + Complex Examples: + • Weekly with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=TU,TH", "EXDATE:20231024T100000Z"] + • Monthly with additional dates: ["RRULE:FREQ=MONTHLY;BYMONTHDAY=1", "RDATE:20231215T100000Z"] + • Every other month on 2nd Tuesday: ["RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU"] + + All-Day Event Examples: + • Daily all-day: ["RRULE:FREQ=DAILY"] + • Weekly all-day with exceptions: ["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE;VALUE=DATE:20231030"] + + Note: For all-day events, use VALUE=DATE format for EXDATE/RDATE. For timed events, use full timestamp format.""", + "items": {"type": "string"} + }, + "reminders": { + "type": "object", + "description": "Event reminders configuration", + "properties": { + "useDefault": {"type": "boolean", "description": "Whether to use default reminders"}, + "overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["email", "popup"]}, + "minutes": {"type": "integer", "description": "Minutes before event"} + } + } + } + } + }, + "attachments": { + "type": "array", + "description": "Event attachments (processed if supportsAttachments=true)", + "items": { + "type": "object", + "properties": { + "fileUrl": {"type": "string", "description": "URL of attached file"} + }, + "required": ["fileUrl"] + } + }, + "conferenceData": { + "type": "object", + "description": "Conference data (processed based on conferenceDataVersion)", + "properties": { + "createRequest": {"type": "object", "description": "Request to create conference"}, + "entryPoints": { + "type": "array", + "description": "Conference entry points", + "items": { + "type": "object", + "properties": { + "entryPointType": {"type": "string", "description": "Entry point type (video, phone, sip, more)"}, + "uri": {"type": "string", "description": "URI of the entry point"}, + "label": {"type": "string", "description": "Label for the entry point"}, + "pin": {"type": "string", "description": "PIN to access the conference"}, + "accessCode": {"type": "string", "description": "Access code for the conference"}, + "meetingCode": {"type": "string", "description": "Meeting code for the conference"}, + "passcode": {"type": "string", "description": "Passcode for the conference"}, + "password": {"type": "string", "description": "Password for the conference"} + } + } + }, + "conferenceSolution": {"type": "object", "description": "Conference solution details"}, + "conferenceId": {"type": "string", "description": "Conference ID"}, + "signature": {"type": "string", "description": "Conference signature"} + } + }, + "extendedProperties": { + "type": "object", + "description": "Extended properties", + "properties": { + "private": {"type": "object", "description": "Private extended properties"}, + "shared": {"type": "object", "description": "Shared extended properties"} + } + }, + "focusTimeProperties": { + "type": "object", + "description": "Focus time properties for focusTime events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "chatStatus": { + "type": "string", + "description": "The status to mark the user in Chat and related products", + "enum": ["available", "doNotDisturb"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "outOfOfficeProperties": { + "type": "object", + "description": "Out of office properties for outOfOffice events", + "properties": { + "autoDeclineMode": { + "type": "string", + "description": "Whether to decline meeting invitations which overlap Focus Time events", + "enum": ["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"] + }, + "declineMessage": { + "type": "string", + "description": "Response message to set if an existing event or new invitation is automatically declined by Calendar" + } + } + }, + "originalStartTime": { + "type": "object", + "description": "Original start time for event instances (must match start field values)", + "properties": { + "dateTime": { + "type": "string", + "description": "RFC3339 timestamp with timezone for timed events" + }, + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format for all-day events" + }, + "timeZone": { + "type": "string", + "description": "IANA timezone identifier" + } + } + }, + "source": { + "type": "object", + "description": "Event source information", + "properties": { + "url": {"type": "string", "description": "Source URL"}, + "title": {"type": "string", "description": "Source title"} + }, + "required": ["url", "title"] + }, + "iCalUID": { + "type": "string", + "description": "iCalendar UID for external calendar integration" + }, + "sequence": { + "type": "integer", + "description": "iCalendar sequence number" + }, + "guestsCanInviteOthers": { + "type": "boolean", + "default": True, + "description": "Whether attendees can invite others" + }, + "guestsCanModify": { + "type": "boolean", + "default": False, + "description": "Whether attendees can modify the event" + }, + "guestsCanSeeOtherGuests": { + "type": "boolean", + "default": True, + "description": "Whether attendees can see other guests" + }, + "conferenceDataVersion": { + "type": "integer", + "description": "Version number of conference data supported by API client (0 or 1)" + }, + "supportsAttachments": { + "type": "boolean", + "default": False, + "description": "Whether API client supports event attachments" + } + }, + "required": ["calendarId", "start", "end", "iCalUID"] + } + }, + { + "name": "get_event_instances", + "description": """Returns instances of the specified recurring event following Google Calendar API v3 specification. + + Returns individual instances of a recurring event with complete Google Calendar API v3 compatibility. + Expands recurring events into their actual occurrences within the specified time range. + Supports all official query parameters including filtering, pagination, and response formatting. + + Request Body Requirements: + - calendarId: Required. Calendar identifier + - eventId: Required. Recurring event identifier + + Optional Parameters (Google Calendar API v3 compliant): + - maxAttendees: 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. + - maxResults: 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. + - originalStart: The original start time of the instance in the result. Optional. + - pageToken: Token specifying which result page to return. Optional. + - showDeleted: 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. + - timeMin: 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. + - timeMax: 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. + - timeZone: Time zone used in the response. Optional. The default is the time zone of the calendar. + + Instance Expansion Features: + - Takes a recurring event and expands it into individual occurrences + - Each instance shows the actual date/time it occurs + - Useful for displaying recurring events in calendar views + - Respects time range filters to limit results + - Supports pagination for large recurring series + - Handles timezone conversions based on timeZone parameter + - Includes deleted instances when showDeleted=true + + Response Structure: + - Returns events collection with Google Calendar API v3 format: + * kind: "calendar#events" + * etag: ETag of the collection + * summary: Calendar summary + * description: Calendar description + * updated: Last modification time + * timeZone: Response timezone + * accessRole: User's access role + * defaultReminders: Default reminders for the calendar + * nextPageToken: Token for next page (if applicable) + * items: Array of individual event instances + + Status Codes: + - 200: Success - Event instances retrieved successfully + - 400: Bad Request - Invalid parameters or non-recurring event + - 403: Forbidden - Insufficient permissions + - 404: Not Found - Event or calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier" + }, + "eventId": { + "type": "string", + "description": "Recurring event identifier" + }, + "maxAttendees": { + "type": "integer", + "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." + }, + "maxResults": { + "type": "integer", + "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.", + "minimum": 1, + "maximum": 2500 + }, + "originalStart": { + "type": "string", + "description": "The original start time of the instance in the result. Optional." + }, + "pageToken": { + "type": "string", + "description": "Token specifying which result page to return. Optional." + }, + "showDeleted": { + "type": "boolean", + "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." + }, + "timeMin": { + "type": "string", + "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." + }, + "timeMax": { + "type": "string", + "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." + }, + "timeZone": { + "type": "string", + "description": "Time zone used in the response. Optional. The default is the time zone of the calendar." + } + }, + "required": ["calendarId", "eventId"] + } + }, + { + "name": "watch_events", + "description": """Set up a webhook to receive notifications when events change. + + Sets up webhook notifications for event changes following Google Calendar API v3 structure. + Returns a channel for managing the watch. Monitors events in the specified calendar for changes. + + Request Body Requirements: + - calendarId: Required. Calendar identifier to watch for changes + + Optional Query Parameters: + - eventTypes(string): 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. + + Request Body Requirements: + - id: Required. Unique channel identifier + - address: Required. Webhook URL to receive notifications + - type: Optional. Channel type (default: \"web_hook\") + - token: Optional. Optional token for webhook authentication + - params: Optional. Additional channel parameters + + Webhook Notifications: + - Server will send POST requests to the specified address + - Notifications triggered by event changes: + * Event created, updated, or deleted + * Event moved between calendars + * Event status changes + + Channel Management: + - Each watch creates a unique notification channel + - Channels can expire (set expiration time) + - Multiple channels can watch the same calendar + - Use unique channel IDs to avoid conflicts + + Response Structure: + - Returns Channel resource with Google Calendar API v3 format: + * kind: \"api#channel\" + * id: Channel identifier + * resourceId: Resource being watched + * resourceUri: Resource URI path + + Status Codes: + - 200: Success - Watch channel created successfully + - 400: Bad Request - Invalid channel configuration + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "calendarId": { + "type": "string", + "description": "Calendar identifier to watch for changes" + }, + "id": { + "type": "string", + "description": "Unique channel identifier" + }, + "type": { + "type": "string", + "description": "Channel type (web_hook)" + }, + "address": { + "type": "string", + "description": "Webhook URL to receive notifications" + }, + "token": { + "type": "string", + "description": "Optional token for webhook authentication" + }, + "params": { + "type": "object", + "description": "Additional channel parameters", + "properties": { + "ttl": { + "type": "string", + "description": "Time to live in seconds (string)." + } + } + }, + "eventTypes": { + "type": "string", + "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." + } + }, + "required": ["calendarId", "id", "type", "address"] + } + } +] \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/freebusy.py b/envs/calendar_env/server/calendar_mcp/tools/freebusy.py new file mode 100644 index 000000000..9a8680d6f --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/freebusy.py @@ -0,0 +1,158 @@ +""" +FreeBusy MCP tools for Google Calendar API v3 compatibility +All FreeBusy API endpoints with clean tool definitions +""" + +FREEBUSY_TOOLS = [ + { + "name": "query_freebusy", + "description": """Query free/busy information for a set of calendars. + + Returns free/busy information for specified calendars following Google Calendar API v3 structure. + Shows busy time periods when calendars have confirmed events that block time. + Essential for scheduling meetings and finding available time slots. + + Request Body Requirements: + - timeMin: Required. Lower bound for the query (RFC3339 timestamp) + - timeMax: Required. Upper bound for the query (RFC3339 timestamp) + - items: Required. List of calendar identifiers to query + + Optional Parameters: + - timeZone: Time zone for the query (default: UTC) + - groupExpansionMax: Maximum number of calendars to expand for groups + - calendarExpansionMax: Maximum number of events to expand for calendars + + Time Period Handling: + - Only confirmed events block time (status = "confirmed") + - Transparent events do not block time + - Overlapping events are merged into continuous busy periods + - Results are clipped to the requested time range + + Response Structure: + - Returns FreeBusy resource with Google Calendar API v3 format: + * kind: "calendar#freeBusy" + * timeMin: Query start time + * timeMax: Query end time + * calendars: Object with calendar IDs as keys + * Each calendar contains: + - busy: Array of busy time periods + - errors: Array of errors (if any) + + Status Codes: + - 200: Success - FreeBusy information retrieved successfully + - 400: Bad Request - Invalid query parameters + - 404: Not Found - Calendar not found + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "timeMin": { + "type": "string", + "description": "Lower bound for the query (RFC3339 timestamp)" + }, + "timeMax": { + "type": "string", + "description": "Upper bound for the query (RFC3339 timestamp)" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the query (IANA timezone, default: UTC)" + }, + "groupExpansionMax": { + "type": "integer", + "description": "Maximum number of calendars to expand for groups" + }, + "calendarExpansionMax": { + "type": "integer", + "description": "Maximum number of events to expand for calendars" + }, + "items": { + "type": "array", + "description": "List of calendar identifiers to query", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Calendar identifier" + } + }, + "required": ["id"] + } + } + }, + "required": ["timeMin", "timeMax", "items"] + } + }, +] + + +# Additional helper information for FreeBusy functionality +FREEBUSY_CONCEPTS = { + "busy_time_calculation": { + "description": "How busy times are calculated from calendar events", + "rules": [ + "Only events with status='confirmed' block time", + "Events with transparency='transparent' do not block time", + "All-day events block the entire day", + "Overlapping events are merged into continuous periods", + "Event times are clipped to the query time range" + ] + }, + "time_zone_handling": { + "description": "How timezones are handled in FreeBusy queries", + "details": [ + "Query times should be in RFC3339 format", + "All calculations are done in UTC internally", + "Results are returned in the requested timezone", + "Event times are converted from their native timezone", + "Default timezone is UTC if not specified" + ] + }, + "error_handling": { + "description": "How errors are handled in FreeBusy responses", + "scenarios": [ + "Calendar not found: Returns error in calendar result", + "No access to calendar: Returns error in calendar result", + "Invalid time range: Returns 400 Bad Request", + "Too many calendars: Returns 400 Bad Request", + "Internal errors: Calendar marked with backend error" + ] + }, + "performance_considerations": { + "description": "Performance aspects of FreeBusy queries", + "guidelines": [ + "Limit time range to reasonable periods (max 366 days)", + "Limit number of calendars per query (max 50)", + "Use batch queries for multiple time ranges efficiently", + "Consider caching for frequently accessed calendars" + ] + } +} + + +# Sample usage examples for documentation +FREEBUSY_EXAMPLES = { + "simple_query": { + "description": "Query busy times for a single calendar today", + "request": { + "timeMin": "2024-01-15T00:00:00Z", + "timeMax": "2024-01-16T00:00:00Z", + "timeZone": "UTC", + "items": [{"id": "primary"}] + } + }, + "multiple_calendars": { + "description": "Query multiple calendars for the next week", + "request": { + "timeMin": "2024-01-15T00:00:00Z", + "timeMax": "2024-01-22T00:00:00Z", + "timeZone": "America/New_York", + "items": [ + {"id": "primary"}, + {"id": "work@company.com"}, + {"id": "team@company.com"} + ] + } + }, +} \ No newline at end of file diff --git a/envs/calendar_env/server/calendar_mcp/tools/settings.py b/envs/calendar_env/server/calendar_mcp/tools/settings.py new file mode 100644 index 000000000..6f986ef52 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/settings.py @@ -0,0 +1,161 @@ +""" +Settings Tools Module + +This module contains tools related to user settings management. +Follows Google Calendar API v3 structure for settings operations. +Covers listing and retrieving settings only. +""" + +SETTINGS_TOOLS = [ + { + "name": "get_settings", + "description": """Retrieve a specific user setting by ID. + + Returns the value of a setting resource by its ID. + Follows Google Calendar API v3 `/settings/{settingId}` structure. + + Request Body Requirements: + - settingId: Required. Unique identifier of the setting (e.g., "timezone") + + Response Structure: + - kind: "calendar#setting" + - etag: ETag of the setting + - id: Unique setting identifier + - value: Current value of the setting + - user_id: ID of the user who owns the setting + + Status Codes: + - 200: Success - Setting returned successfully + - 404: Not Found - No setting exists with the given ID + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "settingId": { + "type": "string", + "description": "Unique identifier of the setting (e.g., 'timezone')", + "minLength": 1 + } + }, + "required": ["settingId"] + } + }, + { + "name": "list_settings", + "description": """List all visible user settings. + + Retrieves all settings that are visible to the authenticated user. + Follows the structure of Google Calendar API v3 `/settings` endpoint. + + No request body required. + + Response Structure: + - kind: "calendar#settings" + - etag: ETag for the entire settings collection + - items: Array of setting resources, each containing: + * kind: "calendar#setting" + * etag: ETag of the setting + * id: Unique identifier of the setting (e.g., "timezone") + * value: Current value of the setting + + Status Codes: + - 200: Success - List of settings returned + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "watch_settings", + "description": """Watch for changes to user settings. + + Sets up a notification channel to receive updates when settings change. + Follows Google Calendar API v3 `/settings/watch` structure. + + Creates a watch channel that will send webhook notifications to the specified + address whenever settings are modified. The channel will automatically expire + after a maximum of 24 hours or at the specified expiration time. + + Request Body Requirements: + - id: Required. Unique identifier for the channel + - type: Optional. Channel type (defaults to "web_hook") + - address: Required. URL where notifications will be sent + - token: Optional. Verification token for webhook security + - params: Optional. Additional parameters as key-value pairs + + Optional Parameters: + - token: Optional token for webhook authentication + - params: Additional channel parameters + + Response Structure: + - kind: "api#channel" + - id: Channel identifier + - resourceId: Resource being watched + - resourceUri: URI of the resource ("/settings") + - token: Verification token (if provided) + - expiration: Channel expiration time + + Webhook Notification Format: + Your webhook will receive POST requests with this payload: + { + "kind": "api#channel", + "id": "channel-id", + "resourceId": "settings-user-id", + "resourceUri": "/settings", + "eventType": "update|insert|delete", + "resourceState": "sync", + "timestamp": "2024-10-01T18:30:00Z", + "data": { + "kind": "calendar#setting", + "id": "setting-id", + "value": "new-value", + "oldValue": "previous-value", + "user_id": "user-id" + } + } + + Status Codes: + - 200: Success - Watch channel created + - 400: Bad Request - Invalid request parameters + - 500: Internal Server Error""", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the watch channel", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Type of notification channel", + "default": "web_hook", + "enum": ["web_hook"] + }, + "address": { + "type": "string", + "description": "URL where webhook notifications will be sent", + "format": "uri", + "minLength": 1 + }, + "token": { + "type": "string", + "description": "Optional verification token for webhook security" + }, + "params": { + "type": "object", + "description": "Additional parameters as key-value pairs", + "properties": { + "ttl": { + "type": "string", + "description": "Time to live in seconds (string)." + } + } + } + }, + "required": ["id", "type", "address"] + } + }, +] diff --git a/envs/calendar_env/server/calendar_mcp/tools/users.py b/envs/calendar_env/server/calendar_mcp/tools/users.py new file mode 100644 index 000000000..0bc05d039 --- /dev/null +++ b/envs/calendar_env/server/calendar_mcp/tools/users.py @@ -0,0 +1,37 @@ +""" +MCP Tools for User Management +""" + +USERS_TOOLS = [ + { + "name": "get_user_by_email", + "description": """Get user details by email address + +This tool retrieves complete user information using their email address, including user ID, name, and other profile details. + +**API Endpoint:** GET /users/email/{email} + +**Parameters:** +- email (required): The email address to lookup + +**Returns:** +Complete user information including id, name, given_name, family_name, picture, locale, timezone, is_active, is_verified, etc. + +**Example:** +```json +{ + "email": "user@example.com" +} +```""", + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email address to lookup" + } + }, + "required": ["email"] + } + } +] \ No newline at end of file diff --git a/envs/calendar_env/server/data/__init__.py b/envs/calendar_env/server/data/__init__.py new file mode 100644 index 000000000..77a203758 --- /dev/null +++ b/envs/calendar_env/server/data/__init__.py @@ -0,0 +1 @@ +"""Data package""" \ No newline at end of file diff --git a/envs/calendar_env/server/data/enhanced_event_seed_data.py b/envs/calendar_env/server/data/enhanced_event_seed_data.py new file mode 100644 index 000000000..7095d05a5 --- /dev/null +++ b/envs/calendar_env/server/data/enhanced_event_seed_data.py @@ -0,0 +1,893 @@ +""" +Enhanced Event Seed Data with all new fields and related tables +""" + +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any +import json + + +def get_enhanced_event_seed_data() -> Dict[str, Any]: + """ + Generate comprehensive seed data demonstrating all Event model features including: + - All new Event fields (conference_data, reminders, extended_properties, etc.) + - Attendees with various statuses and roles + - Attachments (file URLs) + - Working location properties for different work modes + """ + + # Base datetime for consistent relative dates + base_date = datetime.now(timezone.utc).replace(hour=9, minute=0, second=0, microsecond=0) + + # Office locations - required for working location properties + office_locations_data = [ + { + "id": "office-building-1", + "buildingId": "building-1", + "deskId": None, + "floorId": None, + "floorSectionId": None, + "label": "TechCorp Main Campus - Building 1" + }, + { + "id": "office-building-2-floor-3", + "buildingId": "building-2", + "deskId": "desk-W3-45", + "floorId": "floor-3", + "floorSectionId": "west-wing", + "label": "TechCorp Main Campus - Building 2, Floor 3, West Wing, Desk W3-45" + }, + { + "id": "office-meeting-room-a", + "buildingId": "building-1", + "deskId": None, + "floorId": "floor-1", + "floorSectionId": "conference-area", + "label": "Conference Room A - Building 1" + } + ] + + # Events data - to match model exactly + events_data = [ + { + "event_id": "event-corrected-001", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "organizer_id": "alice_manager", + "organizer_email": "alice.johnson@techcorp.com", + "organizer_display_name": "Alice Johnson", + "organizer_self": True, + "summary": "Sprint Planning & Architecture Review", + "description": "Detailed sprint planning session with architecture discussion for Q4 features. We'll review user stories, estimate effort, and plan the technical approach.", + "location": "Conference Room A, Building 1", + "start_datetime": base_date, + "end_datetime": base_date + timedelta(hours=2), + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date, + "originalStartTime_timeZone": "America/New_York", + "recurrence": None, + "status": "confirmed", + "visibility": "default", + "color_id": "7", + "eventType": "default", + "focusTimeProperties": None, + "guestsCanInviteOthers": True, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 1, + "iCalUID":"event-corrected-001@gmail.com", + "source": { + "title": "Sprint Planning Board", + "url": "https://jira.techcorp.com/sprint-planning-q4" + } + }, + { + "event_id": "event-corrected-002", + "calendar_id": "bob-development", + "user_id": "bob_developer", + "organizer_id": "bob_developer", + "organizer_email": "bob.smith@techcorp.com", + "organizer_display_name": "Bob Smith", + "organizer_self": True, + "summary": "Deep Work: Core Algorithm Implementation", + "description": "Focused development time for implementing the new search algorithm. No interruptions please.", + "location": "Developer Workspace, Building 2", + "start_datetime": base_date + timedelta(days=1, hours=1), + "end_datetime": base_date + timedelta(days=1, hours=4), + "start_timezone": "America/Los_Angeles", + "end_timezone": "America/Los_Angeles", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=1, hours=1), + "originalStartTime_timeZone": "America/Los_Angeles", + "recurrence": None, + "status": "confirmed", + "visibility": "private", + "color_id": "9", + "eventType": "focusTime", + "focusTimeProperties": { + "autoDeclineMode": "declineNone", + "declineMessage": "I'm in focus time. Please reschedule or reach out via Slack for urgent matters.", + "chatStatus": "doNotDisturb" + }, + "guestsCanInviteOthers": False, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": False, + "outOfOfficeProperties": None, + "sequence": 0, + "source": None, + "iCalUID":"event-corrected-002@gmail.com" + }, + { + "event_id": "event-corrected-003", + "calendar_id": "carol-primary", + "user_id": "carol_designer", + "organizer_id": "carol_designer", + "organizer_email": "carol.white@techcorp.com", + "organizer_display_name": "Carol White", + "organizer_self": True, + "summary": "Annual Leave - Family Vacation", + "description": "Taking time off for family vacation. Will have limited access to email.", + "location": "Bali, Indonesia", + "start_datetime": base_date + timedelta(days=14), + "end_datetime": base_date + timedelta(days=21), + "start_timezone": "Asia/Makassar", + "end_timezone": "Asia/Makassar", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=14), + "originalStartTime_timeZone": "Asia/Makassar", + "recurrence": None, + "status": "confirmed", + "visibility": "public", + "color_id": "4", + "eventType": "outOfOffice", + "focusTimeProperties": None, + "guestsCanInviteOthers": False, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": { + "autoDeclineMode": "declineAllConflictingInvitations", + "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.", + "autoDeclineEventTypes": [ + "default", + "focusTime", + "workingLocation" + ] + }, + "sequence": 0, + "source": None, + "iCalUID":"event-corrected-003@gmail.com" + }, + { + "event_id": "event-corrected-004", + "calendar_id": "bob-primary", + "user_id": "bob_developer", + "organizer_id": "bob_developer", + "organizer_email": "bob.smith@techcorp.com", + "organizer_display_name": "Bob Smith", + "organizer_self": True, + "summary": "Office Day - Collaboration Sessions", + "description": "In office today for team collaboration and pair programming sessions.", + "location": None, + "start_datetime": base_date + timedelta(days=2), + "end_datetime": base_date + timedelta(days=2, hours=8), + "start_timezone": "America/Los_Angeles", + "end_timezone": "America/Los_Angeles", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=2), + "originalStartTime_timeZone": "America/Los_Angeles", + "recurrence": None, + "status": "confirmed", + "visibility": "public", + "color_id": "2", + "eventType": "workingLocation", + "focusTimeProperties": None, + "guestsCanInviteOthers": True, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 0, + "source": None, + "iCalUID":"event-corrected-004@gmail.com" + }, + { + "event_id": "event-corrected-005", + "calendar_id": "dave-primary", + "user_id": "dave_sales", + "organizer_id": "dave_sales", + "organizer_email": "dave.brown@techcorp.com", + "organizer_display_name": "Dave Brown", + "organizer_self": True, + "summary": "Dave's Birthday", + "description": "Happy Birthday Dave!", + "location": None, + "start_datetime": base_date + timedelta(days=30), + "end_datetime": base_date + timedelta(days=30, hours=1), + "start_timezone": None, + "end_timezone": None, + "originalStartTime_date": (base_date + timedelta(days=30)).date(), + "originalStartTime_dateTime": None, + "originalStartTime_timeZone": "UTC", + "recurrence": None, + "status": "confirmed", + "visibility": "public", + "color_id": "6", + "eventType": "birthday", + "focusTimeProperties": None, + "guestsCanInviteOthers": True, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 0, + "source": None, + "iCalUID":"event-corrected-005@gmail.com" + }, + { + "event_id": "event-corrected-006", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "organizer_id": "dave_sales", + "organizer_email": "dave.brown@techcorp.com", + "organizer_display_name": "Dave Brown", + "organizer_self": True, + "recurring_event_id": "rec-event-001", + "summary": "Enterprise Client Demo - TechCorp Solutions", + "description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.", + "location": "MegaCorp Headquarters, 123 Business Ave, New York, NY", + "start_datetime": base_date + timedelta(days=3, hours=2), + "end_datetime": base_date + timedelta(days=3, hours=4), + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=3, hours=2), + "originalStartTime_timeZone": "America/New_York", + "recurrence": ["RRULE:COUNT=2"], + "status": "confirmed", + "visibility": "default", + "color_id": "1", + "eventType": "default", + "focusTimeProperties": None, + "guestsCanInviteOthers": False, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 2, + "source": { + "title": "CRM System - MegaCorp Deal", + "url": "https://crm.techcorp.com/deals/megacorp-2024" + }, + "iCalUID":"event-icalid-001@gmail.com" + }, + { + "event_id": "event-corrected-007", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "organizer_id": "dave_sales", + "organizer_email": "dave.brown@techcorp.com", + "organizer_display_name": "Dave Brown", + "organizer_self": True, + "recurring_event_id": "rec-event-001", + "summary": "Enterprise Client Demo - TechCorp Solutions", + "description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.", + "location": "MegaCorp Headquarters, 123 Business Ave, New York, NY", + "start_datetime": base_date + timedelta(days=4, hours=2), + "end_datetime": base_date + timedelta(days=4, hours=4), + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=4, hours=2), + "originalStartTime_timeZone": "America/New_York", + "recurrence": ["RRULE:COUNT=2"], + "status": "confirmed", + "visibility": "default", + "color_id": "1", + "eventType": "default", + "focusTimeProperties": None, + "guestsCanInviteOthers": False, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 2, + "source": { + "title": "CRM System - MegaCorp Deal", + "url": "https://crm.techcorp.com/deals/megacorp-2024" + }, + "iCalUID":"event-icalid-001@gmail.com" + }, + { + "event_id": "event-corrected-008", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "organizer_id": "dave_sales", + "organizer_email": "dave.brown@techcorp.com", + "organizer_display_name": "Dave Brown", + "organizer_self": True, + "recurring_event_id": "rec-event-001", + "summary": "Enterprise Client Demo - TechCorp Solutions", + "description": "Product demonstration for MegaCorp Inc. Focus on enterprise features, security, and scalability. Bring pricing sheets and technical specs.", + "location": "MegaCorp Headquarters, 123 Business Ave, New York, NY", + "start_datetime": base_date + timedelta(days=5, hours=2), + "end_datetime": base_date + timedelta(days=5, hours=4), + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + "originalStartTime_date": None, + "originalStartTime_dateTime": base_date + timedelta(days=5, hours=2), + "originalStartTime_timeZone": "America/New_York", + "recurrence": ["RRULE:COUNT=2"], + "status": "confirmed", + "visibility": "default", + "color_id": "1", + "eventType": "default", + "focusTimeProperties": None, + "guestsCanInviteOthers": False, + "guestsCanModify": False, + "guestsCanSeeOtherGuests": True, + "outOfOfficeProperties": None, + "sequence": 2, + "source": { + "title": "CRM System - MegaCorp Deal", + "url": "https://crm.techcorp.com/deals/megacorp-2024" + }, + "iCalUID":"event-icalid-001@gmail.com" + } + ] + + recurring_event_data = [ + { + "recurring_event_id":"rec-event-001", + "original_recurrence":["RRULE:COUNT=2"] + } + ] + + # ConferenceData - separate table (correct relationship name) + conference_data = [ + { + "id": "conf-corrected-001", + "event_id": "event-corrected-001", + "request_id": "req-sprint-planning-001", + "solution_type": "hangoutsMeet", + "status_code": "success", + "meeting_uri": "https://meet.google.com/abc-defg-hij", + "label": "Sprint Planning Meet" + }, + { + "id": "conf-corrected-002", + "event_id": "event-corrected-006", + "request_id": "req-client-demo-001", + "solution_type": "hangoutsMeet", + "status_code": "success", + "meeting_uri": "https://meet.google.com/enterprise-demo-xyz", + "label": "Client Demo Backup" + } + ] + + # BirthdayProperties - separate table (correct relationship name) + birthday_properties = [ + { + "id": "birthday-corrected-001", + "event_id": "event-corrected-005", + "type": "birthday" + } + ] + + # ExtendedProperties - separate table with scope enum (correct relationship name) + extended_properties = [ + { + "id": "ext-corrected-001", + "event_id": "event-corrected-001", + "scope": "private", + "properties": { + "departmentBudget": "engineering", + "projectCode": "PROJ-2024-Q4" + } + }, + { + "id": "ext-corrected-002", + "event_id": "event-corrected-001", + "scope": "shared", + "properties": { + "meetingType": "sprint_planning", + "priority": "high" + } + }, + { + "id": "ext-corrected-003", + "event_id": "event-corrected-002", + "scope": "private", + "properties": { + "taskType": "development", + "estimatedComplexity": "high" + } + }, + { + "id": "ext-corrected-004", + "event_id": "event-corrected-003", + "scope": "shared", + "properties": { + "backupContact": "sarah@techcorp.com", + "vacationType": "personal" + } + }, + { + "id": "ext-corrected-005", + "event_id": "event-corrected-004", + "scope": "private", + "properties": { + "commute_reminder": "Leave by 8:00 AM to avoid traffic" + } + }, + { + "id": "ext-corrected-006", + "event_id": "event-corrected-006", + "scope": "private", + "properties": { + "dealValue": "$250000", + "clientPriority": "high", + "preparationTime": "2 hours" + } + }, + { + "id": "ext-corrected-007", + "event_id": "event-corrected-006", + "scope": "shared", + "properties": { + "meetingType": "client_demo", + "department": "sales" + } + } + ] + + # Reminders - separate table with method enum (correct relationship name) + reminders_data = [ + { + "id": "rem-corrected-001", + "event_id": "event-corrected-001", + "method": "email", + "minutes": 1440, # 1 day before + "use_default": False + }, + { + "id": "rem-corrected-002", + "event_id": "event-corrected-001", + "method": "popup", + "minutes": 30, # 30 minutes before + "use_default": False + }, + { + "id": "rem-corrected-003", + "event_id": "event-corrected-002", + "method": "popup", + "minutes": 15, # 15 minutes before + "use_default": False + }, + { + "id": "rem-corrected-004", + "event_id": "event-corrected-003", + "method": "email", + "minutes": 10080, # 1 week before + "use_default": False + }, + { + "id": "rem-corrected-005", + "event_id": "event-corrected-005", + "method": "popup", + "minutes": 10, # Day of reminder + "use_default": False + }, + { + "id": "rem-corrected-006", + "event_id": "event-corrected-006", + "method": "email", + "minutes": 2880, # 2 days before + "use_default": False + }, + { + "id": "rem-corrected-007", + "event_id": "event-corrected-006", + "method": "popup", + "minutes": 60, # 1 hour before + "use_default": False + }, + { + "id": "rem-corrected-008", + "event_id": "event-corrected-006", + "method": "popup", + "minutes": 15, # 15 minutes before + "use_default": False + } + ] + + # Attendees data - to match model exactly + attendees_data = [ + # Sprint Planning attendees + { + "attendees_id": "att-corrected-001", + "event_id": "event-corrected-001", + "user_id": "alice_manager", + "comment": "Looking forward to planning Q4!", + "displayName": "Alice Johnson", + "additionalGuests": 0, + "optional": False, + "resource": False, + "responseStatus": "accepted" + }, + { + "attendees_id": "att-corrected-002", + "event_id": "event-corrected-001", + "user_id": "bob_developer", + "comment": None, + "displayName": "Bob Smith", + "additionalGuests": 0, + "optional": False, + "resource": False, + "responseStatus": "accepted" + }, + { + "attendees_id": "att-corrected-003", + "event_id": "event-corrected-001", + "user_id": "carol_designer", + "comment": "Will join if no conflicts with user research session", + "displayName": "Carol White", + "additionalGuests": 0, + "optional": True, + "resource": False, + "responseStatus": "tentative" + }, + { + "attendees_id": "att-corrected-004", + "event_id": "event-corrected-001", + "user_id": None, # Resource doesn't have user_id + "comment": None, + "displayName": "Conference Room A", + "additionalGuests": 0, + "optional": False, + "resource": True, + "responseStatus": "accepted" + }, + + # Client Demo attendees + { + "attendees_id": "att-corrected-005", + "event_id": "event-corrected-006", + "user_id": "dave_sales", + "comment": "Prepared demo materials and pricing", + "displayName": "Dave Brown", + "additionalGuests": 0, + "optional": False, + "resource": False, + "responseStatus": "accepted" + }, + { + "attendees_id": "att-corrected-006", + "event_id": "event-corrected-006", + "user_id": None, # External attendee + "comment": "Bringing 2 technical team members", + "displayName": "John Doe", + "additionalGuests": 2, + "optional": False, + "resource": False, + "responseStatus": "accepted" + }, + { + "attendees_id": "att-corrected-007", + "event_id": "event-corrected-006", + "user_id": "sarah_tech", + "comment": "Technical support for enterprise questions", + "displayName": "Sarah Chen", + "additionalGuests": 0, + "optional": True, + "resource": False, + "responseStatus": "accepted" + }, + { + "attendees_id": "att-corrected-008", + "event_id": "event-corrected-006", + "user_id": "alice_manager", + "comment": None, + "displayName": "Alice Johnson", + "additionalGuests": 0, + "optional": True, + "resource": False, + "responseStatus": "needsAction" + } + ] + + # Attachments for events + attachments_data = [ + { + "attachment_id": "attach-corrected-001", + "event_id": "event-corrected-001", + "file_url": "https://drive.google.com/file/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/view" + }, + { + "attachment_id": "attach-corrected-002", + "event_id": "event-corrected-001", + "file_url": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit" + }, + { + "attachment_id": "attach-corrected-003", + "event_id": "event-corrected-006", + "file_url": "https://drive.google.com/file/d/enterprise-demo-deck-2024/view" + }, + { + "attachment_id": "attach-corrected-004", + "event_id": "event-corrected-006", + "file_url": "https://drive.google.com/file/d/pricing-sheet-enterprise-2024/view" + }, + { + "attachment_id": "attach-corrected-005", + "event_id": "event-corrected-006", + "file_url": "https://docs.google.com/document/d/technical-specs-enterprise/edit" + } + ] + + # Working location properties - corrected structure with proper office location foreign key + working_location_data = [ + { + "working_location_id": "wl-corrected-001", + "event_id": "event-corrected-004", + "type": "officeLocation", + "homeOffice": False, + "customLocationLabel": None, + "officeLocationId": "office-building-2-floor-3" # - foreign key to office_locations + } + ] + + return { + "office_locations": office_locations_data, + "events": events_data, + "recurring_events": recurring_event_data, + "conference_data": conference_data, + "birthday_properties": birthday_properties, + "extended_properties": extended_properties, + "reminders": reminders_data, + "attendees": attendees_data, + "attachments": attachments_data, + "working_location_properties": working_location_data, + "description": "Event seed data that exactly matches the updated event.py model structure with proper field names and relationships" + } + + +def generate_enhanced_event_sql(sql_statements) -> str: + """ + Generate SQL INSERT statements for enhanced event seed data + """ + data = get_enhanced_event_seed_data() + + # Office Locations + sql_statements.append("-- Office Locations") + sql_statements.append("INSERT INTO office_locations (") + sql_statements.append(" id, buildingId, deskId, floorId, floorSectionId, label") + sql_statements.append(") VALUES") + + office_values = [] + for office in data["office_locations"]: + building_id = "NULL" if not office.get("buildingId") else f"'{office['buildingId']}'" + desk_id = "NULL" if not office.get("deskId") else f"'{office['deskId']}'" + floor_id = "NULL" if not office.get("floorId") else f"'{office['floorId']}'" + floor_section_id = "NULL" if not office.get("floorSectionId") else f"'{office['floorSectionId']}'" + + office_values.append( + f"('{office['id']}', {building_id}, {desk_id}, {floor_id}, {floor_section_id}, '{office['label']}')" + ) + + sql_statements.append(",\n".join(office_values) + ";") + sql_statements.append("") + + # Recurring Events + sql_statements.append("-- Recurring Events") + sql_statements.append("INSERT INTO recurring_events (") + sql_statements.append(" recurring_event_id, original_recurrence") + sql_statements.append(") VALUES") + + recurring_events = [] + for rec_event in data["recurring_events"]: + recurring_event_id = "NULL" if not rec_event.get("recurring_event_id") else f"'{rec_event['recurring_event_id']}'" + 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))}'" + + recurring_events.append(f"({recurring_event_id},{original_recurrence})") + + sql_statements.append(",\n".join(recurring_events) + ";") + sql_statements.append("") + + # Events + sql_statements.append("-- Events") + sql_statements.append("INSERT INTO events (") + sql_statements.append(" event_id, calendar_id, user_id, organizer_id, organizer_email, organizer_display_name, organizer_self,") + sql_statements.append(" recurring_event_id, summary, description, location,") + sql_statements.append(" start_datetime, end_datetime, start_timezone, end_timezone, originalStartTime_date, originalStartTime_dateTime, originalStartTime_timeZone, recurrence,") + sql_statements.append(" status, visibility, color_id, iCalUID, eventType, focusTimeProperties,") + sql_statements.append(" guestsCanInviteOthers, guestsCanModify, guestsCanSeeOtherGuests,") + sql_statements.append(" outOfOfficeProperties, sequence, source, created_at, updated_at") + sql_statements.append(") VALUES") + + event_values = [] + for event in data["events"]: + # Handle optional fields + description = "NULL" if not event.get("description") else f"'{event['description'].replace(chr(39), chr(39)+chr(39))}'" + location = "NULL" if not event.get("location") else f"'{event['location'].replace(chr(39), chr(39)+chr(39))}'" + start_tz = "NULL" if not event.get("start_timezone") else f"'{event['start_timezone']}'" + end_tz = "NULL" if not event.get("end_timezone") else f"'{event['end_timezone']}'" + + # Handle originalStartTime fields + original_start_date = "NULL" + original_start_datetime = "NULL" + original_start_timezone = "NULL" + + if event.get("originalStartTime_date"): + original_start_date = f"'{event['originalStartTime_date'].isoformat()}'" + if event.get("originalStartTime_dateTime"): + original_start_datetime = f"'{event['originalStartTime_dateTime'].isoformat()}'" + if event.get("originalStartTime_timeZone"): + original_start_timezone = f"'{event['originalStartTime_timeZone']}'" + + # Handle recurrence field - it can be a list or None + recurring_event_id = "NULL" if not event.get("recurring_event_id") else event["recurring_event_id"] + + recurrence = "NULL" + if event.get("recurrence"): + if isinstance(event['recurrence'], list): + recurrence = f"'{json.dumps(event['recurrence']).replace(chr(39), chr(39)+chr(39))}'" + else: + recurrence = f"'{event['recurrence'].replace(chr(39), chr(39)+chr(39))}'" + + color_id = "NULL" if not event.get("color_id") else f"'{event['color_id']}'" + + # Handle JSON fields - using + focus_props = "NULL" + if event.get("focusTimeProperties"): + focus_props = f"'{json.dumps(event['focusTimeProperties']).replace(chr(39), chr(39)+chr(39))}'" + + ooo_props = "NULL" + if event.get("outOfOfficeProperties"): + ooo_props = f"'{json.dumps(event['outOfOfficeProperties']).replace(chr(39), chr(39)+chr(39))}'" + + source = "NULL" + if event.get("source"): + source = f"'{json.dumps(event['source']).replace(chr(39), chr(39)+chr(39))}'" + + # Handle organizer fields + organizer_id = "NULL" if not event.get("organizer_id") else f"'{event['organizer_id']}'" + organizer_email = "NULL" if not event.get("organizer_email") else f"'{event['organizer_email']}'" + organizer_display_name = "NULL" if not event.get("organizer_display_name") else f"'{event['organizer_display_name']}'" + organizer_self = 1 if event.get("organizer_self", False) else 0 + + event_values.append( + f"('{event['event_id']}', '{event['calendar_id']}', '{event['user_id']}', {organizer_id}, {organizer_email}, {organizer_display_name}, {organizer_self}, " + f"'{recurring_event_id}', '{event['summary'].replace(chr(39), chr(39)+chr(39))}', {description}, {location}, " + f"'{event['start_datetime'].isoformat()}', '{event['end_datetime'].isoformat()}', " + f"{start_tz}, {end_tz}, {original_start_date}, {original_start_datetime}, {original_start_timezone}, {recurrence}, " + f"'{event['status']}', '{event['visibility']}', {color_id}, '{event['iCalUID']}', '{event['eventType']}', {focus_props}, " + f"{1 if event['guestsCanInviteOthers'] else 0}, " + f"{1 if event['guestsCanModify'] else 0}, " + f"{1 if event['guestsCanSeeOtherGuests'] else 0}, " + f"{ooo_props}, {event['sequence']}, {source}, datetime('now'), datetime('now'))" + ) + + sql_statements.append(",\n".join(event_values) + ";") + sql_statements.append("") + + # ConferenceData + if data["conference_data"]: + sql_statements.append("-- ConferenceData") + sql_statements.append("INSERT INTO conference_data (") + sql_statements.append(" id, event_id, request_id, solution_type, status_code, meeting_uri, label") + sql_statements.append(") VALUES") + + conf_values = [] + for conf in data["conference_data"]: + request_id = "NULL" if not conf.get("request_id") else f"'{conf['request_id']}'" + solution_type = "NULL" if not conf.get("solution_type") else f"'{conf['solution_type']}'" + status_code = "NULL" if not conf.get("status_code") else f"'{conf['status_code']}'" + meeting_uri = "NULL" if not conf.get("meeting_uri") else f"'{conf['meeting_uri']}'" + label = "NULL" if not conf.get("label") else f"'{conf['label']}'" + + conf_values.append( + f"('{conf['id']}', '{conf['event_id']}', {request_id}, {solution_type}, " + f"{status_code}, {meeting_uri}, {label})" + ) + + sql_statements.append(",\n".join(conf_values) + ";") + sql_statements.append("") + + # BirthdayProperties + if data["birthday_properties"]: + sql_statements.append("-- BirthdayProperties") + sql_statements.append("INSERT INTO birthday_properties (id, event_id, type) VALUES") + + birthday_values = [] + for birthday in data["birthday_properties"]: + birthday_values.append(f"('{birthday['id']}', '{birthday['event_id']}', '{birthday['type']}')") + + sql_statements.append(",\n".join(birthday_values) + ";") + sql_statements.append("") + + # ExtendedProperties + if data["extended_properties"]: + sql_statements.append("-- ExtendedProperties") + sql_statements.append("INSERT INTO extended_properties (id, event_id, scope, properties) VALUES") + + ext_values = [] + for ext in data["extended_properties"]: + properties = json.dumps(ext["properties"]).replace(chr(39), chr(39)+chr(39)) + ext_values.append( + f"('{ext['id']}', '{ext['event_id']}', '{ext['scope']}', '{properties}')" + ) + + sql_statements.append(",\n".join(ext_values) + ";") + sql_statements.append("") + + # Reminders + if data["reminders"]: + sql_statements.append("-- Reminders") + sql_statements.append("INSERT INTO reminders (id, event_id, method, minutes, use_default) VALUES") + + reminder_values = [] + for reminder in data["reminders"]: + reminder_values.append( + f"('{reminder['id']}', '{reminder['event_id']}', '{reminder['method']}', " + f"{reminder['minutes']}, {1 if reminder['use_default'] else 0})" + ) + + sql_statements.append(",\n".join(reminder_values) + ";") + sql_statements.append("") + + # Attendees - with + sql_statements.append("-- Attendees") + sql_statements.append("INSERT INTO attendees (") + sql_statements.append(" attendees_id, event_id, user_id, comment, displayName,") + sql_statements.append(" additionalGuests, optional, resource, responseStatus") + sql_statements.append(") VALUES") + + attendee_values = [] + for attendee in data["attendees"]: + user_id = "NULL" if not attendee.get("user_id") else f"'{attendee['user_id']}'" + comment = "NULL" if not attendee.get("comment") else f"'{attendee['comment'].replace(chr(39), chr(39)+chr(39))}'" + display_name = "NULL" if not attendee.get("displayName") else f"'{attendee['displayName']}'" + + attendee_values.append( + f"('{attendee['attendees_id']}', '{attendee['event_id']}', {user_id}, {comment}, {display_name}, " + f"{attendee['additionalGuests']}, {1 if attendee['optional'] else 0}, " + f"{1 if attendee['resource'] else 0}, '{attendee['responseStatus']}')" + ) + + sql_statements.append(",\n".join(attendee_values) + ";") + sql_statements.append("") + + # Attachments + sql_statements.append("-- Attachments") + sql_statements.append("INSERT INTO attachments (attachment_id, event_id, file_url) VALUES") + + attachment_values = [] + for attachment in data["attachments"]: + attachment_values.append( + f"('{attachment['attachment_id']}', '{attachment['event_id']}', '{attachment['file_url']}')" + ) + + sql_statements.append(",\n".join(attachment_values) + ";") + sql_statements.append("") + + # Working Location Properties + sql_statements.append("-- Working Location Properties") + sql_statements.append("INSERT INTO working_location_properties (") + sql_statements.append(" working_location_id, event_id, type, homeOffice, customLocationLabel, officeLocationId") + sql_statements.append(") VALUES") + + wl_values = [] + for wl in data["working_location_properties"]: + custom_label = "NULL" if not wl.get("customLocationLabel") else f"'{wl['customLocationLabel']}'" + office_location_id = "NULL" if not wl.get("officeLocationId") else f"'{wl['officeLocationId']}'" + + wl_values.append( + f"('{wl['working_location_id']}', '{wl['event_id']}', '{wl['type']}', " + f"{1 if wl['homeOffice'] else 0}, {custom_label}, {office_location_id})" + ) + + sql_statements.append(",\n".join(wl_values) + ";") + sql_statements.append("") + + return sql_statements + + diff --git a/envs/calendar_env/server/data/google_colors.py b/envs/calendar_env/server/data/google_colors.py new file mode 100644 index 000000000..ad746777a --- /dev/null +++ b/envs/calendar_env/server/data/google_colors.py @@ -0,0 +1,46 @@ +""" +Google Calendar API v3 Color Data +Exact color definitions from Google Calendar API +""" + +# Google Calendar exact color definitions based on official API +GOOGLE_CALENDAR_COLORS = [ + # Calendar colors (1-24) + {"color_id": "1", "color_type": "calendar", "background": "#ac725e", "foreground": "#1d1d1d"}, + {"color_id": "2", "color_type": "calendar", "background": "#d06b64", "foreground": "#1d1d1d"}, + {"color_id": "3", "color_type": "calendar", "background": "#f83a22", "foreground": "#1d1d1d"}, + {"color_id": "4", "color_type": "calendar", "background": "#fa57c4", "foreground": "#1d1d1d"}, + {"color_id": "5", "color_type": "calendar", "background": "#9fc6e7", "foreground": "#1d1d1d"}, + {"color_id": "6", "color_type": "calendar", "background": "#9a9cff", "foreground": "#1d1d1d"}, + {"color_id": "7", "color_type": "calendar", "background": "#4986e7", "foreground": "#1d1d1d"}, + {"color_id": "8", "color_type": "calendar", "background": "#9aa116", "foreground": "#1d1d1d"}, + {"color_id": "9", "color_type": "calendar", "background": "#ef6c00", "foreground": "#1d1d1d"}, + {"color_id": "10", "color_type": "calendar", "background": "#ff7537", "foreground": "#1d1d1d"}, + {"color_id": "11", "color_type": "calendar", "background": "#42d692", "foreground": "#1d1d1d"}, + {"color_id": "12", "color_type": "calendar", "background": "#16a765", "foreground": "#1d1d1d"}, + {"color_id": "13", "color_type": "calendar", "background": "#7bd148", "foreground": "#1d1d1d"}, + {"color_id": "14", "color_type": "calendar", "background": "#b3dc6c", "foreground": "#1d1d1d"}, + {"color_id": "15", "color_type": "calendar", "background": "#fbe983", "foreground": "#1d1d1d"}, + {"color_id": "16", "color_type": "calendar", "background": "#fad165", "foreground": "#1d1d1d"}, + {"color_id": "17", "color_type": "calendar", "background": "#92e1c0", "foreground": "#1d1d1d"}, + {"color_id": "18", "color_type": "calendar", "background": "#9fe1e7", "foreground": "#1d1d1d"}, + {"color_id": "19", "color_type": "calendar", "background": "#9fc6e7", "foreground": "#1d1d1d"}, + {"color_id": "20", "color_type": "calendar", "background": "#4986e7", "foreground": "#1d1d1d"}, + {"color_id": "21", "color_type": "calendar", "background": "#9aa116", "foreground": "#1d1d1d"}, + {"color_id": "22", "color_type": "calendar", "background": "#16a765", "foreground": "#1d1d1d"}, + {"color_id": "23", "color_type": "calendar", "background": "#ff7537", "foreground": "#1d1d1d"}, + {"color_id": "24", "color_type": "calendar", "background": "#ffad46", "foreground": "#1d1d1d"}, + + # Event colors (1-11) + {"color_id": "1", "color_type": "event", "background": "#a4bdfc", "foreground": "#1d1d1d"}, + {"color_id": "2", "color_type": "event", "background": "#7ae7bf", "foreground": "#1d1d1d"}, + {"color_id": "3", "color_type": "event", "background": "#dbadff", "foreground": "#1d1d1d"}, + {"color_id": "4", "color_type": "event", "background": "#ff887c", "foreground": "#1d1d1d"}, + {"color_id": "5", "color_type": "event", "background": "#fbd75b", "foreground": "#1d1d1d"}, + {"color_id": "6", "color_type": "event", "background": "#ffb878", "foreground": "#1d1d1d"}, + {"color_id": "7", "color_type": "event", "background": "#46d6db", "foreground": "#1d1d1d"}, + {"color_id": "8", "color_type": "event", "background": "#e1e1e1", "foreground": "#1d1d1d"}, + {"color_id": "9", "color_type": "event", "background": "#5484ed", "foreground": "#1d1d1d"}, + {"color_id": "10", "color_type": "event", "background": "#51b749", "foreground": "#1d1d1d"}, + {"color_id": "11", "color_type": "event", "background": "#dc2127", "foreground": "#1d1d1d"}, +] \ No newline at end of file diff --git a/envs/calendar_env/server/data/multi_user_sample.py b/envs/calendar_env/server/data/multi_user_sample.py new file mode 100644 index 000000000..1139948ed --- /dev/null +++ b/envs/calendar_env/server/data/multi_user_sample.py @@ -0,0 +1,532 @@ +""" +Multi-User Sample Data for Calendar Application +""" + +from datetime import datetime, timedelta +from typing import List, Dict, Any +from .enhanced_event_seed_data import generate_enhanced_event_sql +from .watch_channel_seed_data import get_watch_channel_sql + +def get_multi_user_sample_data() -> Dict[str, Any]: + """ + Generate sample data for multiple users demonstrating multi-user scenarios + """ + + # Sample users + users = [ + { + "user_id": "alice_manager", + "email": "alice.manager@techcorp.com", + "name": "Alice Johnson", + "given_name": "Alice", + "family_name": "Johnson", + "static_token": "ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2.qwErTyUIopASDfGhJkLzXcVbNm12_34-56", + "timezone": "America/New_York", + "role": "Project Manager" + }, + { + "user_id": "bob_developer", + "email": "bob.smith@techcorp.com", + "name": "Bob Smith", + "given_name": "Bob", + "family_name": "Smith", + "static_token": "ya29.A0ARrdaM-Zx8Nw3Q4pVb6Ls9R1mT0cG2uF5yH7kJd8sA1Lq2.wErtYuIoPaSdFgHjKlZxCvBnM987_65-43", + "timezone": "America/Los_Angeles", + "role": "Senior Developer" + }, + { + "user_id": "carol_designer", + "email": "carol.white@techcorp.com", + "name": "Carol White", + "given_name": "Carol", + "family_name": "White", + "static_token": "ya29.A0ARrdaM-b7Hc5Vn2Qm8R1sT4pL0xY9wK3uF6jZ2eRc1.QaWsEdRfTgHyJuIkOlPzXcVbNmKjHgf_21-098", + "timezone": "Europe/London", + "role": "UX Designer" + }, + { + "user_id": "dave_sales", + "email": "dave.brown@techcorp.com", + "name": "Dave Brown", + "given_name": "Dave", + "family_name": "Brown", + "static_token": "ya29.A0ARrdaM-p3Lk9Vb6Qw2Zx8N1sT4mH7gF5yR0uJc2ePq.ZxCvBnMlKjHgFfDsaQwErTyUiOpAsDfGhJk_77-11", + "timezone": "America/Chicago", + "role": "Sales Director" + } + ] + + # Alice's calendars (Project Manager) + alice_calendars = [ + { + "calendar_id": "alice-primary", + "user_id": "alice_manager", + "summary": "Alice Johnson", + "description": "Primary calendar for Alice Johnson - Project Manager", + "time_zone": "America/New_York", + "is_primary": True, + "color_id": "1" + }, + { + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "summary": "Project Management", + "description": "Project meetings, deadlines, and milestones", + "time_zone": "America/New_York", + "is_primary": False, + "color_id": "7" + }, + { + "calendar_id": "alice-team", + "user_id": "alice_manager", + "summary": "Team Coordination", + "description": "Team meetings, 1-on-1s, and team events", + "time_zone": "America/New_York", + "is_primary": False, + "color_id": "11" + } + ] + + # Bob's calendars (Developer) + bob_calendars = [ + { + "calendar_id": "bob-primary", + "user_id": "bob_developer", + "summary": "Bob Smith", + "description": "Primary calendar for Bob Smith - Senior Developer", + "time_zone": "America/Los_Angeles", + "is_primary": True, + "color_id": "2" + }, + { + "calendar_id": "bob-development", + "user_id": "bob_developer", + "summary": "Development Schedule", + "description": "Sprint planning, code reviews, and development tasks", + "time_zone": "America/Los_Angeles", + "is_primary": False, + "color_id": "9" + }, + { + "calendar_id": "bob-personal", + "user_id": "bob_developer", + "summary": "Personal Time", + "description": "Personal appointments and time off", + "time_zone": "America/Los_Angeles", + "is_primary": False, + "color_id": "14" + } + ] + + # Carol's calendars (Designer) + carol_calendars = [ + { + "calendar_id": "carol-primary", + "user_id": "carol_designer", + "summary": "Carol White", + "description": "Primary calendar for Carol White - UX Designer", + "time_zone": "Europe/London", + "is_primary": True, + "color_id": "4" + }, + { + "calendar_id": "carol-design", + "user_id": "carol_designer", + "summary": "Design Work", + "description": "Design sessions, user research, and creative time", + "time_zone": "Europe/London", + "is_primary": False, + "color_id": "16" + } + ] + + # Dave's calendars (Sales) + dave_calendars = [ + { + "calendar_id": "dave-primary", + "user_id": "dave_sales", + "summary": "Dave Brown", + "description": "Primary calendar for Dave Brown - Sales Director", + "time_zone": "America/Chicago", + "is_primary": True, + "color_id": "6" + }, + { + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "summary": "Sales Activities", + "description": "Client meetings, sales calls, and deals", + "time_zone": "America/Chicago", + "is_primary": False, + "color_id": "23" + } + ] + + # Sample events for each user + base_date = datetime.now().replace(hour=9, minute=0, second=0, microsecond=0) + + alice_events = [ + { + "event_id": "alice-event-1", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "summary": "Sprint Planning Meeting", + "description": "Plan upcoming sprint with development team", + "location": "Conference Room A", + "start_datetime": base_date, + "end_datetime": base_date + timedelta(hours=1), + "status": "confirmed" + }, + { + "event_id": "alice-event-2", + "calendar_id": "alice-team", + "user_id": "alice_manager", + "summary": "1-on-1 with Bob", + "description": "Weekly check-in with Bob Smith", + "location": "Alice's Office", + "start_datetime": base_date + timedelta(days=1, hours=2), + "end_datetime": base_date + timedelta(days=1, hours=2, minutes=30), + "status": "confirmed" + }, + { + "event_id": "alice-event-3", + "calendar_id": "alice-primary", + "user_id": "alice_manager", + "summary": "Board Meeting", + "description": "Monthly board meeting presentation", + "location": "Executive Conference Room", + "start_datetime": base_date + timedelta(days=7), + "end_datetime": base_date + timedelta(days=7, hours=2), + "status": "confirmed" + } + ] + + bob_events = [ + { + "event_id": "bob-event-1", + "calendar_id": "bob-development", + "user_id": "bob_developer", + "summary": "Code Review Session", + "description": "Review pull requests from junior developers", + "location": "Development Room", + "start_datetime": base_date + timedelta(hours=3), + "end_datetime": base_date + timedelta(hours=4), + "status": "confirmed" + }, + { + "event_id": "bob-event-2", + "calendar_id": "bob-primary", + "user_id": "bob_developer", + "summary": "Architecture Discussion", + "description": "Discuss system architecture for new feature", + "location": "Video Call", + "start_datetime": base_date + timedelta(days=2), + "end_datetime": base_date + timedelta(days=2, hours=1, minutes=30), + "status": "confirmed" + }, + { + "event_id": "bob-event-3", + "calendar_id": "bob-personal", + "user_id": "bob_developer", + "summary": "Dentist Appointment", + "description": "Annual dental checkup", + "location": "Downtown Dental", + "start_datetime": base_date + timedelta(days=5, hours=5), + "end_datetime": base_date + timedelta(days=5, hours=6), + "status": "confirmed" + } + ] + + carol_events = [ + { + "event_id": "carol-event-1", + "calendar_id": "carol-design", + "user_id": "carol_designer", + "summary": "User Research Session", + "description": "Interview users about new feature requirements", + "location": "Research Lab", + "start_datetime": base_date + timedelta(hours=1), + "end_datetime": base_date + timedelta(hours=3), + "status": "confirmed" + }, + { + "event_id": "carol-event-2", + "calendar_id": "carol-primary", + "user_id": "carol_designer", + "summary": "Design Review", + "description": "Present mockups to stakeholders", + "location": "Design Studio", + "start_datetime": base_date + timedelta(days=3, hours=2), + "end_datetime": base_date + timedelta(days=3, hours=3, minutes=30), + "status": "confirmed" + } + ] + + dave_events = [ + { + "event_id": "dave-event-1", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "summary": "Client Demo", + "description": "Product demonstration for potential enterprise client", + "location": "Client Office", + "start_datetime": base_date + timedelta(days=1), + "end_datetime": base_date + timedelta(days=1, hours=2), + "status": "confirmed" + }, + { + "event_id": "dave-event-2", + "calendar_id": "dave-primary", + "user_id": "dave_sales", + "summary": "Sales Team Meeting", + "description": "Weekly sales team sync and pipeline review", + "location": "Sales Conference Room", + "start_datetime": base_date + timedelta(days=4), + "end_datetime": base_date + timedelta(days=4, hours=1), + "status": "confirmed" + } + ] + + settings = [ + {"id": "alice_timezone", "user_id": "alice_manager", "value": "America/New_York"}, + {"id": "bob_timezone", "user_id": "bob_developer", "value": "America/Los_Angeles"}, + {"id": "carol_timezone", "user_id": "carol_designer", "value": "Europe/London"}, + {"id": "dave_timezone", "user_id": "dave_sales", "value": "America/Chicago"} + ] + + scopes = [ + {"id": "scope-alice", "type": "user", "value": "alice.manager@techcorp.com"}, + {"id": "scope-bob", "type": "user", "value": "bob.smith@techcorp.com"}, + {"id": "scope-carol", "type": "user", "value": "carol.white@techcorp.com"}, + {"id": "scope-dave", "type": "user", "value": "dave.brown@techcorp.com"}, + {"id": "scope-group", "type": "group", "value": "product-team@techcorp.com"}, + {"id": "scope-domain", "type": "domain", "value": "techcorp.com"}, + {"id": "scope-public", "type": "default", "value":"public"} + ] + + acls = [ + # Alice's calendar ACLs (owner of her calendars) + { + "id": "acl-alice-primary", + "calendar_id": "alice-primary", + "user_id": "alice_manager", + "role": "owner", + "scope_id": "scope-alice", + "etag": "etag-alice-primary" + }, + { + "id": "acl-alice-projects", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "role": "owner", + "scope_id": "scope-alice", + "etag": "etag-alice-projects" + }, + { + "id": "acl-alice-team", + "calendar_id": "alice-team", + "user_id": "alice_manager", + "role": "owner", + "scope_id": "scope-alice", + "etag": "etag-alice-team" + }, + # Bob's calendar ACLs (owner of his calendars) + { + "id": "acl-bob-primary", + "calendar_id": "bob-primary", + "user_id": "bob_developer", + "role": "owner", + "scope_id": "scope-bob", + "etag": "etag-bob-primary" + }, + { + "id": "acl-bob-development", + "calendar_id": "bob-development", + "user_id": "bob_developer", + "role": "owner", + "scope_id": "scope-bob", + "etag": "etag-bob-development" + }, + { + "id": "acl-bob-personal", + "calendar_id": "bob-personal", + "user_id": "bob_developer", + "role": "owner", + "scope_id": "scope-bob", + "etag": "etag-bob-personal" + }, + # Carol's calendar ACLs (owner of her calendars) + { + "id": "acl-carol-primary", + "calendar_id": "carol-primary", + "user_id": "carol_designer", + "role": "owner", + "scope_id": "scope-carol", + "etag": "etag-carol-primary" + }, + { + "id": "acl-carol-design", + "calendar_id": "carol-design", + "user_id": "carol_designer", + "role": "owner", + "scope_id": "scope-carol", + "etag": "etag-carol-design" + }, + # Dave's calendar ACLs (owner of his calendars) + { + "id": "acl-dave-primary", + "calendar_id": "dave-primary", + "user_id": "dave_sales", + "role": "owner", + "scope_id": "scope-dave", + "etag": "etag-dave-primary" + }, + { + "id": "acl-dave-sales", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "role": "owner", + "scope_id": "scope-dave", + "etag": "etag-dave-sales" + }, + # Shared access examples + { + "id": "acl-shared-1", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "role": "writer", + "scope_id": "scope-bob", + "etag": "etag-shared-1" + }, + { + "id": "acl-shared-2", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "role": "reader", + "scope_id": "scope-carol", + "etag": "etag-shared-2" + } + ] + + return { + "users": users, + "calendars": alice_calendars + bob_calendars + carol_calendars + dave_calendars, + "events": alice_events + bob_events + carol_events + dave_events, + "settings": settings, + "scopes": scopes, + "acls": acls, + "description": "Multi-user sample data with 4 users (Alice-PM, Bob-Dev, Carol-Design, Dave-Sales) demonstrating isolated data per user" + } + + +def get_multi_user_sql(database_name: str = "multi_user_calendar") -> str: + """ + Generate SQL statements for multi-user sample data + """ + data = get_multi_user_sample_data() + + sql_statements = [] + + # Header + sql_statements.append(f"-- Multi-User Calendar Sample Data for {database_name}") + sql_statements.append(f"-- Generated on: {datetime.now().isoformat()}") + sql_statements.append("-- Contains sample data for 4 users with isolated calendars and events") + sql_statements.append("") + + # Users + sql_statements.append("-- Users") + 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") + + user_values = [] + for user in data["users"]: + # Escape single quotes by doubling them + name = user['name'].replace("'", "''") + given_name = user.get('given_name', '').replace("'", "''") + family_name = user.get('family_name', '').replace("'", "''") + + user_values.append( + f"('{user['user_id']}', '{user['email']}', '{name}', " + f"'{given_name}', '{family_name}', " + f"'{user['static_token']}', '{user['timezone']}', 1, 1, " + f"datetime('now'), datetime('now'))" + ) + + sql_statements.append(",\n".join(user_values) + ";") + sql_statements.append("") + + # Calendars + sql_statements.append("-- Calendars") + 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") + + calendar_values = [] + for calendar in data["calendars"]: + # Escape single quotes by doubling them + summary = calendar['summary'].replace("'", "''") + description = calendar.get('description', '').replace("'", "''") + + calendar_values.append( + f"('{calendar['calendar_id']}', '{calendar['user_id']}', '{summary}', " + f"'{description}', '{calendar['time_zone']}', " + f"{1 if calendar.get('is_primary') else 0}, '{calendar.get('color_id', '1')}', " + f"0, 1, 0, datetime('now'), datetime('now'))" + ) + + sql_statements.append(",\n".join(calendar_values) + ";") + sql_statements.append("") + + # Events + sql_statements = generate_enhanced_event_sql(sql_statements) + + # Colors (shared across users) + sql_statements.append("-- Google Calendar Colors (shared across all users)") + from data.google_colors import GOOGLE_CALENDAR_COLORS + + sql_statements.append("INSERT INTO colors (color_id, color_type, background, foreground, created_at, updated_at) VALUES") + + color_values = [] + for color in GOOGLE_CALENDAR_COLORS: + color_values.append( + f"('{color['color_id']}', '{color['color_type'].upper()}', '{color['background']}', " + f"'{color['foreground']}', datetime('now'), datetime('now'))" + ) + + sql_statements.append(",\n".join(color_values) + ";") + sql_statements.append("") + + # Settings + sql_statements.append("-- Calendar Settings") + sql_statements.append("INSERT INTO settings (id, user_id, value) VALUES") + setting_values = [] + for setting in data["settings"]: + setting_values.append( + f"('{setting['id']}', '{setting['user_id']}', '{setting['value']}')" + ) + sql_statements.append(",\n".join(setting_values) + ";\n") + sql_statements.append("") + + # Scopes + sql_statements.append("-- ACL Scopes") + sql_statements.append("INSERT INTO scopes (id, type, value) VALUES") + scope_values = [] + for scope in data["scopes"]: + scope_values.append( + f"('{scope['id']}', '{scope['type']}', '{scope['value']}')" + ) + sql_statements.append(",\n".join(scope_values) + ";\n") + sql_statements.append("") + + # ACLs + sql_statements.append("-- Access Control Rules") + sql_statements.append("INSERT INTO acls (id, calendar_id, user_id, role, scope_id, etag) VALUES") + acl_values = [] + for acl in data["acls"]: + acl_values.append( + f"('{acl['id']}', '{acl['calendar_id']}', '{acl['user_id']}', " + f"'{acl['role']}', '{acl['scope_id']}', '{acl['etag']}')" + ) + sql_statements.append(",\n".join(acl_values) + ";\n") + sql_statements.append("") + + sql_statements = get_watch_channel_sql(sql_statements) + + return "\n".join(sql_statements) diff --git a/envs/calendar_env/server/data/watch_channel_seed_data.py b/envs/calendar_env/server/data/watch_channel_seed_data.py new file mode 100644 index 000000000..2d327c2f4 --- /dev/null +++ b/envs/calendar_env/server/data/watch_channel_seed_data.py @@ -0,0 +1,245 @@ +""" +Watch Channel Seed Data for Calendar Application +""" + +from datetime import datetime, timedelta +from typing import List, Dict, Any +import json + + +def get_watch_channel_sample_data() -> Dict[str, Any]: + """ + Generate sample watch channel data for multi-user scenarios + """ + + # Base time for calculations + now = datetime.utcnow() + + # Sample watch channels demonstrating various scenarios + watch_channels = [ + # Alice's active watch channels + { + "id": "watch-alice-projects-001", + "resource_id": "acl-alice-projects", + "resource_uri": "/calendars/alice-projects/acl", + "resource_type": "acl", + "calendar_id": "alice-projects", + "user_id": "alice_manager", + "webhook_address": "https://techcorp.com/webhooks/alice/acl-notifications", + "webhook_token": "alice_token_abc123", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 14200}), + "created_at": now - timedelta(days=5), + "expires_at": now + timedelta(seconds=14200), + "last_notification_at": now - timedelta(hours=2), + "is_active": "true", + "notification_count": 12 + }, + { + "id": "watch-alice-team-002", + "resource_id": "acl-alice-team", + "resource_uri": "/calendars/alice-team/acl", + "resource_type": "acl", + "calendar_id": "alice-team", + "user_id": "alice_manager", + "webhook_address": "https://api.techcorp.com/calendar/notifications/team-acl", + "webhook_token": "team_secure_token_xyz789", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 11200}), + "created_at": now - timedelta(days=2), + "expires_at": now + timedelta(seconds=11200), + "last_notification_at": now - timedelta(hours=6), + "is_active": "true", + "notification_count": 5 + }, + + # Bob's watch channels + { + "id": "watch-bob-dev-001", + "resource_id": "acl-bob-development", + "resource_uri": "/calendars/bob-development/acl", + "resource_type": "acl", + "calendar_id": "bob-development", + "user_id": "bob_developer", + "webhook_address": "https://hooks.slack.com/services/T123/B456/dev-calendar-notifications", + "webhook_token": None, # No token for Slack webhook + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 9700}), + "created_at": now - timedelta(days=10), + "expires_at": now + timedelta(seconds=9700), + "last_notification_at": now - timedelta(days=1), + "is_active": "true", + "notification_count": 23 + }, + { + "id": "watch-bob-personal-002", + "resource_id": "acl-bob-personal", + "resource_uri": "/calendars/bob-personal/acl", + "resource_type": "acl", + "calendar_id": "bob-personal", + "user_id": "bob_developer", + "webhook_address": "https://api.example.com/personal/calendar/webhook", + "webhook_token": "personal_webhook_token_def456", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 24390}), + "created_at": now - timedelta(days=1), + "expires_at": now + timedelta(seconds=24390), + "last_notification_at": None, # No notifications sent yet + "is_active": "true", + "notification_count": 0 + }, + + # Carol's watch channels + { + "id": "watch-carol-design-001", + "resource_id": "acl-carol-design", + "resource_uri": "/calendars/carol-design/acl", + "resource_type": "acl", + "calendar_id": "carol-design", + "user_id": "carol_designer", + "webhook_address": "https://design-tools.techcorp.com/calendar/acl-updates", + "webhook_token": "design_team_token_ghi789", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 13200}), + "created_at": now - timedelta(days=7), + "expires_at": now + timedelta(seconds=13200), + "last_notification_at": now - timedelta(hours=12), + "is_active": "true", + "notification_count": 8 + }, + + # Dave's watch channels + { + "id": "watch-dave-sales-001", + "resource_id": "acl-dave-sales", + "resource_uri": "/calendars/dave-sales/acl", + "resource_type": "acl", + "calendar_id": "dave-sales", + "user_id": "dave_sales", + "webhook_address": "https://crm.techcorp.com/calendar/sales-acl-sync", + "webhook_token": "sales_crm_token_jkl012", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 15600}), + "created_at": now - timedelta(days=14), + "expires_at": now + timedelta(seconds=15600), + "last_notification_at": now - timedelta(hours=4), + "is_active": "true", + "notification_count": 31 + }, + + # Expired/Inactive watch channels (for testing cleanup) + { + "id": "watch-alice-expired-001", + "resource_id": "acl-alice-primary", + "resource_uri": "/calendars/alice-primary/acl", + "resource_type": "acl", + "calendar_id": "alice-primary", + "user_id": "alice_manager", + "webhook_address": "https://old-system.techcorp.com/webhooks/acl", + "webhook_token": "expired_token_mno345", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 2592000}), + "created_at": now - timedelta(days=35), + "expires_at": (now - timedelta(days=35)) + timedelta(seconds=2592000), # Expired 5 days ago + "last_notification_at": now - timedelta(days=6), + "is_active": "true", # Not yet cleaned up + "notification_count": 47 + }, + { + "id": "watch-bob-stopped-001", + "resource_id": "acl-bob-primary", + "resource_uri": "/calendars/bob-primary/acl", + "resource_type": "acl", + "calendar_id": "bob-primary", + "user_id": "bob_developer", + "webhook_address": "https://temp-webhook.example.com/test", + "webhook_token": "temp_token_pqr678", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 12000}), + "created_at": now - timedelta(days=8), + "expires_at": now + timedelta(seconds=12000), + "last_notification_at": now - timedelta(days=3), + "is_active": "false", # Manually stopped + "notification_count": 15 + }, + + # Cross-calendar watch example (someone watching another user's shared calendar) + { + "id": "watch-bob-alice-shared-001", + "resource_id": "acl-alice-projects", + "resource_uri": "/calendars/alice-projects/acl", + "resource_type": "acl", + "calendar_id": "alice-projects", # Alice's calendar + "user_id": "bob_developer", # But Bob is watching it + "webhook_address": "https://api.example.com/shared-calendar/acl-watch", + "webhook_token": "shared_access_token_stu901", + "webhook_type": "web_hook", + "params": json.dumps({"ttl": 16800}), + "created_at": now - timedelta(days=3), + "expires_at": now + timedelta(seconds=16800), + "last_notification_at": now - timedelta(hours=18), + "is_active": "true", + "notification_count": 3 + } + ] + + return { + "watch_channels": watch_channels, + "description": "Sample watch channel data demonstrating various ACL notification scenarios across multiple users" + } + + +def get_watch_channel_sql(sql_statements) -> str: + """ + Generate SQL statements for watch channel sample data + """ + data = get_watch_channel_sample_data() + + + # Header + sql_statements.append(f"-- Generated on: {datetime.now().isoformat()}") + sql_statements.append("-- Contains sample watch channel data for ACL notifications") + sql_statements.append("") + + # Watch Channels + sql_statements.append("-- Watch Channels for ACL Notifications") + sql_statements.append("INSERT INTO watch_channels (") + sql_statements.append(" id, resource_id, resource_uri, resource_type, calendar_id, user_id,") + sql_statements.append(" webhook_address, webhook_token, webhook_type, params,") + sql_statements.append(" created_at, expires_at, last_notification_at, is_active, notification_count") + sql_statements.append(") VALUES") + + channel_values = [] + for channel in data["watch_channels"]: + # Handle nullable fields + webhook_token = f"'{channel['webhook_token']}'" if channel['webhook_token'] else "NULL" + params = f"'{channel['params']}'" if channel['params'] else "NULL" + last_notification = f"'{channel['last_notification_at'].isoformat()}'" if channel['last_notification_at'] else "NULL" + expires_at = f"'{channel['expires_at'].isoformat()}'" if channel['expires_at'] else "NULL" + + # Escape single quotes in webhook addresses + webhook_address = channel['webhook_address'].replace("'", "''") + + channel_values.append( + f"('{channel['id']}', '{channel['resource_id']}', '{channel['resource_uri']}', " + f"'{channel['resource_type']}', '{channel['calendar_id']}', '{channel['user_id']}', " + f"'{webhook_address}', {webhook_token}, '{channel['webhook_type']}', {params}, " + f"'{channel['created_at'].isoformat()}', {expires_at}, {last_notification}, " + f"'{channel['is_active']}', {channel['notification_count']})" + ) + + sql_statements.append(",\n".join(channel_values) + ";") + sql_statements.append("") + + # Add some comments about the data + sql_statements.append("-- Watch Channel Data Summary:") + sql_statements.append("-- - Alice: 2 active channels + 1 expired") + sql_statements.append("-- - Bob: 2 active channels + 1 stopped + 1 shared calendar watch") + sql_statements.append("-- - Carol: 1 active channel") + sql_statements.append("-- - Dave: 1 active channel") + sql_statements.append("-- - Total: 6 active, 1 expired, 1 manually stopped") + sql_statements.append("-- - Demonstrates various webhook URLs (Slack, CRM, internal APIs)") + sql_statements.append("-- - Shows different notification patterns and usage levels") + sql_statements.append("") + + return sql_statements diff --git a/envs/calendar_env/server/database/__init__.py b/envs/calendar_env/server/database/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/database/base_manager.py b/envs/calendar_env/server/database/base_manager.py new file mode 100644 index 000000000..c34d1f262 --- /dev/null +++ b/envs/calendar_env/server/database/base_manager.py @@ -0,0 +1,112 @@ +""" +Base Manager - Common CRUD operations for all Calendar services +""" + +import sqlite3 +import logging +from typing import Dict, Optional, List, Any +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class BaseManager: + """Base manager for common database operations""" + + def __init__(self, db_path: str): + self.db_path = db_path + + def execute_query(self, query: str, params: tuple = ()) -> List[Dict]: + """Execute a SELECT query and return results""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + try: + cursor = conn.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + def execute_insert(self, query: str, params: tuple = ()) -> int: + """Execute an INSERT query and return the last row ID""" + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.execute(query, params) + conn.commit() + return cursor.lastrowid + except sqlite3.IntegrityError as e: + logger.error(f"Database integrity error: {e}") + raise ValueError(f"Database constraint violation: {e}") + finally: + conn.close() + + def execute_update(self, query: str, params: tuple = ()) -> int: + """Execute an UPDATE query and return the number of affected rows""" + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.execute(query, params) + conn.commit() + return cursor.rowcount + finally: + conn.close() + + def execute_delete(self, query: str, params: tuple = ()) -> int: + """Execute a DELETE query and return the number of affected rows""" + conn = sqlite3.connect(self.db_path) + try: + cursor = conn.execute(query, params) + conn.commit() + return cursor.rowcount + finally: + conn.close() + + def get_by_id(self, table: str, record_id: int) -> Optional[Dict]: + """Get a record by ID from any table""" + query = f"SELECT * FROM {table} WHERE id = ?" + results = self.execute_query(query, (record_id,)) + return results[0] if results else None + + def get_all(self, table: str, limit: int = 100, offset: int = 0, order_by: str = "id DESC") -> List[Dict]: + """Get all records from a table with pagination""" + query = f"SELECT * FROM {table} ORDER BY {order_by} LIMIT ? OFFSET ?" + return self.execute_query(query, (limit, offset)) + + def count_records(self, table: str, where_clause: str = "", params: tuple = ()) -> int: + """Count records in a table""" + query = f"SELECT COUNT(*) as count FROM {table}" + if where_clause: + query += f" WHERE {where_clause}" + result = self.execute_query(query, params) + return result[0]["count"] if result else 0 + + def update_record(self, table: str, record_id: int, updates: Dict[str, Any]) -> bool: + """Update a record with given field values""" + if not updates: + return False + + # Add updated_at timestamp if the table has this column + updates["updated_at"] = datetime.now().isoformat() + + set_clauses = [] + params = [] + + for field, value in updates.items(): + set_clauses.append(f"{field} = ?") + params.append(value) + + params.append(record_id) + + query = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE id = ?" + affected_rows = self.execute_update(query, tuple(params)) + return affected_rows > 0 + + def delete_record(self, table: str, record_id: int) -> bool: + """Delete a record by ID""" + query = f"DELETE FROM {table} WHERE id = ?" + affected_rows = self.execute_delete(query, (record_id,)) + return affected_rows > 0 + + def table_exists(self, table_name: str) -> bool: + """Check if a table exists""" + query = "SELECT name FROM sqlite_master WHERE type='table' AND name=?" + result = self.execute_query(query, (table_name,)) + return len(result) > 0 \ No newline at end of file diff --git a/envs/calendar_env/server/database/managers/__init__.py b/envs/calendar_env/server/database/managers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/database/managers/acl_manager.py b/envs/calendar_env/server/database/managers/acl_manager.py new file mode 100644 index 000000000..7c123ed0f --- /dev/null +++ b/envs/calendar_env/server/database/managers/acl_manager.py @@ -0,0 +1,595 @@ +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, desc +from database.models.acl import ACLs, Scope, AclRole +from database.models.watch_channel import WatchChannel +from schemas.acl import ACLRuleInput, PatchACLRuleInput, Channel, ACLListResponse, ACLRule, ScopeInput, ScopeOutput +from services.notification_service import get_notification_service +from uuid import uuid4 +from datetime import datetime, timedelta +from typing import Dict, Any, Optional +import logging +import json +import uuid +import base64 +from database.models.calendar import Calendar +from database.models.user import User + +logger = logging.getLogger(__name__) + + +class ACLManager: + def __init__(self, db: Session, user_id: str): + self.db = db + self.user_id = user_id + + def validate_calendar_id(self, calendar_id, user_id): + calendar = self.db.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id, + Calendar.deleted == False + ).first() + if not calendar: + return False + return True + + def list_rules(self, calendar_id: str, max_results: int = 100, + page_token: Optional[str] = None, show_deleted: bool = False, + sync_token: Optional[str] = None) -> ACLListResponse: + """ + List ACL rules for a calendar with pagination and filtering support. + + Args: + calendar_id: The calendar ID + max_results: Maximum number of entries per page (1-250, default 100) + page_token: Token for pagination continuation + show_deleted: Whether to include deleted ACLs (role == "none") + sync_token: Token for incremental synchronization + + Returns: + ACLListResponse with items and pagination tokens + """ + try: + # Handle sync token for incremental synchronization + if sync_token: + return self._handle_sync_request(calendar_id, sync_token, max_results, show_deleted) + + # Base query + query = self.db.query(ACLs).filter( + ACLs.calendar_id == calendar_id, + ACLs.user_id == self.user_id + ) + + # Filter deleted ACLs if not requested + if not show_deleted: + query = query.filter(ACLs.role != AclRole.none) + + # Order by created_at for consistent pagination + query = query.order_by(ACLs.created_at, ACLs.id) + + # Handle pagination + offset = 0 + if page_token: + try: + offset = int(base64.b64decode(page_token).decode('utf-8')) + except (ValueError, TypeError): + raise ValueError("Invalid pageToken") + + # Get one extra item to determine if there's a next page + items = query.offset(offset).limit(max_results + 1).all() + + # Determine if there's a next page + has_next_page = len(items) > max_results + if has_next_page: + items = items[:max_results] # Remove the extra item + next_page_token = base64.b64encode(str(offset + max_results).encode('utf-8')).decode('utf-8') + else: + next_page_token = None + + # Generate next sync token + latest_updated = self.db.query(ACLs.updated_at).filter( + ACLs.calendar_id == calendar_id, + ACLs.user_id == self.user_id + ).order_by(desc(ACLs.updated_at)).first() + + next_sync_token = None + if latest_updated and latest_updated[0]: + sync_data = { + 'calendar_id': calendar_id, + 'timestamp': latest_updated[0].isoformat() + } + next_sync_token = base64.b64encode(json.dumps(sync_data).encode('utf-8')).decode('utf-8') + + # Generate collection etag + etag = f'"{uuid4()}"' + + # Convert database models to schema models + acl_rules = [] + for item in items: + if item.scope.type == "default": + scope = ScopeOutput(type=item.scope.type) + else: + scope = ScopeOutput( + type=item.scope.type, + value=item.scope.value + ) + acl_rule = ACLRule( + id=item.id, + calendar_id=item.calendar_id, + user_id=item.user_id, + role=item.role, + etag=item.etag, + scope=scope + ) + acl_rules.append(acl_rule) + + return ACLListResponse( + etag=etag, + items=acl_rules, + nextPageToken=next_page_token, + nextSyncToken=next_sync_token + ) + + except Exception as e: + logger.error(f"Error listing ACL rules: {e}") + raise + + def _handle_sync_request(self, calendar_id: str, sync_token: str, + max_results: int, show_deleted: bool) -> ACLListResponse: + """ + Handle incremental synchronization request. + + Args: + calendar_id: The calendar ID + sync_token: The sync token from previous request + max_results: Maximum number of entries per page + show_deleted: Whether to include deleted ACLs + + Returns: + ACLListResponse with changes since the sync token + """ + try: + # Decode sync token + sync_data = json.loads(base64.b64decode(sync_token).decode('utf-8')) + last_sync_time = datetime.fromisoformat(sync_data['timestamp']) + + # Verify calendar ID matches + if sync_data.get('calendar_id') != calendar_id: + raise ValueError("Sync token calendar ID mismatch") + + # Check if sync token is too old (expired) + if (datetime.utcnow() - last_sync_time).days > 7: # 7 days expiration + from fastapi import HTTPException + raise HTTPException(status_code=410, detail="Sync token expired") + + # Query for changes since last sync + query = self.db.query(ACLs).filter( + ACLs.calendar_id == calendar_id, + ACLs.user_id == self.user_id, + ACLs.updated_at > last_sync_time + ) + + # Always include deleted ACLs in sync requests (Google API behavior) + # Order by updated_at for consistent results + query = query.order_by(ACLs.updated_at, ACLs.id) + + # Apply limit + items = query.limit(max_results + 1).all() + + # Determine if there's more data + has_more = len(items) > max_results + if has_more: + items = items[:max_results] + # For sync requests, we don't use page tokens, just return what we have + # Client should make another sync request with updated sync token + + # Generate new sync token based on latest item + next_sync_token = None + if items: + latest_time = max(item.updated_at for item in items) + sync_data = { + 'calendar_id': calendar_id, + 'timestamp': latest_time.isoformat() + } + next_sync_token = base64.b64encode(json.dumps(sync_data).encode('utf-8')).decode('utf-8') + else: + # No changes, return same sync token + next_sync_token = sync_token + + # Generate collection etag + etag = f'"{uuid4()}"' + + # Convert database models to schema models + acl_rules = [] + for item in items: + scope = ScopeInput( + type=item.scope.type, + value=item.scope.value + ) + acl_rule = ACLRule( + id=item.id, + calendar_id=item.calendar_id, + user_id=item.user_id, + role=item.role, + etag=item.etag, + scope=scope + ) + acl_rules.append(acl_rule) + + return ACLListResponse( + etag=etag, + items=acl_rules, + nextPageToken=None, # No page tokens in sync mode + nextSyncToken=next_sync_token + ) + + except json.JSONDecodeError: + raise ValueError("Invalid sync token format") + except Exception as e: + logger.error(f"Error handling sync request: {e}") + raise + + def get_rule(self, calendar_id: str, rule_id: str): + """ + Retrieve a specific ACL rule by calendar ID and rule ID (must be owned). + """ + return self.db.query(ACLs).filter_by( + id=rule_id, + calendar_id=calendar_id, + user_id=self.user_id + ).first() + + def insert_rule(self, calendar_id: str, rule: ACLRuleInput, send_notifications: bool = True): + """ + Insert a new ACL rule after validating scope existence. + + Args: + calendar_id: The calendar ID + rule: The ACL rule input data + send_notifications: Whether to send notifications about the calendar sharing change (default: True) + + Returns the inserted ACL rule. + """ + # Look up scope (must exist) + if rule.scope.type == "default": + scope = self.db.query(Scope).filter(Scope.type == rule.scope.type).first() + else: + if rule.scope.value is None: + scope = self.db.query(Scope).filter(Scope.type == rule.scope.type).first() + else: + scope = ( + self.db.query(Scope) + .filter(Scope.type == rule.scope.type, Scope.value == rule.scope.value) + .first() + ) + + if not scope: + raise ValueError(f"Scope ({rule.scope.type}, {rule.scope.value}) not found") + + # Create ACL rule + rule_id = f"{uuid4()}" + db_rule = ACLs( + id=rule_id, + calendar_id=calendar_id, + user_id=self.user_id, + role=rule.role, + scope_id=scope.id, + etag=f'"{uuid4()}"', + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + self.db.add(db_rule) + self.db.commit() + self.db.refresh(db_rule) + + # Send notification for ACL rule insertion if notifications are enabled + if send_notifications: + self._send_acl_notification(calendar_id, "insert", { + "id": db_rule.id, + "calendar_id": db_rule.calendar_id, + "user_id": db_rule.user_id, + "role": db_rule.role.value, + "scope": scope.as_dict(), + "etag": db_rule.etag + }) + + response = { + "kind": "calendar#aclRule", + "etag": db_rule.etag, + "id": db_rule.id, + "scope":{}, + "role": db_rule.role.value + } + scope_dict = scope.as_dict() + response["scope"]["type"] = scope_dict.get("type") + if scope_dict.get("value") != "public": + response["scope"]["value"] = scope_dict.get("value") + return response + + def update_rule(self, calendar_id: str, rule_id: str, rule: ACLRuleInput, send_notifications: bool = True): + """ + Fully replace an existing ACL rule's role and scope. + + Returns the updated rule or None if not found. + """ + db_rule = self.get_rule(calendar_id, rule_id) + + if not db_rule: + return None + + if rule.role is not None: + db_rule.role = rule.role + # Update scope + if rule.scope is not None: + if db_rule.scope: + db_rule.scope.type = rule.scope.type + if rule.scope.value is not None and rule.scope.type != "default": + if rule.scope.type in ["user", "group"]: + # Validate whether value contains valid email address + user = self.db.query(User).filter(User.email == rule.scope.value, User.is_active == True).first() + if user is None: + raise ValueError("Invalid data in 'value field'. Please enter an existing email id in 'value' field") + db_rule.scope.value = rule.scope.value + else: + raise ValueError("ACL rule has no associated scope object to update.") + db_rule.updated_at = datetime.utcnow() + self.db.commit() + self.db.refresh(db_rule) + + # Send notification for ACL rule update if notifications are enabled + if send_notifications: + self._send_acl_notification(calendar_id, "update", { + "id": db_rule.id, + "calendar_id": db_rule.calendar_id, + "user_id": db_rule.user_id, + "role": db_rule.role.value, + "scope": db_rule.scope.as_dict(), + "etag": db_rule.etag + }) + + response = { + "kind": "calendar#aclRule", + "etag": db_rule.etag, + "id": db_rule.id, + "scope":{}, + "role": db_rule.role.value + } + scope_dict = db_rule.scope.as_dict() + response["scope"]["type"] = scope_dict.get("type") + if scope_dict.get("value") != "public": + response["scope"]["value"] = scope_dict.get("value") + return response + + def patch_rule(self, calendar_id: str, rule_id: str, rule: PatchACLRuleInput, send_notifications: bool = True): + """ + Partially update an ACL rule's role or scope if provided. + + Returns the updated rule or None if not found. + """ + db_rule = self.get_rule(calendar_id, rule_id) + if not db_rule: + return None + + if rule.role is not None: + db_rule.role = rule.role + + if rule.scope is not None: + # Patch the related Scope object, not as a dict + if db_rule.scope: + db_rule.scope.type = rule.scope.type + if rule.scope.value is not None and rule.scope.type != "default": + if rule.scope.type in ["user", "group"]: + # Validate whether value contains valid email address + user = self.db.query(User).filter(User.email == rule.scope.value, User.is_active == True).first() + if user is None: + raise ValueError("Invalid data in 'value field'. Please enter an existing email id in 'value' field") + db_rule.scope.value = rule.scope.value + else: + raise ValueError("ACL rule has no associated scope object to patch.") + + db_rule.updated_at = datetime.utcnow() + self.db.commit() + self.db.refresh(db_rule) + + # Send notification for ACL rule patch if notifications are enabled + if send_notifications: + self._send_acl_notification(calendar_id, "update", { + "id": db_rule.id, + "calendar_id": db_rule.calendar_id, + "user_id": db_rule.user_id, + "role": db_rule.role.value, + "scope": db_rule.scope.as_dict(), + "etag": db_rule.etag + }) + + response = { + "kind": "calendar#aclRule", + "etag": db_rule.etag, + "id": db_rule.id, + "scope":{}, + "role": db_rule.role.value + } + scope_dict = db_rule.scope.as_dict() + response["scope"]["type"] = scope_dict.get("type") + if scope_dict.get("value") != "public": + response["scope"]["value"] = scope_dict.get("value") + return response + + def delete_rule(self, calendar_id: str, rule_id: str) -> bool: + """ + Delete an ACL rule by ID and calendar ID. Only the calendar owner can delete ACLs. + + Returns: + True if deleted, False if rule not found. + Raises: + Exception if DB operation fails. + """ + session = self.db + try: + from database.models import Calendar # to resolve join dependency + rule = session.query(ACLs).join(Calendar).filter( + ACLs.id == rule_id, + ACLs.calendar_id == calendar_id, + Calendar.user_id == self.user_id + ).first() + + if not rule: + return False + + # Capture rule data before deletion for notification + rule_data = { + "id": rule.id, + "calendar_id": rule.calendar_id, + "user_id": rule.user_id, + "role": rule.role.value, + "scope": rule.scope.as_dict() if rule.scope else {}, + "etag": rule.etag + } + + session.delete(rule) + session.commit() + + # Send notification for ACL rule deletion + self._send_acl_notification(calendar_id, "delete", rule_data) + + return True + + except Exception as e: + session.rollback() + raise + + def watch_acl( + self, + user_id:str, + calendar_id: str, + watch_request: Dict[str, Any] + ) -> Channel: + """ + Set up watch notifications for ACL changes. + + POST /calendars/{calendarId}/acl/watch + """ + try: + session = self.db + + # Generate unique resource ID for events watch + resource_id = f"acl-{calendar_id}-{uuid.uuid4().hex[:8]}" + resource_uri = f"/calendars/{calendar_id}/acl" + # Validate that the user has access to the calendar + + # Default expiration: 24 hours from now + expires_at = datetime.utcnow() + timedelta(hours=24) + + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first(): + raise ValueError(f"Channel with Id {watch_request.id} already exists") + + # Create watch channel record + watch_channel = WatchChannel( + id=watch_request.id, + resource_id=resource_id, + resource_uri=resource_uri, + resource_type="acl", + calendar_id=calendar_id, + user_id=user_id, + webhook_address=watch_request.address, + webhook_token=watch_request.token, + webhook_type=watch_request.type, + params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None, + created_at=datetime.utcnow(), + expires_at=expires_at, + is_active="true", + notification_count=0 + ) + + # Save to database + session.add(watch_channel) + session.commit() + + logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}") + + + + # Create response channel + channel = Channel( + id=watch_channel.id, + resourceId=resource_id, + resourceUri=watch_channel.resource_uri, + token=watch_channel.webhook_token, + expiration=watch_channel.expires_at.isoformat() + "Z" if watch_channel.expires_at else None + ) + + logger.info(f"Set up watch channel {watch_channel.id} for ACL changes in calendar {calendar_id}") + return channel + + except Exception as e: + logger.error(f"Error setting up ACL watch for calendar {calendar_id}: {e}") + self.db.rollback() + raise + + def cleanup_expired_channels(self) -> int: + """ + Clean up expired watch channels for this user + + Returns: + Number of channels cleaned up + """ + try: + current_time = datetime.utcnow() + + # Find expired channels for this user + expired_channels = self.db.query(WatchChannel).filter( + WatchChannel.user_id == self.user_id, + WatchChannel.expires_at < current_time, + WatchChannel.is_active == "true" + ).all() + + cleanup_count = 0 + for channel in expired_channels: + channel.is_active = "false" + cleanup_count += 1 + + if cleanup_count > 0: + self.db.commit() + logger.info(f"Cleaned up {cleanup_count} expired watch channels for user {self.user_id}") + + return cleanup_count + + except Exception as e: + logger.error(f"Error cleaning up expired channels: {e}") + self.db.rollback() + return 0 + + def _send_acl_notification(self, calendar_id: str, change_type: str, acl_data: Dict[str, Any]): + """ + Send ACL change notification to all active watch channels for the calendar + + Args: + calendar_id: The calendar ID + change_type: Type of change ("insert", "update", "delete") + acl_data: The ACL rule data + """ + try: + notification_service = get_notification_service(self.db) + notifications_sent = notification_service.notify_acl_change( + calendar_id, + change_type, + acl_data + ) + + if notifications_sent > 0: + logger.debug(f"Sent {notifications_sent} notifications for ACL {change_type} in calendar {calendar_id}") + + except Exception as e: + logger.error(f"Error sending ACL notification: {e}") + # Don't raise the exception as notification failure shouldn't break the main operation + + +def get_acl_manager(db: Session, user_id: str) -> ACLManager: + return ACLManager(db, user_id) diff --git a/envs/calendar_env/server/database/managers/calendar_list_manager.py b/envs/calendar_env/server/database/managers/calendar_list_manager.py new file mode 100644 index 000000000..9cc3fca59 --- /dev/null +++ b/envs/calendar_env/server/database/managers/calendar_list_manager.py @@ -0,0 +1,626 @@ +""" +Calendar List Manager - Database operations for calendar list management using SQLAlchemy +Manages user-specific calendar settings and access in a database-per-user architecture +""" + +import logging +import uuid +import json +import base64 +from typing import Dict, List, Optional, Tuple +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from database.models import Calendar, Event, User +from database.models.acl import ACLs, Scope +from database.models.watch_channel import WatchChannel +from database.session_utils import get_session, init_database +from schemas.calendar_list import WatchRequest +from datetime import timedelta +from schemas.settings import Channel +from fastapi import HTTPException, status + +logger = logging.getLogger(__name__) + +# Allowed values aligning with Google Calendar API v3 +ALLOWED_REMINDER_METHODS = {"email", "popup"} +ALLOWED_NOTIFICATION_METHODS = {"email"} +ALLOWED_NOTIFICATION_TYPES = { + "eventCreation", + "eventChange", + "eventCancellation", + "eventResponse", + "agenda", +} + + +class CalendarListManager: + """Manager for calendar list database operations using SQLAlchemy""" + + def __init__(self, database_id: str): + self.database_id = database_id + # Initialize database on first use + init_database(database_id) + + def list_calendar_entries( + self, + user_id: str, + max_results: Optional[int] = None, + min_access_role: Optional[str] = None, + show_deleted: Optional[bool] = False, + show_hidden: Optional[bool] = False, + page_token: Optional[str] = None, + sync_token: Optional[str] = None + ) -> Tuple[List[Dict], Optional[str], Optional[str]]: + """List all calendar entries in user's calendar list + + Notes: + - If max_results is None, return all (subject to other filters) + - If max_results is 0, return an empty list (explicit zero results) + - If max_results > 0, limit the results accordingly + - min_access_role filters calendars based on user's minimum access role + - page_token specifies which result page to return + - sync_token enables incremental synchronization + + Returns: + - Tuple of (calendar_entries, next_page_token, next_sync_token) + """ + session = get_session(self.database_id) + try: + # Handle sync token for incremental synchronization + sync_timestamp = None + if sync_token: + try: + sync_timestamp = self._decode_sync_token(sync_token) + except (ValueError, TypeError) as e: + logger.error(f"Invalid sync token: {sync_token}, error: {e}") + raise ValueError(f"Invalid sync token. Please perform full synchronization.") + + # Parse page token to get offset + offset = 0 + if page_token: + try: + offset = self._decode_page_token(page_token) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid page token: {page_token}, error: {e}") + offset = 0 + + query = session.query(Calendar).filter(Calendar.user_id == user_id) + + # Apply sync token filtering for incremental sync + if sync_token and sync_timestamp: + # For incremental sync, return entries modified since sync timestamp + query = query.filter(Calendar.updated_at > sync_timestamp) + # Include deleted and hidden entries when using sync token + show_deleted = True + show_hidden = True + else: + # Filter deleted calendars unless specifically requested + if not show_deleted: + query = query.filter(Calendar.deleted.is_(False)) + + # Order consistently for pagination (by calendar_id for deterministic results) + query = query.order_by(Calendar.calendar_id) + + # Apply offset for pagination + if offset > 0: + query = query.offset(offset) + + # Apply limit if specified (support 0 to return empty set) + # For pagination, we fetch max_results + 1 to determine if there are more pages + fetch_limit = None + if max_results is not None: + # Negative values are treated as 0 (empty set) + limit_value = max(0, int(max_results)) + if limit_value == 0: + return [], None, None + fetch_limit = limit_value + 1 # Fetch one extra to check for next page + query = query.limit(fetch_limit) + + calendars = query.all() + + # Define access role hierarchy for filtering + role_hierarchy = { + "freeBusyReader": 1, + "reader": 2, + "writer": 3, + "owner": 4 + } + + min_role_level = role_hierarchy.get(min_access_role, 0) if min_access_role else 0 + + result = [] + for calendar in calendars: + # Check if user has sufficient access role if min_access_role is specified + if min_access_role: + user_access_role = self._get_user_access_role(user_id, calendar) + user_role_level = role_hierarchy.get(user_access_role, 0) + + # Skip calendars where user doesn't meet minimum access role + if user_role_level < min_role_level: + continue + + entry = self._format_calendar_list_entry(calendar, show_hidden, user_id) + if entry: + result.append(entry) + + # Determine if there are more pages + next_page_token = None + if max_results is not None and len(result) > max_results: + # Remove the extra item and generate next page token + result = result[:max_results] + next_offset = offset + max_results + next_page_token = self._encode_page_token(next_offset) + + # Generate next sync token for incremental sync + next_sync_token = None + if len(result) > 0: + # Generate sync token based on current timestamp + next_sync_token = self._encode_sync_token(datetime.utcnow()) + + return result, next_page_token, next_sync_token + + except Exception as e: + logger.error(f"Error listing calendar entries: {e}") + raise + finally: + session.close() + + def get_calendar_entry(self, user_id: str, calendar_id: str) -> Optional[Dict]: + """Get a specific calendar entry from user's calendar list""" + session = get_session(self.database_id) + try: + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar or calendar.deleted: + return None + + return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id) + + except Exception as e: + logger.error(f"Error getting calendar entry {calendar_id}: {e}") + raise + finally: + session.close() + + def insert_calendar_entry(self, user_id: str, calendar_id: str, entry_data: Dict) -> Optional[Dict]: + """Insert an existing calendar into user's calendar list""" + session = get_session(self.database_id) + try: + # Check if calendar exists + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar: + # If calendar doesn't exist, we could create it or return None + # For this implementation, we'll return None (calendar must exist first) + return None + + # If calendar is soft-deleted from list, restore it on insert (Google API semantics) + if calendar.deleted: + calendar.deleted = False + + # Update calendar with list-specific settings + if "summaryOverride" in entry_data: + calendar.summary_override = entry_data["summaryOverride"] + if "colorId" in entry_data: + calendar.color_id = entry_data["colorId"] + if "backgroundColor" in entry_data: + calendar.background_color = entry_data["backgroundColor"] + if "foregroundColor" in entry_data: + calendar.foreground_color = entry_data["foregroundColor"] + if "hidden" in entry_data: + calendar.hidden = entry_data["hidden"] + if "selected" in entry_data: + calendar.selected = entry_data["selected"] + if "defaultReminders" in entry_data: + calendar.default_reminders = json.dumps(entry_data["defaultReminders"]) if entry_data["defaultReminders"] else None + if "notificationSettings" in entry_data: + calendar.notification_settings = json.dumps(entry_data["notificationSettings"]) if entry_data["notificationSettings"] else None + + session.commit() + + return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id) + + except Exception as e: + session.rollback() + logger.error(f"Error inserting calendar entry {calendar_id}: {e}") + raise + finally: + session.close() + + def update_calendar_entry(self, user_id: str, calendar_id: str, entry_data: Dict, is_patch: bool = True) -> Optional[Dict]: + """Update a calendar entry in user's calendar list""" + session = get_session(self.database_id) + try: + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar or calendar.deleted: + return None + + # Update fields based on entry data + if "summaryOverride" in entry_data: + calendar.summary_override = entry_data["summaryOverride"] + elif not is_patch: + # For PUT requests, clear the field if not provided + calendar.summary_override = None + + if "colorId" in entry_data: + calendar.color_id = entry_data["colorId"] + elif not is_patch: + calendar.color_id = None + + if "backgroundColor" in entry_data: + calendar.background_color = entry_data["backgroundColor"] + elif not is_patch: + calendar.background_color = None + + if "foregroundColor" in entry_data: + calendar.foreground_color = entry_data["foregroundColor"] + elif not is_patch: + calendar.foreground_color = None + + if "hidden" in entry_data: + calendar.hidden = entry_data["hidden"] + elif not is_patch: + # For PUT (full update), set default for NOT NULL fields when not provided + calendar.hidden = False + + if "selected" in entry_data: + calendar.selected = entry_data["selected"] + elif not is_patch: + # For PUT (full update), set default for NOT NULL fields when not provided + calendar.selected = True + + if "defaultReminders" in entry_data: + calendar.default_reminders = json.dumps(entry_data["defaultReminders"]) if entry_data["defaultReminders"] else None + elif not is_patch: + calendar.default_reminders = None + + if "notificationSettings" in entry_data: + calendar.notification_settings = json.dumps(entry_data["notificationSettings"]) if entry_data["notificationSettings"] else None + elif not is_patch: + calendar.notification_settings = None + + if "conferenceProperties" in entry_data: + calendar.conference_properties = json.dumps(entry_data["conferenceProperties"]) if entry_data["conferenceProperties"] else None + + session.commit() + + return self._format_calendar_list_entry(calendar, show_hidden=True, user_id=user_id) + + except Exception as e: + session.rollback() + logger.error(f"Error updating calendar entry {calendar_id}: {e}") + raise + finally: + session.close() + + def delete_calendar_entry(self, user_id: str, calendar_id: str) -> bool: + """Remove a calendar from user's calendar list""" + session = get_session(self.database_id) + try: + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar or calendar.deleted: + return False + + # For primary calendar, we can't remove it from the list + if calendar.is_primary: + raise ValueError("Cannot remove primary calendar from calendar list") + + # Mark as deleted (soft delete for calendar list) + calendar.deleted = True + session.commit() + return True + + except Exception as e: + session.rollback() + logger.error(f"Error deleting calendar entry {calendar_id}: {e}") + raise + finally: + session.close() + + def _has_acl_role(self, user_id: str, calendar: Calendar, allowed_roles: List[str]) -> bool: + """Check if user has required ACL permissions for calendar operations""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + if not user: + logger.warning(f"No user found with ID: {user_id}") + return False + + acls = ( + session.query(ACLs) + .join(Scope, ACLs.scope_id == Scope.id) + .filter( + ACLs.calendar_id == calendar.calendar_id, + Scope.type == "user", + Scope.value == user.email + ) + .all() + ) + + if not acls: + logger.warning(f"No ACL found for user {user.email} on calendar {calendar.calendar_id}") + return False + + for acl in acls: + if acl.role.value in allowed_roles: + return True + + logger.warning(f"User {user.email} has ACLs but lacks required roles: {allowed_roles}") + return False + finally: + session.close() + + def check_calendar_acl_permissions(self, user_id: str, calendar_id: str, allowed_roles: List[str]) -> Calendar: + """Check if user has required ACL permissions for calendar operations and return calendar""" + session = get_session(self.database_id) + try: + calendar = session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first() + if not calendar: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist") + + if not self._has_acl_role(user_id, calendar, allowed_roles): + raise PermissionError(f"User '{user_id}' lacks required roles: {allowed_roles}") + + return calendar + finally: + session.close() + + def watch_calendar_list(self, watch_request: WatchRequest, user_id: str) -> Dict: + """Set up watch notifications for calendar list changes""" + + session = get_session(self.database_id) + try: + # Generate unique resource ID for settings watch + resource_id = f"calendarList-{user_id}-{uuid.uuid4().hex[:8]}" + resource_uri = "/calendars/me/calendarList" + + # Calculate expiration time (max 24 hours from now if not specified) + now = datetime.utcnow() + expires_at = now + timedelta(hours=24) + + if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first(): + raise ValueError(f"Channel with Id {watch_request.id} already exists") + # Create watch channel record + watch_channel = WatchChannel( + id=watch_request.id, + resource_id=resource_id, + resource_uri=resource_uri, + resource_type="calendar_list", + calendar_id="", + user_id=user_id, + webhook_address=watch_request.address, + webhook_token=watch_request.token, + webhook_type=watch_request.type, + params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None, + created_at=now, + expires_at=expires_at, + is_active="true", + notification_count=0 + ) + + # Save to database + session.add(watch_channel) + session.commit() + + logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}") + + # Return channel response + return { + "kind":"api#channel", + "id":watch_channel.id, + "resourceId":resource_id, + "resourceUri":resource_uri, + "token":watch_channel.webhook_token, + "expiration": expires_at.isoformat() + "Z" if expires_at else None + } + + except Exception as e: + session.rollback() + logger.error(f"Error creating settings watch channel: {e}") + raise + finally: + session.close() + + + def _get_user_access_role(self, user_id: str, calendar: Calendar) -> str: + """Get the user's actual access role for a calendar based on ACL entries""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + if not user: + return "none" + + acls = ( + session.query(ACLs) + .join(Scope, ACLs.scope_id == Scope.id) + .filter( + ACLs.calendar_id == calendar.calendar_id, + Scope.type == "user", + Scope.value == user.email + ) + .all() + ) + + if not acls: + return "none" + + # Return the highest permission level found + role_hierarchy = {"none": 0, "freeBusyReader": 1, "reader": 2, "writer": 3, "owner": 4} + highest_role = "none" + highest_weight = 0 + + for acl in acls: + role_weight = role_hierarchy.get(acl.role.value, 0) + if role_weight > highest_weight: + highest_weight = role_weight + highest_role = acl.role.value + + return highest_role + finally: + session.close() + + def _format_calendar_list_entry(self, calendar: Calendar, show_hidden: bool = False, user_id: str = None) -> Optional[Dict]: + """Format calendar model for calendar list API response""" + # Skip hidden calendars unless explicitly requested + if calendar.hidden and not show_hidden: + return None + + # Determine access role from actual ACL data + access_role = "owner" if calendar.is_primary else "writer" # Default fallback + if user_id: + access_role = self._get_user_access_role(user_id, calendar) + + formatted = { + "kind": "calendar#calendarListEntry", + "etag": f"etag-list-{calendar.calendar_id}-{calendar.updated_at.isoformat() if calendar.updated_at else ''}", + "id": calendar.calendar_id, + "summary": calendar.summary_override or calendar.summary, + "description": calendar.description, + "location": calendar.location, + "timeZone": calendar.time_zone, + "accessRole": access_role, + "primary": calendar.is_primary, + "hidden": calendar.hidden or False, + "selected": calendar.selected if calendar.selected is not None else True, + "deleted": calendar.deleted or False + } + + # Add optional fields if present + if calendar.summary_override: + formatted["summaryOverride"] = calendar.summary_override + + if calendar.color_id: + formatted["colorId"] = calendar.color_id + + if calendar.background_color: + formatted["backgroundColor"] = calendar.background_color + + if calendar.foreground_color: + formatted["foregroundColor"] = calendar.foreground_color + + if calendar.default_reminders: + try: + raw_items = json.loads(calendar.default_reminders) + sanitized: List[Dict] = [] + if isinstance(raw_items, list): + for item in raw_items: + if not isinstance(item, dict): + continue + method = item.get("method") + minutes = item.get("minutes") + if ( + method in ALLOWED_REMINDER_METHODS + and isinstance(minutes, int) + and minutes >= 0 + ): + sanitized.append({"method": method, "minutes": minutes}) + if sanitized: + formatted["defaultReminders"] = sanitized + except Exception: + # Ignore malformed stored data + pass + + if calendar.notification_settings: + try: + raw = json.loads(calendar.notification_settings) + if isinstance(raw, dict): + notifs = raw.get("notifications") + sanitized_notifs: List[Dict] = [] + if isinstance(notifs, list): + for n in notifs: + if not isinstance(n, dict): + continue + m = n.get("method") + t = n.get("type") + if m in ALLOWED_NOTIFICATION_METHODS and t in ALLOWED_NOTIFICATION_TYPES: + sanitized_notifs.append({"method": m, "type": t}) + if sanitized_notifs: + formatted["notificationSettings"] = {"notifications": sanitized_notifs} + except Exception: + # Ignore malformed stored data + pass + + # Add conference properties + if calendar.conference_properties: + try: + # conference_properties is stored as JSON string, parse it + conf_props = json.loads(calendar.conference_properties) + formatted["conferenceProperties"] = conf_props + except Exception: + # If parsing fails, provide default + formatted["conferenceProperties"] = { + "allowedConferenceSolutionTypes": [] + } + else: + formatted["conferenceProperties"] = { + "allowedConferenceSolutionTypes": [] + } + + return formatted + + def _encode_page_token(self, offset: int) -> str: + """Encode offset as a page token""" + try: + token_data = str(offset) + return base64.b64encode(token_data.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error(f"Error encoding page token: {e}") + return "" + + def _decode_page_token(self, token: str) -> int: + """Decode page token to get offset""" + try: + # Handle legacy case where raw numbers might be passed + if token.isdigit(): + logger.warning(f"Raw numeric page token received: {token}. This should be a base64-encoded token.") + return int(token) + + # Add padding if needed for base64 decoding + missing_padding = len(token) % 4 + if missing_padding: + token += '=' * (4 - missing_padding) + + decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8') + return int(decoded) + except Exception as e: + logger.error(f"Error decoding page token: {e}") + raise ValueError(f"Invalid page token: {token}. Page tokens should only be generated by the API.") + + def _encode_sync_token(self, timestamp: datetime) -> str: + """Encode timestamp as a sync token""" + try: + # Use ISO format timestamp for sync token + token_data = timestamp.isoformat() + return base64.b64encode(token_data.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error(f"Error encoding sync token: {e}") + return "" + + def _decode_sync_token(self, token: str) -> datetime: + """Decode sync token to get timestamp""" + try: + # Add padding if needed for base64 decoding + missing_padding = len(token) % 4 + if missing_padding: + token += '=' * (4 - missing_padding) + + decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8') + return datetime.fromisoformat(decoded) + except Exception as e: + logger.error(f"Error decoding sync token: {e}") + raise ValueError(f"Invalid sync token. Token may have expired or is malformed.") diff --git a/envs/calendar_env/server/database/managers/calendar_manager.py b/envs/calendar_env/server/database/managers/calendar_manager.py new file mode 100644 index 000000000..4e36b0fc6 --- /dev/null +++ b/envs/calendar_env/server/database/managers/calendar_manager.py @@ -0,0 +1,445 @@ +""" +Calendar Manager - Database operations for calendar management using SQLAlchemy +""" +from datetime import datetime, timezone +from sqlalchemy.orm import Session +from database.models import Calendar, User +from database.models.acl import ACLs, Scope +from database.session_utils import get_session, init_database +import json, uuid, logging +from typing import Dict, List, Optional +from fastapi import HTTPException, status + +logger = logging.getLogger(__name__) + +# Allowed conference solution types per Google Calendar API v3 spec +ALLOWED_CONFERENCE_SOLUTION_TYPES = { + "eventHangout", "eventNamedHangout", "hangoutsMeet" +} + +class CalendarManager: + """Manager for calendar database operations using SQLAlchemy""" + def __init__(self, database_id: str): + # Initialize database on first use + self.database_id = database_id + self.session = self.get_session() + + def get_session(self): + from database.session_manager import CalendarSessionManager + return CalendarSessionManager().get_session(self.database_id) + + def _has_acl_role(self, user_id: str, calendar: Calendar, allowed_roles: List[str]) -> bool: + user = self.session.query(User).filter(User.user_id == user_id).first() + if not user: + logger.warning(f"No user found with ID: {user_id}") + return False + + acls = ( + self.session.query(ACLs) + .join(Scope, ACLs.scope_id == Scope.id) + .filter( + ACLs.calendar_id == calendar.calendar_id, + Scope.type == "user", + Scope.value == user.email + ) + .all() + ) + + if not acls: + logger.warning(f"No ACL found for user {user.email} on calendar {calendar.calendar_id}") + return False + + for acl in acls: + if acl.role.value in allowed_roles: + return True + + logger.warning(f"User {user.email} has ACLs but lacks required roles: {allowed_roles}") + return False + + def create_calendar(self, user_id: str, calendar_data: dict): + """Create a new calendar""" + conf_types = calendar_data.get("conferenceProperties", {}).get("allowedConferenceSolutionTypes") + if conf_types: + for ctype in conf_types: + if ctype not in ALLOWED_CONFERENCE_SOLUTION_TYPES: + raise ValueError(f"Invalid conference solution type: {ctype}") + # This endpoint strictly creates secondary calendars per Google Calendar API semantics. + # Do not auto-promote to primary here. + mapped_data = { + "calendar_id": str(uuid.uuid4()), # Generate unique calendar ID + "user_id": user_id, + "summary": calendar_data.get("summary"), + "description": calendar_data.get("description"), + "location": calendar_data.get("location"), + "time_zone": calendar_data.get("timeZone", "UTC"), + "conference_properties": json.dumps(calendar_data.get("conferenceProperties", {})), + "is_primary": False + } + # Create Calendar model instance + new_calendar = Calendar(**mapped_data) + self.session.add(new_calendar) + self.session.commit() + self.session.refresh(new_calendar) + + # Automatically create ACL entry for the calendar owner + self._create_owner_acl(user_id, new_calendar.calendar_id) + + # Return the created calendar + return new_calendar + + def _create_owner_acl(self, user_id: str, calendar_id: str): + """Create ACL entries with owner and writer roles for the calendar creator""" + from database.models.acl import AclRole, ScopeType + + # Get the user to access their email + user = self.session.query(User).filter(User.user_id == user_id).first() + if not user: + raise ValueError(f"User with ID {user_id} not found") + + # Create or get the scope for this user + scope = self.session.query(Scope).filter( + Scope.type == ScopeType.user, + Scope.value == user.email + ).first() + + if not scope: + scope = Scope( + id=str(uuid.uuid4()), + type=ScopeType.user, + value=user.email + ) + self.session.add(scope) + self.session.flush() # Flush to get the scope ID + + # Create the owner ACL entry + owner_acl = ACLs( + id=str(uuid.uuid4()), + calendar_id=calendar_id, + user_id=user_id, + scope_id=scope.id, + role=AclRole.owner, + etag=f'"{uuid.uuid4()}"' + ) + + # Create the writer ACL entry + writer_acl = ACLs( + id=str(uuid.uuid4()), + calendar_id=calendar_id, + user_id=user_id, + scope_id=scope.id, + role=AclRole.writer, + etag=f'"{uuid.uuid4()}"' + ) + + self.session.add(owner_acl) + self.session.add(writer_acl) + self.session.commit() + + logger.info(f"Created owner and writer ACL entries for user {user.email} on calendar {calendar_id}") + + def get_calendar_by_id(self, user_id: str, calendar_id: str, allowed_roles: List[str] = None): + """Get a calendar by ID for a specific user""" + calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first() + if not calendar: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist") + calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id, Calendar.user_id == user_id).first() + if not calendar: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Calendar with id '{calendar_id}' does not exist for user {user_id}") + if allowed_roles and not self._has_acl_role(user_id, calendar, allowed_roles): + raise PermissionError(f"User '{user_id}' lacks required roles: {allowed_roles}") + return calendar + + + def update_calendar(self, user_id: str, calendar_id: str, update_data: dict): + """Update a calendar (cannot modify primary status)""" + calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first() + if not calendar: + return None + + # Prevent changing primary status via update + if "is_primary" in update_data: + raise ValueError("Cannot modify primary calendar status") + + # Update fields + if "summary" in update_data: + calendar.summary = update_data["summary"] + + if "description" in update_data: + calendar.description = update_data["description"] + + if "location" in update_data: + calendar.location = update_data["location"] + + if "timeZone" in update_data and update_data["timeZone"] is not None: + calendar.time_zone = update_data["timeZone"] + + if "conferenceProperties" in update_data: + self._validate_conference_properties(update_data.get("conferenceProperties")) + calendar.conference_properties = ( + json.dumps(update_data["conferenceProperties"]) + if update_data["conferenceProperties"] else None + ) + + calendar.updated_at = datetime.now(timezone.utc) + + self.session.commit() + self.session.refresh(calendar) + return calendar + + def delete_calendar(self, user_id: str, calendar_id: str): + """Delete a calendar (cannot delete primary calendar)""" + calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first() + if not calendar: + return False + if calendar.is_primary: + raise ValueError("Cannot delete primary calendar.") + if not self._has_acl_role(user_id, calendar, ["owner"]): + raise PermissionError("Only owners can delete the calendar") + self.session.delete(calendar) + self.session.commit() + return True + + def clear_calendar(self, user_id: str, calendar_id: str): + """Clear all events from a calendar and return the number of events deleted""" + calendar = self.session.query(Calendar).filter(Calendar.calendar_id == calendar_id).first() + if not calendar: + return 0 + if not self._has_acl_role(user_id, calendar, ["owner", "writer"]): + raise PermissionError("User does not have permission to clear this calendar") + if not calendar.is_primary: + raise ValueError("Can only clear primary calendars") + count = len(calendar.events) + for event in calendar.events: + self.session.delete(event) + self.session.commit() + return count + + def list_calendars(self, user_id: str): + """List all calendars for a user""" + user = self.session.query(User).filter(User.user_id == user_id).first() + if not user: + return [] + owned = self.session.query(Calendar).filter(Calendar.user_id == user_id) + shared = ( + self.session.query(Calendar) + .join(ACLs, Calendar.calendar_id == ACLs.calendar_id) + .join(Scope, ACLs.scope_id == Scope.id) + .filter(Scope.type == "user", Scope.value == user.email) + ) + return owned.union(shared).all() + + def get_primary_calendar(self, user_id: str): + return self.session.query(Calendar).filter_by(user_id=user_id, is_primary=True).first() + + def _format_calendar_response(self, calendar: Calendar) -> Dict: + """Format calendar model for API response""" + formatted = { + "kind": "calendar#calendar", + "etag": f"etag-{calendar.calendar_id}-{calendar.updated_at.isoformat() if calendar.updated_at else ''}", + "id": calendar.calendar_id, + "summary": calendar.summary, + "timeZone": calendar.time_zone + } + + # Add optional fields if present + if calendar.description: + formatted["description"] = calendar.description + + if calendar.location: + formatted["location"] = calendar.location + if calendar.conference_properties: + try: + formatted["conferenceProperties"] = json.loads(calendar.conference_properties) + except: + pass + + return formatted + + def ensure_primary_calendar_constraint(self, user_id: str) -> bool: + """Ensure user has exactly one primary calendar""" + session = get_session(self.database_id) + try: + # Get all primary calendars for user + primary_calendars = session.query(Calendar).filter( + Calendar.user_id == user_id, + Calendar.is_primary == True + ).all() + + # Case 1: No primary calendar - make first calendar primary + if not primary_calendars: + first_calendar = session.query(Calendar).filter( + Calendar.user_id == user_id + ).order_by(Calendar.created_at.asc()).first() + + if first_calendar: + first_calendar.is_primary = True + session.commit() + logger.info(f"Made calendar {first_calendar.calendar_id} primary for user {user_id}") + return True + else: + # User has no calendars - this is valid + return True + + # Case 2: Multiple primary calendars - keep oldest, make others secondary + elif len(primary_calendars) > 1: + # Sort by creation date, keep the oldest as primary + primary_calendars.sort(key=lambda c: c.created_at) + primary_calendar = primary_calendars[0] + + # Make all others secondary + for calendar in primary_calendars[1:]: + calendar.is_primary = False + logger.warning(f"Made calendar {calendar.calendar_id} secondary for user {user_id}") + + session.commit() + logger.info(f"Ensured single primary calendar {primary_calendar.calendar_id} for user {user_id}") + return True + + # Case 3: Exactly one primary calendar - already correct + return True + + except Exception as e: + session.rollback() + logger.error(f"Error ensuring primary calendar constraint for user {user_id}: {e}") + raise + finally: + session.close() + + def _validate_conference_properties(self, conference_properties: Optional[Dict]) -> None: + """Validate conferenceProperties.allowedConferenceSolutionTypes values. + + Raises ValueError if any provided value is not allowed per API spec. + """ + if not conference_properties: + return + allowed_list = conference_properties.get("allowedConferenceSolutionTypes") + if allowed_list is None: + return + if not isinstance(allowed_list, list): + raise ValueError("conferenceProperties.allowedConferenceSolutionTypes must be a list if provided") + invalid = [item for item in allowed_list if item not in ALLOWED_CONFERENCE_SOLUTION_TYPES and item not in [None, ""]] + if invalid: + allowed_sorted = sorted(ALLOWED_CONFERENCE_SOLUTION_TYPES) + raise ValueError( + "Invalid values for conferenceProperties.allowedConferenceSolutionTypes: " + f"{invalid}. Allowed values are: {allowed_sorted}" + ) + + def validate_primary_calendar_integrity(self, user_id: str) -> Dict[str, any]: + """Validate primary calendar integrity for a user""" + session = get_session(self.database_id) + try: + primary_calendars = session.query(Calendar).filter( + Calendar.user_id == user_id, + Calendar.is_primary == True + ).all() + + total_calendars = session.query(Calendar).filter( + Calendar.user_id == user_id + ).count() + + result = { + "user_id": user_id, + "total_calendars": total_calendars, + "primary_calendars_count": len(primary_calendars), + "is_valid": len(primary_calendars) == 1 if total_calendars > 0 else True, + "primary_calendar_ids": [c.calendar_id for c in primary_calendars] + } + + return result + + except Exception as e: + logger.error(f"Error validating primary calendar integrity for user {user_id}: {e}") + raise + finally: + session.close() + + def _ensure_user_has_primary_calendar(self, user_id: str, session: Session = None) -> None: + """Private method to ensure user has exactly one primary calendar + + This enforces the business rule: If user has calendars, exactly one must be primary + """ + close_session = session is None + if session is None: + session = get_session(self.database_id) + + try: + # Get all calendars for user + all_calendars = session.query(Calendar).filter( + Calendar.user_id == user_id + ).order_by(Calendar.created_at.asc()).all() + + if not all_calendars: + # User has no calendars - nothing to enforce + return + + # Get primary calendars + primary_calendars = [c for c in all_calendars if c.is_primary] + + if len(primary_calendars) == 0: + # No primary calendar - make the oldest one primary + oldest_calendar = all_calendars[0] + oldest_calendar.is_primary = True + logger.info(f"Made calendar {oldest_calendar.calendar_id} primary for user {user_id} (no primary existed)") + + elif len(primary_calendars) > 1: + # Multiple primary calendars - keep oldest, make others secondary + primary_calendars.sort(key=lambda c: c.created_at) + primary_calendar = primary_calendars[0] + + for calendar in primary_calendars[1:]: + calendar.is_primary = False + logger.warning(f"Made calendar {calendar.calendar_id} secondary for user {user_id} (multiple primaries existed)") + + logger.info(f"Kept calendar {primary_calendar.calendar_id} as primary for user {user_id}") + + # If exactly one primary exists, no action needed + + except Exception as e: + logger.error(f"Error ensuring primary calendar for user {user_id}: {e}") + raise + finally: + if close_session: + session.close() + + def enforce_primary_calendar_constraint_for_all_users(self) -> Dict[str, int]: + """Enforce primary calendar constraint for all users in the database + + Returns: + Dict with statistics about fixes applied + """ + session = get_session(self.database_id) + try: + # Get all unique user IDs + user_ids = session.query(Calendar.user_id).distinct().all() + user_ids = [uid[0] for uid in user_ids] + + stats = { + "users_processed": 0, + "users_fixed": 0, + "calendars_made_primary": 0, + "calendars_made_secondary": 0 + } + + for user_id in user_ids: + stats["users_processed"] += 1 + + # Check if user needs fixing + validation_result = self.validate_primary_calendar_integrity(user_id) + + if not validation_result["is_valid"]: + stats["users_fixed"] += 1 + logger.info(f"Fixing primary calendar constraint for user {user_id}") + + # Fix the constraint + self._ensure_user_has_primary_calendar(user_id, session) + + session.commit() + return stats + + except Exception as e: + session.rollback() + logger.error(f"Error enforcing primary calendar constraints: {e}") + raise + finally: + session.close() diff --git a/envs/calendar_env/server/database/managers/color_manager.py b/envs/calendar_env/server/database/managers/color_manager.py new file mode 100644 index 000000000..397051e97 --- /dev/null +++ b/envs/calendar_env/server/database/managers/color_manager.py @@ -0,0 +1,224 @@ +""" +Color database manager with Google Calendar API v3 compatible operations +Handles color definitions for calendars and events +""" + +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime, timezone +from sqlalchemy import and_, func + +from database.session_utils import get_session, init_database +from database.models.color import Color, ColorType + +logger = logging.getLogger(__name__) + + +class ColorManager: + """Color manager for database operations""" + + def __init__(self, database_id: str): + self.database_id = database_id + # Initialize database on first use + init_database(database_id) + + + def get_colors_response(self) -> Dict[str, Any]: + """ + Get the complete colors response in Google Calendar API v3 format + + Returns: + Dict containing calendar and event colors with metadata + """ + session = get_session(self.database_id) + try: + # Get all colors from database + colors = session.query(Color).all() + + # Get last updated timestamp + last_updated = session.query(func.max(Color.updated_at)).scalar() + if not last_updated: + last_updated = datetime.now(timezone.utc) + + # Organize colors by type + calendar_colors = {} + event_colors = {} + + for color in colors: + color_dict = color.to_dict() + if color.color_type == ColorType.CALENDAR: + calendar_colors[color.color_id] = color_dict + elif color.color_type == ColorType.EVENT: + event_colors[color.color_id] = color_dict + + return { + "kind": "calendar#colors", + "updated": last_updated.isoformat().replace('+00:00', '.000Z'), + "calendar": calendar_colors, + "event": event_colors + } + + except Exception as e: + logger.error(f"Error getting colors response: {e}") + raise + finally: + session.close() + + def get_color_by_id(self, color_type: str, color_id: str) -> Optional[Dict[str, str]]: + """ + Get a specific color by type and ID + + Args: + color_type: Either 'calendar' or 'event' + color_id: The color ID (e.g., '1', '2', etc.) + + Returns: + Dict containing background and foreground colors, or None if not found + """ + session = get_session(self.database_id) + try: + # Convert string type to enum + if color_type == "calendar": + type_enum = ColorType.CALENDAR + elif color_type == "event": + type_enum = ColorType.EVENT + else: + return None + + color = session.query(Color).filter( + and_( + Color.color_type == type_enum, + Color.color_id == color_id + ) + ).first() + + if color: + return color.to_dict() + return None + + except Exception as e: + logger.error(f"Error getting color {color_type}:{color_id}: {e}") + return None + finally: + session.close() + + def validate_color_id(self, color_type: str, color_id: str) -> bool: + """ + Validate if a color ID exists for the given type + + Args: + color_type: Either 'calendar' or 'event' + color_id: The color ID to validate + + Returns: + True if color ID exists, False otherwise + """ + return self.get_color_by_id(color_type, color_id) is not None + + def get_all_colors_by_type(self, color_type: str) -> Dict[str, Dict[str, str]]: + """ + Get all colors of a specific type + + Args: + color_type: Either 'calendar' or 'event' + + Returns: + Dict mapping color IDs to their color definitions + """ + session = get_session(self.database_id) + try: + # Convert string type to enum + if color_type == "calendar": + type_enum = ColorType.CALENDAR + elif color_type == "event": + type_enum = ColorType.EVENT + else: + return {} + + colors = session.query(Color).filter(Color.color_type == type_enum).all() + + result = {} + for color in colors: + result[color.color_id] = color.to_dict() + + return result + + except Exception as e: + logger.error(f"Error getting all colors for type {color_type}: {e}") + return {} + finally: + session.close() + + def load_sample_colors(self, color_data: List[Dict[str, Any]]) -> int: + """ + Load sample color data into the database + + Args: + color_data: List of color dictionaries with color_id, color_type, background, foreground + + Returns: + Number of colors loaded + """ + session = get_session(self.database_id) + try: + loaded_count = 0 + + for color_info in color_data: + # Check if color already exists + existing = session.query(Color).filter( + and_( + Color.color_id == color_info["color_id"], + Color.color_type == ColorType(color_info["color_type"]) + ) + ).first() + + if not existing: + # Create new color + color = Color( + color_id=color_info["color_id"], + color_type=ColorType(color_info["color_type"]), + background=color_info["background"], + foreground=color_info["foreground"] + ) + session.add(color) + loaded_count += 1 + else: + # Update existing color + existing.background = color_info["background"] + existing.foreground = color_info["foreground"] + existing.updated_at = datetime.now(timezone.utc) + loaded_count += 1 + + session.commit() + logger.info(f"Loaded {loaded_count} colors into database") + return loaded_count + + except Exception as e: + session.rollback() + logger.error(f"Error loading sample colors: {e}") + raise + finally: + session.close() + + def clear_all_colors(self) -> int: + """ + Clear all colors from the database + + Returns: + Number of colors deleted + """ + session = get_session(self.database_id) + try: + count = session.query(Color).count() + session.query(Color).delete() + session.commit() + + logger.info(f"Cleared {count} colors from database") + return count + + except Exception as e: + session.rollback() + logger.error(f"Error clearing colors: {e}") + raise + finally: + session.close() \ No newline at end of file diff --git a/envs/calendar_env/server/database/managers/event_manager.py b/envs/calendar_env/server/database/managers/event_manager.py new file mode 100644 index 000000000..271cf4e88 --- /dev/null +++ b/envs/calendar_env/server/database/managers/event_manager.py @@ -0,0 +1,3617 @@ +""" +Event database manager with Google Calendar API v3 compatible operations +Handles all 11 Events API operations with database-per-user architecture +""" + +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime, timezone +from sqlalchemy.orm import sessionmaker, joinedload +from sqlalchemy import and_, or_, desc, asc +from dateutil import parser +import re + +from database.session_utils import get_session, init_database +from database.models.event import Event, Attendees, Reminder, EventTypeEnum, ConferenceData as ConferenceDataModel, ExtendedPropertyScope +from database.models.calendar import Calendar +from database.models.user import User +from schemas.event import ( + Event as EventSchema, + EventListResponse, + EventCreateRequest, + EventUpdateRequest, + EventMoveRequest, + EventQuickAddRequest, + EventInstancesResponse, + Channel, + DateTime, + Person, + Attendee, + EventStatus, + EventVisibility +) +from schemas.import_event import ( + EventImportRequest, + EventImportResponse, + EventImportQueryParams, + EventImportResult, + EventImportValidation, + EventDateTime, + EventImportError, + ConferenceData, +) +from enum import Enum +import uuid +from database.managers.calendar_manager import CalendarManager +from database.models.watch_channel import WatchChannel +from database.models.user import User +from database.models.event import RecurringEvent +from datetime import timedelta +import json +import uuid +import string +import random +import base64 +from utils.recurrence_utils import RecurrenceParser, RecurrenceParseError + + +logger = logging.getLogger(__name__) + + +class EventManager: + """Event manager for database operations""" + + def __init__(self, database_id: str): + self.database_id = database_id + # Initialize database on first use + init_database(database_id) + self.calendar_manager = CalendarManager(database_id) + # self.recurring_event_manager = RecurringEventManager(database_id) + + def _get_user_calendar_role(self, user_id: str, calendar: Calendar) -> str: + """Get the highest role a user has on a calendar""" + from database.models.acl import ACLs, Scope + + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + if not user: + return "none" + + acls = ( + session.query(ACLs) + .join(Scope, ACLs.scope_id == Scope.id) + .filter( + ACLs.calendar_id == calendar.calendar_id, + Scope.type == "user", + Scope.value == user.email + ) + .all() + ) + + if not acls: + return "none" + + # Return the highest permission level found + role_hierarchy = {"none": 0, "freeBusyReader": 1, "reader": 2, "writer": 3, "owner": 4} + highest_role = "none" + highest_weight = 0 + + for acl in acls: + role_weight = role_hierarchy.get(acl.role.value, 0) + if role_weight > highest_weight: + highest_weight = role_weight + highest_role = acl.role.value + + return highest_role + finally: + session.close() + + def check_event_permissions(self, user_id: str, calendar_id: str, event_id: str, required_roles: list[str]) -> bool: + """ + Check if user has required permissions for an event operation + + Args: + user_id: User performing the operation + calendar_id: Calendar containing the event + event_id: Event ID + required_roles: List of roles that allow the operation + + Returns: + True if user has permission, False otherwise + """ + session = get_session(self.database_id) + try: + # Get the event and calendar + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id) + ).first() + + if not db_event: + return False + + calendar = db_event.calendar + user_role = self._get_user_calendar_role(user_id, calendar) + + # Check if user has required calendar role + if user_role in required_roles: + return True + + # Special case: event creator can modify their own events if they have writer+ access + if db_event.user_id == user_id and user_role in ["writer", "owner"]: + return True + + return False + + except Exception as e: + logger.error(f"Error checking event permissions: {e}") + return False + finally: + session.close() + + def check_event_visibility_permission(self, user_id: str, calendar_id: str, event_id: str) -> bool: + """ + Check if user can see a specific event based on visibility settings + + Visibility Rules: + - default/public: Anyone with calendar read access can see + - private: Only event creator and calendar owners can see + - confidential: Only calendar owners can see + """ + session = get_session(self.database_id) + try: + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id) + ).first() + + if not db_event: + return False + + calendar = db_event.calendar + user_role = self._get_user_calendar_role(user_id, calendar) + + # Check basic calendar access first + if user_role == "none": + return False + + # Check visibility rules + visibility = db_event.visibility or "default" + + if visibility in ["default", "public"]: + # Anyone with calendar access can see + return user_role in ["freeBusyReader", "reader", "writer", "owner"] + elif visibility == "private": + # Only event creator and calendar owners can see + return (db_event.user_id == user_id or user_role == "owner") + elif visibility == "confidential": + # Only calendar owners can see + return user_role == "owner" + + return False + + except Exception as e: + logger.error(f"Error checking event visibility permission: {e}") + return False + finally: + session.close() + + def check_event_delete_permission(self, user_id: str, calendar_id: str, event_id: str) -> bool: + """ + Check if user has permission to delete a specific event + + Rules: + - Event creator can delete their events (if they have writer+ access) + - Calendar owner can delete any event + - Users with writer access can delete events they created + """ + session = get_session(self.database_id) + try: + # Get the event + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id) + ).first() + + if not db_event: + return False + + calendar = db_event.calendar + user_role = self._get_user_calendar_role(user_id, calendar) + + # Calendar owner can delete any event + if user_role == "owner": + return True + + # Event creator can delete if they have writer+ access to calendar + if db_event.user_id == user_id and user_role in ["writer", "owner"]: + return True + + return False + + except Exception as e: + logger.error(f"Error checking event delete permission: {e}") + return False + finally: + session.close() + + def check_event_modification_permission(self, user_id: str, calendar_id: str, event_id: str) -> bool: + """ + Check if user can modify a specific event + + Rules: + - Calendar owner can modify any event + - Calendar writer can modify events they created + - Guest permissions (guestsCanModify) are checked for attendees + """ + session = get_session(self.database_id) + try: + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id) + ).first() + + if not db_event: + return False + + calendar = db_event.calendar + user_role = self._get_user_calendar_role(user_id, calendar) + + # Owner can modify any event + if user_role == "owner": + return True + + # Writer can modify events they created + if user_role == "writer" and db_event.user_id == user_id: + return True + + # Check if user is an attendee with modify permissions + if db_event.guestsCanModify: + from database.models.user import User + user = session.query(User).filter(User.user_id == user_id).first() + if user: + attendee = session.query(Attendees).filter( + and_(Attendees.event_id == event_id, Attendees.user_id == user_id) + ).first() + if attendee: + return True + + return False + + except Exception as e: + logger.error(f"Error checking event modification permission: {e}") + return False + finally: + session.close() + + def _convert_db_event_to_schema(self, db_event: Event) -> EventSchema: + """Convert database Event model to EventSchema""" + try: + # Build organizer info from stored database fields + organizer_info = {} + if hasattr(db_event, 'organizer_id') and db_event.organizer_id: + organizer_info = { + "id": db_event.organizer_id, + "email": db_event.organizer_email or "", + "displayName": db_event.organizer_display_name or "", + "self": db_event.organizer_self or False + } + + # Basic event data + event_data = { + "id": db_event.event_id, + "kind": "calendar#event", + "etag": f'"{db_event.updated_at.timestamp()}"' if db_event.updated_at else None, + "status": db_event.status, + "htmlLink": f"https://calendar.google.com/event?eid={db_event.event_id}", + "created": db_event.created_at.isoformat() if db_event.created_at else None, + "updated": db_event.updated_at.isoformat() if db_event.updated_at else None, + "summary": db_event.summary, + "description": db_event.description, + "location": db_event.location, + "start": self._build_datetime_for_schema(db_event.start_datetime, db_event.start_timezone), + "end": self._build_datetime_for_schema(db_event.end_datetime, db_event.end_timezone), + "originalStartTime": self._build_original_start_time_for_schema(db_event), + "recurringEventId": db_event.recurring_event_id, + "recurrence": json.loads(db_event.recurrence) if db_event.recurrence else [], + "visibility": db_event.visibility, + "transparency": db_event.transparency, + "iCalUID": db_event.iCalUID, + "guestsCanInviteOthers":db_event.guestsCanInviteOthers, + "guestsCanModify": db_event.guestsCanModify, + "guestsCanSeeOtherGuests": db_event.guestsCanSeeOtherGuests, + "privateCopy": db_event.privateCopy, + "locked": db_event.locked, + "hangoutLink": db_event.hangoutLink, + "creator": organizer_info, + "organizer": organizer_info, + "sequence": db_event.sequence + + } + + + # Add eventType if present + if hasattr(db_event, 'eventType') and db_event.eventType: + event_data["eventType"] = db_event.eventType.value if hasattr(db_event.eventType, 'value') else str(db_event.eventType) + + # Add colorId if present + if hasattr(db_event, 'color_id') and db_event.color_id: + event_data["colorId"] = db_event.color_id + + # Convert attendees from database relationships + attendees_list = [] + if db_event.attendees: + for attendee in db_event.attendees: + attendee_data = { + "id": attendee.attendees_id, + "email": attendee.user.email if attendee.user else None, + "displayName": attendee.displayName, + "responseStatus": attendee.responseStatus, + "optional": attendee.optional, + "comment": attendee.comment, + "additionalGuests": attendee.additionalGuests, + "resource": attendee.resource + } + + # Set organizer and self flags based on stored organizer info + if organizer_info and "email" in organizer_info and attendee.user: + if attendee.user.email == organizer_info["email"]: + attendee_data["organizer"] = True + attendee_data["self"] = organizer_info.get("self", False) + else: + attendee_data["organizer"] = False + attendee_data["self"] = False + else: + attendee_data["organizer"] = False + attendee_data["self"] = False + + attendees_list.append(attendee_data) + event_data["attendees"] = attendees_list + + event_data["attendeesOmitted"] = False + # Convert attachments from database relationships + attachments_list = [] + if db_event.attachments: + for attachment in db_event.attachments: + attachment_data = { + "fileUrl": attachment.file_url, + "title": attachment.file_url.split('/')[-1] if attachment.file_url else "attachment" + } + attachments_list.append(attachment_data) + event_data["attachments"] = attachments_list + + # Add conference data if present + if hasattr(db_event, 'conferenceData') and db_event.conferenceData: + conf = db_event.conferenceData + conference_data = { + } + + # Add conferenceSolution if available + if conf.solution_type or conf.solution_name or conf.solution_icon_uri: + conference_solution = {} + if conf.solution_icon_uri: + conference_solution["iconUri"] = conf.solution_icon_uri + if conf.solution_type: + conference_solution["key"] = {"type": conf.solution_type} + if conf.solution_name: + conference_solution["name"] = conf.solution_name + conference_data["conferenceSolution"] = conference_solution + + # Add createRequest if available + if conf.request_id or conf.create_solution_type or conf.status_code: + create_request = {} + if conf.request_id: + create_request["requestId"] = conf.request_id + if conf.create_solution_type: + create_request["conferenceSolutionKey"] = { + "type": conf.create_solution_type + } + if conf.status_code: + create_request["status"] = {"statusCode": conf.status_code} + conference_data["createRequest"] = create_request + + # Add entryPoints from JSON array or legacy field + entry_points = [] + if conf.entry_points: + # Use new JSON array format + entry_points = conf.entry_points + elif conf.meeting_uri: + # Fallback to legacy format + entry_points = [{ + "entryPointType": "video", + "uri": conf.meeting_uri + }] + + if entry_points: + conference_data["entryPoints"] = entry_points + + # Add notes and signature if available + if conf.notes: + conference_data["notes"] = conf.notes + if conf.signature: + conference_data["signature"] = conf.signature + + event_data["conferenceData"] = conference_data + + # Add source if present + if hasattr(db_event, 'source') and db_event.source: + event_data["source"] = db_event.source + + # Add extended properties if present + if hasattr(db_event, 'extendedProperties') and db_event.extendedProperties: + ext_props = {} + private_props = {} + shared_props = {} + + for prop in db_event.extendedProperties: + if prop.scope == ExtendedPropertyScope.private: + private_props.update(prop.properties or {}) + elif prop.scope == ExtendedPropertyScope.shared: + shared_props.update(prop.properties or {}) + + if private_props: + ext_props["private"] = private_props + if shared_props: + ext_props["shared"] = shared_props + + if ext_props: + event_data["extendedProperties"] = ext_props + + # Add working location properties if present + if hasattr(db_event, 'workingLocationProperties') and db_event.workingLocationProperties: + working_loc = { + "type": db_event.workingLocationProperties.type, + "homeOffice": db_event.workingLocationProperties.homeOffice + } + + if db_event.workingLocationProperties.customLocationLabel: + working_loc["customLocation"] = { + "label": db_event.workingLocationProperties.customLocationLabel + } + + if db_event.workingLocationProperties.officeLocation: + working_loc["officeLocation"] = { + "buildingId": db_event.workingLocationProperties.officeLocation.buildingId, + "floorId": db_event.workingLocationProperties.officeLocation.floorId, + "deskId": db_event.workingLocationProperties.officeLocation.deskId, + "floorSectionId": db_event.workingLocationProperties.officeLocation.floorSectionId, + "label": db_event.workingLocationProperties.officeLocation.label + } + + event_data["workingLocationProperties"] = working_loc + + # Add birthday properties if present + if hasattr(db_event, 'birthdayProperties') and db_event.birthdayProperties: + from schemas.event import BirthdayProperties + event_data["birthdayProperties"] = BirthdayProperties( + type=db_event.birthdayProperties.type + ) + + # Add focus time properties if present + if hasattr(db_event, 'focusTimeProperties') and db_event.focusTimeProperties: + event_data["focusTimeProperties"] = db_event.focusTimeProperties + + # Add out of office properties if present + if hasattr(db_event, 'outOfOfficeProperties') and db_event.outOfOfficeProperties: + event_data["outOfOfficeProperties"] = db_event.outOfOfficeProperties + + # Build reminders response format + reminders_response = { + "useDefault": False, + "overrides": [] + } + # Add reminders if present + if db_event.reminders: + + # Check if any reminder uses default + use_default = any(reminder.use_default for reminder in db_event.reminders) + + if use_default: + reminders_response["useDefault"] = True + + # Build overrides list + overrides = [] + for reminder in db_event.reminders: + overrides.append({ + "method": reminder.method.value if hasattr(reminder.method, 'value') else reminder.method, + "minutes": reminder.minutes + }) + + reminders_response["overrides"] = overrides + + event_data["reminders"] = reminders_response + + return EventSchema(**event_data) + + except Exception as e: + logger.error(f"Error converting DB event to schema: {e}") + raise + + def _parse_datetime_string(self, datetime_str: str) -> datetime: + """Parse ISO datetime string to datetime object""" + try: + return parser.isoparse(datetime_str.replace('Z', '+00:00')) + except Exception as e: + logger.error(f"Error parsing datetime string {datetime_str}: {e}") + raise ValueError(f"Invalid datetime format: {datetime_str}") + + def _parse_datetime_from_api_format(self, datetime_obj: DateTime) -> tuple[datetime, str]: + """Parse Google Calendar API DateTime format to datetime object and timezone""" + try: + if isinstance(datetime_obj, dict): + # Validate timezone + self._validate_timezone(datetime_obj["timeZone"]) + if "dateTime" in datetime_obj: + # Timed event + dt = self._parse_datetime_string(datetime_obj["dateTime"]) + tz = datetime_obj["timeZone"] or "UTC" + return dt, tz + elif "date" in datetime_obj: + # All-day event - parse date string and create datetime at midnight + from datetime import date + parsed_date = date.fromisoformat(datetime_obj["date"]) + dt = datetime.combine(parsed_date, datetime.min.time()) + tz = datetime_obj["timeZone"] or "UTC" + return dt, tz + else: + raise ValueError("DateTime object must have either dateTime or date") + else: + self._validate_timezone(datetime_obj.timeZone) + if datetime_obj.dateTime: + # Timed event + dt = self._parse_datetime_string(datetime_obj.dateTime) + tz = datetime_obj.timeZone or "UTC" + return dt, tz + elif datetime_obj.date: + # All-day event - parse date string and create datetime at midnight + from datetime import date + parsed_date = date.fromisoformat(datetime_obj.date) + dt = datetime.combine(parsed_date, datetime.min.time()) + tz = datetime_obj.timeZone or "UTC" + return dt, tz + else: + raise ValueError("DateTime object must have either dateTime or date") + except Exception as e: + logger.error(f"Error parsing API DateTime format: {e}") + raise ValueError(f"Invalid DateTime format: {e}") + + def _process_create_attendees(self, db_event: Event, attendees_data: list, session): + """Process attendees from create request""" + if not attendees_data: + return + + from database.models.event import Attendees + + # Check whether email id exist + self.check_attendees_email_id(session, attendees_data) + + for attendee_data in attendees_data: + # Find user based on email + user = session.query(User).filter(User.email == attendee_data.email).first() + if not user: + logger.warning(f"Attendee email '{attendee_data.email}' not found in database, skipping") + continue + + attendee = Attendees( + attendees_id=str(uuid.uuid4()), + event_id=db_event.event_id, + user_id=user.user_id, + displayName=attendee_data.displayName, + optional=attendee_data.optional or False, + resource=attendee_data.resource or False, + responseStatus=attendee_data.responseStatus or "needsAction", + comment=attendee_data.comment, + additionalGuests=attendee_data.additionalGuests or 0 + ) + session.add(attendee) + + def _validate_attachment_file_url(self, attachments_data): + from urllib.parse import urlparse + for data in attachments_data: + if 'fileUrl' not in data.keys(): + raise ValueError("fileUrl is required") + s = str(data.get("fileUrl")).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"Invalid 'fileUrl': must be an https URL {s}") + + def _process_create_attachments(self, db_event: Event, attachments_data: list, session): + """Process attachments from create request""" + if not attachments_data: + return + + from database.models.event import Attachment + + # Vaidate attachment file url + self._validate_attachment_file_url(attachments_data) + + for attachment_data in attachments_data: + attachment = Attachment( + attachment_id=str(uuid.uuid4()), + event_id=db_event.event_id, + file_url=attachment_data.get("fileUrl") + ) + session.add(attachment) + + def _process_create_conference_data(self, db_event: Event, conference_data, session): + """Process conference data from create request using new comprehensive schema""" + from database.models.event import ConferenceData as DBConferenceData + + # Handle both old dict format and new Pydantic ConferenceData model + conference_id = None + request_id = None + solution_type = None + solution_name = None + solution_icon_uri = None + create_solution_type = None + status_code = None + entry_points_json = None + notes = None + signature = None + meeting_uri = None # Legacy field + label = None # Legacy field + + if hasattr(conference_data, 'conferenceId'): + # New Pydantic ConferenceData model + conference_id = conference_data.conferenceId + + # Validate conference Id uniqueness + conference_data_obj = session.query(ConferenceDataModel).filter(ConferenceDataModel.id == conference_id).first() + if conference_data_obj is not None: + raise ValueError(f"Conference Id '{conference_id}' already exists. Please use different ConferenceId") + + # Extract from conferenceSolution if present + if conference_data.conferenceSolution: + solution_type = conference_data.conferenceSolution.key.type if conference_data.conferenceSolution.key else None + solution_name = conference_data.conferenceSolution.name + solution_icon_uri = conference_data.conferenceSolution.iconUri + label = conference_data.conferenceSolution.name # For backward compatibility + + + # Process entryPoints array as JSON + if conference_data.entryPoints: + entry_points_list = [] + for entry_point in conference_data.entryPoints: + entry_point_data = { + "accessCode": entry_point.accessCode if hasattr(entry_point, 'accessCode') else None, + "entryPointType": entry_point.entryPointType if hasattr(entry_point, 'entryPointType') else None, + "label": entry_point.label if hasattr(entry_point, 'label') else None, + "meetingCode": entry_point.meetingCode if hasattr(entry_point, 'meetingCode') else None, + "passcode": entry_point.passcode if hasattr(entry_point, 'passcode') else None, + "pin": entry_point.pin if hasattr(entry_point, 'pin') else None, + "uri": entry_point.uri if hasattr(entry_point, 'uri') else None + } + entry_points_list.append(entry_point_data) + + # Set legacy meeting_uri from first video entry point + if (hasattr(entry_point, 'entryPointType') and + entry_point.entryPointType == 'video' and + hasattr(entry_point, 'uri') and + not meeting_uri): + meeting_uri = entry_point.uri + + entry_points_json = entry_points_list + + # Extract notes and signature + notes = conference_data.notes if hasattr(conference_data, 'notes') else None + signature = conference_data.signature if hasattr(conference_data, 'signature') else None + + # Extract from createRequest if present + if conference_data.createRequest: + request_id = conference_data.createRequest.requestId + if session.query(ConferenceDataModel).filter(ConferenceDataModel.request_id == request_id).first(): + status_code = "failure" + # Create database conference record with all fields + db_conference = DBConferenceData( + id=conference_id or str(uuid.uuid4()), + event_id=db_event.event_id, + solution_type=solution_type, + solution_name=solution_name, + solution_icon_uri=solution_icon_uri, + status_code=status_code, + notes=notes, + signature=signature, + meeting_uri=meeting_uri, # Legacy field for backward compatibility + label=label # Legacy field for backward compatibility + ) + session.add(db_conference) + else: + if conference_data.createRequest.conferenceSolutionKey and conference_data.createRequest.conferenceSolutionKey.type: + create_solution_type = conference_data.createRequest.conferenceSolutionKey.type + + status_code = "success" + + # Create database conference record with all fields + db_conference = DBConferenceData( + id=conference_id or str(uuid.uuid4()), + event_id=db_event.event_id, + solution_type=solution_type, + solution_name=solution_name, + solution_icon_uri=solution_icon_uri, + request_id=request_id, + create_solution_type=create_solution_type, + status_code=status_code, + entry_points=entry_points_json, + notes=notes, + signature=signature, + meeting_uri=meeting_uri, # Legacy field for backward compatibility + label=label # Legacy field for backward compatibility + ) + session.add(db_conference) + + + else: + # Legacy dict format support + conference_id = conference_data.get('conferenceId') + # Validate conference Id uniqueness + conference_data_obj = session.query(ConferenceDataModel).filter(ConferenceDataModel.id == conference_id, ConferenceDataModel.event_id == db_event.event_id).first() + if conference_data_obj is not None: + raise ValueError(f"Conference Id '{conference_id}' already exists. Please use different ConferenceId") + + notes = conference_data.get('notes') + signature = conference_data.get('signature') + + + if conference_data.get('conferenceSolution'): + solution_type = conference_data['conferenceSolution'].get('key', {}).get('type') + solution_name = conference_data['conferenceSolution'].get('name') + solution_icon_uri = conference_data['conferenceSolution'].get('iconUri') + label = conference_data['conferenceSolution'].get('name') # For backward compatibility + + if conference_data.get('entryPoints'): + entry_points_list = [] + for entry_point in conference_data['entryPoints']: + entry_points_list.append({ + "accessCode": entry_point.get('accessCode'), + "entryPointType": entry_point.get('entryPointType'), + "label": entry_point.get('label'), + "meetingCode": entry_point.get('meetingCode'), + "passcode": entry_point.get('passcode'), + "pin": entry_point.get('pin'), + "uri": entry_point.get('uri') + }) + + # Set legacy meeting_uri from first video entry point + if (entry_point.get('entryPointType') == 'video' and + entry_point.get('uri') and + not meeting_uri): + meeting_uri = entry_point.get('uri') + + entry_points_json = entry_points_list + + if conference_data.get('createRequest'): + request_id = conference_data['createRequest'].get('requestId') + if session.query(ConferenceDataModel).filter(ConferenceDataModel.request_id == request_id).first(): + status_code = "failure" + # Create database conference record with all fields + db_conference = DBConferenceData( + id=conference_id or str(uuid.uuid4()), + event_id=db_event.event_id, + solution_type=solution_type, + solution_name=solution_name, + solution_icon_uri=solution_icon_uri, + status_code=status_code, + notes=notes, + signature=signature, + meeting_uri=meeting_uri, # Legacy field for backward compatibility + label=label # Legacy field for backward compatibility + ) + session.add(db_conference) + else: + if conference_data['createRequest'].get('conferenceSolutionKey', {}).get('type'): + create_solution_type = conference_data['createRequest']['conferenceSolutionKey'].get('type') + status_code = "success" + # Create database conference record with all fields + db_conference = DBConferenceData( + id=conference_id or str(uuid.uuid4()), + event_id=db_event.event_id, + solution_type=solution_type, + solution_name=solution_name, + solution_icon_uri=solution_icon_uri, + request_id=request_id, + create_solution_type=create_solution_type, + status_code=status_code, + entry_points=entry_points_json, + notes=notes, + signature=signature, + meeting_uri=meeting_uri, # Legacy field for backward compatibility + label=label # Legacy field for backward compatibility + ) + + session.add(db_conference) + + def _process_create_extended_properties(self, db_event: Event, ext_props, session): + """Process extended properties from create request""" + from database.models.event import ExtendedProperty + + if ext_props.get("private"): + private_prop = ExtendedProperty( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + scope="private", + properties=ext_props["private"] + ) + session.add(private_prop) + + if ext_props.get("shared"): + shared_prop = ExtendedProperty( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + scope="shared", + properties=ext_props["shared"] + ) + session.add(shared_prop) + + def _process_create_reminders(self, db_event: Event, reminders_data, session): + """Process reminders from create request""" + if not reminders_data: + return + + try: + # Handle useDefault logic + if reminders_data.useDefault: + # For useDefault=True, create a standard default reminder + default_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=True, + method="popup", # Default method + minutes=10 # Default 10 minutes before + ) + session.add(default_reminder) + logger.info(f"Added default reminder for event {db_event.event_id}") + + else: + # Handle custom reminder overrides + if reminders_data.overrides and len(reminders_data.overrides) > 0: + for override in reminders_data.overrides: + # Validate reminder data + if not hasattr(override, 'method') or not hasattr(override, 'minutes'): + logger.warning(f"Invalid reminder override for event {db_event.event_id}: missing method or minutes") + continue + + # Validate method + if override.method not in ['email', 'popup']: + logger.warning(f"Invalid reminder method '{override.method}' for event {db_event.event_id}, skipping") + continue + + # Validate minutes (should be non-negative) + if override.minutes < 0: + logger.warning(f"Invalid reminder minutes '{override.minutes}' for event {db_event.event_id}, skipping") + continue + + custom_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=False, + method=override.method, + minutes=override.minutes + ) + session.add(custom_reminder) + logger.info(f"Added custom reminder for event {db_event.event_id}: {override.method} {override.minutes} minutes before") + + except Exception as e: + logger.error(f"Error processing reminders for event {db_event.event_id}: {e}") + # Don't raise the exception - reminders are not critical for event creation + + def _process_create_reminders_for_update(self, db_event: Event, reminders_data, session): + """Process reminders from create request""" + if not reminders_data: + return + + try: + # Handle useDefault logic + if reminders_data.get('useDefault'): + # For useDefault=True, create a standard default reminder + default_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=True, + method="popup", # Default method + minutes=10 # Default 10 minutes before + ) + session.add(default_reminder) + logger.info(f"Added default reminder for event {db_event.event_id}") + + else: + # Handle custom reminder overrides + if reminders_data.get('overrides') and len(reminders_data.get('overrides')) > 0: + for override in reminders_data.get('overrides'): + # Validate reminder data + + custom_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=False, + method=override.get('method').value, + minutes=override.get('minutes') + ) + session.add(custom_reminder) + logger.info(f"Added custom reminder for event {db_event.event_id}: {override.get('method')} {override.get('minutes')} minutes before") + + except Exception as e: + logger.error(f"Error processing reminders for event {db_event.event_id}: {e}") + # Don't raise the exception - reminders are not critical for event creation + + + def _process_create_working_location(self, db_event: Event, working_location, session): + """Process working location properties from create request""" + from database.models.event import WorkingLocationProperties, OfficeLocation + + # Convert to dictionary if it's a Pydantic model + if not isinstance(working_location, dict): + if hasattr(working_location, 'model_dump'): + working_location = working_location.model_dump() + elif hasattr(working_location, 'dict'): + working_location = working_location.dict() + + # Create working location + working_loc = WorkingLocationProperties( + working_location_id=str(uuid.uuid4()), + event_id=db_event.event_id, + type=working_location.get("type"), + homeOffice=working_location.get("homeOffice"), + customLocationLabel=working_location.get("customLocation", {}).get("label") if working_location.get("customLocation") else None + ) + + # Handle office location if specified + if working_location.get("officeLocation"): + office_loc = OfficeLocation( + id=str(uuid.uuid4()), + label=working_location["officeLocation"].get("label", "") if working_location.get("officeLocation") and working_location.get("officeLocation").get("label") else "", + buildingId=working_location["officeLocation"].get("buildingId") if working_location.get("officeLocation") and working_location.get("officeLocation").get("buildingId") else "", + floorId=working_location["officeLocation"].get("floorId") if working_location.get("officeLocation") and working_location.get("officeLocation").get("floorId") else "", + deskId=working_location["officeLocation"].get("deskId") if working_location.get("officeLocation") and working_location.get("officeLocation").get("deskId") else "", + floorSectionId=working_location["officeLocation"].get("floorSectionId") if working_location.get("officeLocation") and working_location.get("officeLocation").get("floorSectionId") else "" + ) + session.add(office_loc) + session.flush() + working_loc.officeLocationId = office_loc.id + + session.add(working_loc) + + def _process_create_birthday_properties(self, db_event: Event, birthday_props, session): + """Process birthday properties from create request""" + from database.models.event import BirthdayProperties + + # Handle both dict and object formats + if isinstance(birthday_props, dict): + birthday_type = birthday_props.get('type', 'birthday') + else: + birthday_type = getattr(birthday_props, 'type', 'birthday') + + # Validate that type is "birthday" + if birthday_type != "birthday": + raise ValueError(f"Invalid birthday properties type: {birthday_type}. Must be 'birthday'.") + + birthday_property = BirthdayProperties( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + type="birthday" # Always enforce "birthday" for birthday events + ) + session.add(birthday_property) + + def _process_import_birthday_properties(self, db_event: Event, birthday_props, session): + """Process birthday properties from import request""" + from database.models.event import BirthdayProperties + + # Validate that type is "birthday" + if hasattr(birthday_props, 'type') and birthday_props.type != "birthday": + raise ValueError(f"Invalid birthday properties type: {birthday_props.type}. Must be 'birthday'.") + + birthday_property = BirthdayProperties( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + type="birthday" # Always enforce "birthday" for birthday events + ) + session.add(birthday_property) + + def list_events( + self, + user_id: str, + calendar_id: str, + event_types: Optional[str] = None, + ical_uid: Optional[str] = None, + max_attendees: Optional[int] = None, + max_results: Optional[int] = None, + order_by: Optional[str] = None, + page_token: Optional[str] = None, + private_extended_property: Optional[str] = None, + q: Optional[str] = None, + shared_extended_property: Optional[str] = None, + show_deleted: Optional[bool] = None, + show_hidden_invitations: Optional[bool] = None, + single_events: Optional[bool] = None, + sync_token: Optional[str] = None, + time_max: Optional[str] = None, + time_min: Optional[str] = None, + time_zone: Optional[str] = None, + updated_min: Optional[str] = None + ) -> EventListResponse: + """ + List events from calendar with ACL permission and visibility checking + Only returns events the user has permission to see based on visibility settings + + GET /calendars/{calendarId}/events + """ + session = get_session(self.database_id) + try: + # Parse page token to get offset + offset = 0 + if page_token: + try: + offset = self._decode_page_token(page_token) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid page token: {page_token}, error: {e}") + offset = 0 + + # Get calendar and check basic access, optionally filtering by timezone + calendar_query = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id + ) + + # Add timezone filter if provided + if time_zone: + # Validate timezone + self._validate_timezone(time_zone) + calendar_query = calendar_query.filter(Calendar.time_zone == time_zone) + + calendar = calendar_query.first() + if not calendar: + if time_zone: + raise ValueError(f"Calendar {calendar_id} not found with timezone {time_zone}") + else: + raise ValueError(f"Calendar {calendar_id} not found") + + # Get user's role on this calendar + user_role = self._get_user_calendar_role(user_id, calendar) + if user_role == "none": + raise PermissionError(f"User '{user_id}' has no access to calendar '{calendar_id}'") + + # Expand recurring events in the time range if time filters are provided + if time_min or time_max: + try: + time_min_dt = self._parse_datetime_string(time_min) if time_min else datetime.now(timezone.utc) - timedelta(days=30) + time_max_dt = self._parse_datetime_string(time_max) if time_max else datetime.now(timezone.utc) + timedelta(days=90) + + # Generate recurring event instances for the time range + self.expand_recurring_events(user_id, calendar_id, time_min_dt, time_max_dt) + except Exception as e: + logger.warning(f"Error expanding recurring events: {e}") + + # Build base query for all events in the calendar + query = session.query(Event).filter(Event.calendar_id == calendar_id) + + + # Apply visibility filters based on user role + if user_role == "freeBusyReader": + # FreeBusyReader can only see basic event info, no detailed content + # For simplicity, we'll allow them to see public/default events + query = query.filter(Event.visibility.in_(["default", "public"])) + elif user_role == "reader": + # Readers can see public/default events and their own private events + + user = session.query(User).filter(User.user_id == user_id).first() + if user: + query = query.filter( + or_( + Event.visibility.in_(["default", "public"]), + and_(Event.visibility == "private", Event.user_id == user_id) + ) + ) + elif user_role == "writer": + # Writers can see public/default events and their own private events + query = query.filter( + or_( + Event.visibility.in_(["default", "public"]), + and_(Event.visibility == "private", Event.user_id == user_id) + ) + ) + # Owners can see all events (no additional filter) + + # Apply other filters + if not show_deleted: + query = query.filter(Event.status != "cancelled") + else: + query = query.filter(Event.status == "cancelled") + + if time_min: + time_min_dt = self._parse_datetime_string(time_min) + query = query.filter(Event.end_datetime >= time_min_dt) + + if time_max: + time_max_dt = self._parse_datetime_string(time_max) + query = query.filter(Event.start_datetime < time_max_dt) + + if updated_min: + updated_min_dt = self._parse_datetime_string(updated_min) + query = query.filter(Event.updated_at >= updated_min_dt) + + # Handle iCalUID filter (mutually exclusive with q) + if ical_uid: + query = query.filter(Event.iCalUID == ical_uid) + elif q: + # Search in summary, description, location (only if no iCalUID) + search_filter = or_( + Event.summary.ilike(f"%{q}%"), + Event.description.ilike(f"%{q}%"), + Event.location.ilike(f"%{q}%") + ) + query = query.filter(search_filter) + + # Handle eventTypes filter + if event_types: + # Parse comma-separated event types + event_type_list = [t.strip() for t in event_types.split(',') if t.strip()] + if event_type_list: + # Convert string event types to enum values for filtering + enum_values = [] + for event_type in event_type_list: + if event_type == "default": + enum_values.append("default") + elif event_type == "birthday": + enum_values.append("birthday") + elif event_type == "focusTime": + enum_values.append("focusTime") + elif event_type == "fromGmail": + enum_values.append("fromGmail") + elif event_type == "outOfOffice": + enum_values.append("outOfOffice") + elif event_type == "workingLocation": + enum_values.append("workingLocation") + if enum_values: + query = query.filter(Event.eventType.in_(enum_values)) + + + # Handle extended properties filters + if private_extended_property or shared_extended_property: + from database.models.event import ExtendedProperty + + if private_extended_property: + # Parse propertyName=value format + if '=' in private_extended_property: + prop_name, prop_value = private_extended_property.split('=', 1) + query = query.join(ExtendedProperty, Event.event_id == ExtendedProperty.event_id).filter( + and_( + ExtendedProperty.scope == "private", + ExtendedProperty.properties[prop_name].astext == prop_value + ) + ) + + if shared_extended_property: + # Parse propertyName=value format + if '=' in shared_extended_property: + prop_name, prop_value = shared_extended_property.split('=', 1) + query = query.join(ExtendedProperty, Event.event_id == ExtendedProperty.event_id).filter( + and_( + ExtendedProperty.scope == "shared", + ExtendedProperty.properties[prop_name].astext == prop_value + ) + ) + + # Handle showHiddenInvitations filter + # Note: This would require additional database schema to track hidden invitations + # For now, we'll ignore this parameter as it's not implemented in our schema + + # Apply ordering + if order_by == "updated": + query = query.order_by(desc(Event.updated_at)) + else: # Default to startTime + query = query.order_by(asc(Event.start_datetime)) + + # Apply pagination + if max_results or max_results == 0: + query = query.limit(max_results) + + # Apply offset for pagination + if offset > 0: + query = query.offset(offset) + + events = query.all() + + # Filter events based on individual visibility permissions + visible_events = [] + for event in events: + if self.check_event_visibility_permission(user_id, calendar_id, event.event_id): + visible_events.append(event) + + # Convert to schema + event_schemas = [] + for event in visible_events: + event_schema = self._convert_db_event_to_schema(event) + + # Apply maxAttendees filtering to each event + if max_attendees is not None and event_schema.attendees and len(event_schema.attendees) > max_attendees: + event_schema.attendees = event_schema.attendees[:max_attendees] + event_schema.attendeesOmitted = True + + + event_schemas.append(event_schema) + + # Determine response timezone + response_timezone = time_zone or (calendar.time_zone if calendar else "UTC") + + # Determine access role for response + access_role = user_role if user_role != "freeBusyReader" else "reader" + + return EventListResponse( + kind="calendar#events", + etag=f"etag-events-{calendar_id}", + summary=calendar.summary if calendar else None, + description=calendar.description if calendar else None, + updated=datetime.now(timezone.utc).isoformat(), + timeZone=response_timezone, + accessRole=access_role, + defaultReminders=[], + nextPageToken=None, # Pagination not implemented in this simple version + nextSyncToken=None, # Sync not implemented in this simple version + items=event_schemas + ) + + except Exception as e: + logger.error(f"Error listing events for calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def create_recurrence_event_instance(self, session, user_id, recurring_event_id, calendar_id, event_request, query_params, total_occurences, datetime_dict): + # Create database event with Google API fields + # Generate event ID for single event + try: + for occurence in total_occurences: + # Convert string datetime to datetime object + occurrence_dt = self._parse_datetime_string(occurence) + + # Calculate end datetime by adding duration + duration = datetime_dict.get("duration") + end_datetime = occurrence_dt + duration + event_id = str(uuid.uuid4()) + db_event = Event( + event_id=event_id, + calendar_id=calendar_id, + user_id=user_id, + recurring_event_id = recurring_event_id, + summary=event_request.summary, + description=event_request.description, + location=event_request.location, + start_datetime=occurrence_dt, + end_datetime=end_datetime, + start_timezone=datetime_dict.get("start_timezone") or "UTC", + end_timezone=datetime_dict.get("end_timezone") or "UTC", + recurrence=json.dumps(event_request.recurrence) if event_request.recurrence else None, + status=event_request.status or "confirmed", + visibility=event_request.visibility or "default", + sequence=event_request.sequence or 0, + source=event_request.source, + # Store originalStartTime fields + originalStartTime_date=occurrence_dt if "T" not in occurence else None, + originalStartTime_dateTime=occurrence_dt if "T" in occurence else None, + originalStartTime_timeZone=datetime_dict.get("start_timezone") or "UTC", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + + # Handle optional Google API fields + if hasattr(event_request, 'eventType') and event_request.eventType: + try: + db_event.eventType = EventTypeEnum(event_request.eventType) + except ValueError: + # Default to 'default' if eventType is not supported + db_event.eventType = EventTypeEnum.DEFAULT + + if hasattr(event_request, 'colorId') and event_request.colorId: + db_event.color_id = event_request.colorId + + if hasattr(event_request, 'transparency') and event_request.transparency: + db_event.transparency = event_request.transparency + + if hasattr(event_request, 'iCalUID') and event_request.iCalUID: + db_event.iCalUID = event_request.iCalUID + else: + db_event.iCalUID = datetime_dict.get("iCalUID") + + # Handle guest permissions + if hasattr(event_request, 'guestsCanInviteOthers') and event_request.guestsCanInviteOthers is not None: + db_event.guestsCanInviteOthers = event_request.guestsCanInviteOthers + if hasattr(event_request, 'guestsCanModify') and event_request.guestsCanModify is not None: + db_event.guestsCanModify = event_request.guestsCanModify + if hasattr(event_request, 'guestsCanSeeOtherGuests') and event_request.guestsCanSeeOtherGuests is not None: + db_event.guestsCanSeeOtherGuests = event_request.guestsCanSeeOtherGuests + + + # Set organizer information for recurring event instances + creator_user = session.query(User).filter(User.user_id == user_id).first() + if creator_user: + db_event.organizer_id = creator_user.user_id + db_event.organizer_email = creator_user.email + db_event.organizer_display_name = creator_user.name + db_event.organizer_self = True + + session.add(db_event) + session.flush() # Get the event ID for related records + + # Handle attendees if provided + if event_request.attendees: + self._process_create_attendees(db_event, event_request.attendees, session) + + # Handle attachments if provided (only if supported by client) + if event_request.attachments and (query_params is None or (query_params.get('supportsAttachments') if query_params.get('supportsAttachments') is not None else True)): + self._process_create_attachments(db_event, event_request.attachments, session) + + # Handle conference data if provided (based on version) + if event_request.conferenceData and (query_params is None or (query_params.get('conferenceDataVersion') or 0) >= 1): + try: + self._process_create_conference_data(db_event, event_request.conferenceData, session) + except Exception as e: + raise + + # Handle extended properties if provided + if event_request.extendedProperties: + self._process_create_extended_properties(db_event, event_request.extendedProperties, session) + + # Handle reminders if provided + if event_request.reminders: + self._process_create_reminders(db_event, event_request.reminders, session) + + # Handle working location properties if provided + if event_request.workingLocationProperties: + self._process_create_working_location(db_event, event_request.workingLocationProperties, session) + + # Handle type-specific properties + if event_request.eventType == "birthday" and event_request.birthdayProperties: + self._process_create_birthday_properties(db_event, event_request.birthdayProperties, session) + elif event_request.eventType == "focusTime" and event_request.focusTimeProperties: + # Convert Pydantic model to dict for JSON storage + if hasattr(event_request.focusTimeProperties, 'model_dump'): + db_event.focusTimeProperties = event_request.focusTimeProperties.model_dump() + elif hasattr(event_request.focusTimeProperties, 'dict'): + db_event.focusTimeProperties = event_request.focusTimeProperties.dict() + else: + db_event.focusTimeProperties = event_request.focusTimeProperties + elif event_request.eventType == "outOfOffice" and event_request.outOfOfficeProperties: + # Convert Pydantic model to dict for JSON storage + if hasattr(event_request.outOfOfficeProperties, 'model_dump'): + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties.model_dump() + elif hasattr(event_request.outOfOfficeProperties, 'dict'): + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties.dict() + else: + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties + + # Handle source if provided + if event_request.source: + db_event.source = event_request.source + + session.commit() + session.refresh(db_event) + + except Exception as e: + logger.error(f"Error while creating recurring event {e}") + + + + + def create_event(self, user_id: str, calendar_id: str, event_request: EventCreateRequest, query_params) -> EventSchema: + """ + Create a new event following Google Calendar API v3 structure + Handles both single events and recurring events based on recurrence data + + POST /calendars/{calendarId}/events + """ + session = get_session(self.database_id) + try: + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + # Generate event ID for single event + event_id = str(uuid.uuid4()) + + # Parse start and end datetime from Google API format + start_dt, start_tz = self._parse_datetime_from_api_format(event_request.start) + end_dt, end_tz = self._parse_datetime_from_api_format(event_request.end) + + # Validate datetime consistency - handle timezone-aware vs naive comparison + def safe_datetime_compare(dt1, dt2): + """Compare two datetimes, handling timezone awareness differences""" + # If both are naive or both are aware, compare directly + if (dt1.tzinfo is None) == (dt2.tzinfo is None): + return dt1 <= dt2 + + # If one is naive and one is aware, make both naive for comparison + # This is safe for validation since we're just checking chronological order + naive_dt1 = dt1.replace(tzinfo=None) if dt1.tzinfo is not None else dt1 + naive_dt2 = dt2.replace(tzinfo=None) if dt2.tzinfo is not None else dt2 + return naive_dt1 <= naive_dt2 + + # Validate datetime consistency - end_dt should be after start_dt + if safe_datetime_compare(end_dt, start_dt): + raise ValueError("Event end time must be after start time") + + # Validate originalStartTime fields if provided and prepare values for storage + original_start_date = None + original_start_datetime = None + original_start_timezone = None + + if event_request.originalStartTime: + # Validate that originalStartTime matches start field values + if event_request.start.date and event_request.originalStartTime.date: + # Both are all-day events - dates must match + if event_request.start.date != event_request.originalStartTime.date: + raise ValueError("originalStartTime.date must match start.date when both are provided") + original_start_date = parser.parse(event_request.originalStartTime.date).date() + elif event_request.start.dateTime and event_request.originalStartTime.dateTime: + # Both are timed events - dateTime must match + if event_request.start.dateTime != event_request.originalStartTime.dateTime: + raise ValueError("originalStartTime.dateTime must match start.dateTime when both are provided") + original_start_datetime = self._parse_datetime_string(event_request.originalStartTime.dateTime) + elif (event_request.start.date and event_request.originalStartTime.dateTime) or \ + (event_request.start.dateTime and event_request.originalStartTime.date): + # Mismatched types (one is all-day, other is timed) + raise ValueError("originalStartTime and start must both be either all-day (date) or timed (dateTime) events") + + # Validate timezone matches + if event_request.start.timeZone and event_request.originalStartTime.timeZone: + if event_request.start.timeZone != event_request.originalStartTime.timeZone: + raise ValueError("originalStartTime.timeZone must match start.timeZone when both are provided") + + # Store originalStartTime timezone + original_start_timezone = event_request.originalStartTime.timeZone + else: + # If originalStartTime not provided, use start field values + if event_request.start.date: + original_start_date = parser.parse(event_request.start.date).date() + elif event_request.start.dateTime: + original_start_datetime = start_dt + original_start_timezone = start_tz + + + # Validate eventType and birthdayproperties value + if event_request.eventType != "birthday" and event_request.birthdayProperties: + raise ValueError("Use birthday properties only when eventType is set to 'birthday'") + + # Validate eventType and focusTimeProperties + if event_request.eventType != "focusTime" and event_request.focusTimeProperties: + raise ValueError("Use focusTimeProperties only when eventType is set to 'focusTime'") + + # Validate eventType and outOfOfficeProperties + if event_request.eventType != "outOfOffice" and event_request.outOfOfficeProperties: + raise ValueError("Use outOfOfficeProperties only when eventType is set to 'outOfOffice'") + + # Validate maximum number of override reminder + if event_request.reminders and event_request.reminders.overrides: + if len(event_request.reminders.overrides) > 5: + raise ValueError("The maximum number of override reminders is 5") + + # Validate conferenceId based on conference solution type + if event_request.conferenceData and event_request.conferenceData.conferenceSolution: + if event_request.conferenceData.conferenceSolution.key and event_request.conferenceData.conferenceSolution.key.type: + if event_request.conferenceData.conferenceSolution.key.type not in ["hangoutsMeet", "addOn"]: + raise ValueError("Value for type under key field in the conferenceSolution is not valid value. Please provide either 'hangoutsMeet' or 'addOn'") + if event_request.conferenceData.conferenceSolution.key.type == "hangoutsMeet": + if event_request.conferenceData.conferenceId: + raise ValueError("ConferenceId will be set internally. Please remove this field") + else: + chars = string.ascii_lowercase + parts = [ + ''.join(random.choices(chars, k=3)), + ''.join(random.choices(chars, k=4)), + ''.join(random.choices(chars, k=3)) + ] + code = '-'.join(parts) + + event_request.conferenceData.conferenceId = code + + elif event_request.conferenceData.conferenceSolution.key.type == "addOn": + if event_request.conferenceData.conferenceId is None: + raise ValueError("Please provide valid value of conferenceId key for conferenceData or add conferenceId key with valid value if not present") + + if event_request.conferenceData and event_request.conferenceData.createRequest and event_request.conferenceData.createRequest.conferenceSolutionKey: + if event_request.conferenceData.createRequest.conferenceSolutionKey.type not in ["hangoutsMeet", "addOn"]: + raise ValueError("Value for type under conferenceSolutionKey inside the createRequest field is not valid value. Please provide either 'hangoutsMeet' or 'addOn'") + + recurring_event_id = None + total_occurences = [] + # Validate recurrence if present + if event_request.recurrence and len(event_request.recurrence) > 0: + RECURRENCE_LIMIT = 365 + + # Ensure start_dt is timezone-aware before passing to parser + # RecurrenceParser needs timezone-aware datetimes for proper comparison + recurrence_start_dt = start_dt + if recurrence_start_dt.tzinfo is None: + recurrence_start_dt = recurrence_start_dt.replace(tzinfo=timezone.utc) + + try: + # Parse recurrence data with timezone-aware start datetime + parsed_recurrence, rset = RecurrenceParser.parse_recurrence_list(event_request.recurrence, recurrence_start_dt) + + if not parsed_recurrence['rrule']: + raise ValueError("RRULE is required for recurring events") + + logger.info(f"Recurrence rule passed all the validation tests") + + # Generate sample occurrences to validate the rule + sample_occurrences = list(rset[:RECURRENCE_LIMIT]) + total_occurences = [dt.isoformat() for dt in sample_occurrences] + logger.info(f"Generated {len(total_occurences)} sample occurrences from recurrence rule") + + # Create Recurring Event + + # Generate recurring event ID + recurring_event_id = str(uuid.uuid4()) + + # 1. Create a Recurring Event instance + rec_event = RecurringEvent( + recurring_event_id = recurring_event_id, + original_recurrence = json.dumps(event_request.recurrence) + ) + session.add(rec_event) + session.commit() + + except RecurrenceParseError as rpe: + raise ValueError(f"Invalid recurrence pattern: {rpe}") + except Exception as e: + logger.error(f"Error validating recurrence pattern: {e}") + raise ValueError(f"Recurrence validation failed: {e}") + + + # Create database event with Google API fields + db_event = Event( + event_id=event_id, + calendar_id=calendar_id, + user_id=user_id, + recurring_event_id = recurring_event_id, + summary=event_request.summary, + description=event_request.description, + location=event_request.location, + start_datetime=start_dt, + end_datetime=end_dt, + start_timezone=start_tz, + end_timezone=end_tz, + recurrence=json.dumps(event_request.recurrence) if event_request.recurrence else None, + status=event_request.status or "confirmed", + visibility=event_request.visibility or "default", + sequence=event_request.sequence or 0, + source=event_request.source, + # Store originalStartTime fields + originalStartTime_date=original_start_date, + originalStartTime_dateTime=original_start_datetime, + originalStartTime_timeZone=original_start_timezone, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + + # Handle optional Google API fields + if hasattr(event_request, 'eventType') and event_request.eventType: + try: + db_event.eventType = EventTypeEnum(event_request.eventType) + except ValueError: + # Default to 'default' if eventType is not supported + db_event.eventType = EventTypeEnum.DEFAULT + + if hasattr(event_request, 'colorId') and event_request.colorId: + db_event.color_id = event_request.colorId + + if hasattr(event_request, 'transparency') and event_request.transparency: + db_event.transparency = event_request.transparency + + if hasattr(event_request, 'iCalUID') and event_request.iCalUID: + db_event.iCalUID = event_request.iCalUID + else: + db_event.iCalUID = f"{event_id}@calendar.google.com" + + # Handle guest permissions + if hasattr(event_request, 'guestsCanInviteOthers') and event_request.guestsCanInviteOthers is not None: + db_event.guestsCanInviteOthers = event_request.guestsCanInviteOthers + if hasattr(event_request, 'guestsCanModify') and event_request.guestsCanModify is not None: + db_event.guestsCanModify = event_request.guestsCanModify + if hasattr(event_request, 'guestsCanSeeOtherGuests') and event_request.guestsCanSeeOtherGuests is not None: + db_event.guestsCanSeeOtherGuests = event_request.guestsCanSeeOtherGuests + + + # Set organizer information - organizer is the user creating the event + creator_user = session.query(User).filter(User.user_id == user_id).first() + if creator_user: + db_event.organizer_id = creator_user.user_id + db_event.organizer_email = creator_user.email + db_event.organizer_display_name = creator_user.name + db_event.organizer_self = True + + session.add(db_event) + session.flush() # Get the event ID for related records + + # Handle attendees if provided + if event_request.attendees: + self._process_create_attendees(db_event, event_request.attendees, session) + + # Handle attachments if provided (only if supported by client) + if event_request.attachments and (query_params is None or (query_params.get('supportsAttachments') if query_params.get('supportsAttachments') is not None else True)): + self._process_create_attachments(db_event, event_request.attachments, session) + + # Handle conference data if provided (based on version) + if event_request.conferenceData and (query_params is None or (query_params.get('conferenceDataVersion') or 0) >= 1): + try: + self._process_create_conference_data(db_event, event_request.conferenceData, session) + except Exception as e: + raise + + # Handle extended properties if provided + if event_request.extendedProperties: + self._process_create_extended_properties(db_event, event_request.extendedProperties, session) + + # Handle reminders if provided + if event_request.reminders: + self._process_create_reminders(db_event, event_request.reminders, session) + + # Handle working location properties if provided + if event_request.workingLocationProperties: + self._process_create_working_location(db_event, event_request.workingLocationProperties, session) + + # Handle type-specific properties + if event_request.eventType == "birthday" and event_request.birthdayProperties: + self._process_create_birthday_properties(db_event, event_request.birthdayProperties, session) + elif event_request.eventType == "focusTime" and event_request.focusTimeProperties: + # Convert Pydantic model to dict for JSON storage + if hasattr(event_request.focusTimeProperties, 'model_dump'): + db_event.focusTimeProperties = event_request.focusTimeProperties.model_dump() + elif hasattr(event_request.focusTimeProperties, 'dict'): + db_event.focusTimeProperties = event_request.focusTimeProperties.dict() + else: + db_event.focusTimeProperties = event_request.focusTimeProperties + elif event_request.eventType == "outOfOffice" and event_request.outOfOfficeProperties: + # Convert Pydantic model to dict for JSON storage + if hasattr(event_request.outOfOfficeProperties, 'model_dump'): + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties.model_dump() + elif hasattr(event_request.outOfOfficeProperties, 'dict'): + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties.dict() + else: + db_event.outOfOfficeProperties = event_request.outOfOfficeProperties + + # Handle source if provided + if event_request.source: + db_event.source = event_request.source + + session.commit() + session.refresh(db_event) + + # Handle notifications based on sendUpdates parameter + try: + send_updates = query_params.get('sendUpdates') if query_params else None + # Set default to 'none' if not provided or is None + if send_updates is None: + send_updates = 'none' + self._handle_create_notifications(db_event, send_updates, session) + except Exception as e: + logger.info(f"Error sending notifications: {str(e)}") + pass + + # Generate Event instance based on total_occurence + if event_request.recurrence and len(event_request.recurrence) > 0: + # Ensure both datetimes have the same timezone awareness for duration calculation + duration_start_dt = start_dt + duration_end_dt = end_dt + + # Handle timezone awareness consistency for duration calculation + if duration_start_dt.tzinfo is None and duration_end_dt.tzinfo is None: + # Both are naive (all-day events) - this is fine + pass + elif duration_start_dt.tzinfo is None and duration_end_dt.tzinfo is not None: + # start is naive, end is aware - make start aware + duration_start_dt = duration_start_dt.replace(tzinfo=timezone.utc) + elif duration_start_dt.tzinfo is not None and duration_end_dt.tzinfo is None: + # start is aware, end is naive - make end aware + duration_end_dt = duration_end_dt.replace(tzinfo=timezone.utc) + + duration = duration_end_dt - duration_start_dt + datetime_dict = { + "duration": duration, + "start_timezone": start_tz, + "end_timezone": end_tz, + "iCalUID": db_event.iCalUID + } + self.create_recurrence_event_instance(session, user_id, recurring_event_id, calendar_id, event_request, query_params, total_occurences, datetime_dict) + + logger.info(f"Created event {event_id} in calendar {calendar_id}") + return self._convert_db_event_to_schema(db_event) + + except RecurrenceParseError as rperr: + raise ValueError(f"{str(rperr)}") + except ValueError as verr: + session.rollback() + raise ValueError(f"{str(verr)}") + except Exception as e: + logger.error(f"Error creating event in calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def get_event(self, user_id: str, calendar_id: str, event_id: str, timeZone: str = None, maxAttendees: Optional[int] = None) -> Optional[EventSchema]: + """ + Get a specific event + + GET /calendars/{calendarId}/events/{eventId} + """ + session = get_session(self.database_id) + try: + # Verify calendar belongs to user + if not timeZone or not timeZone.strip(): + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + else: + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id, + Calendar.time_zone == timeZone + ).first() + + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id} in the timezone {timeZone}") + db_event = session.query(Event).options( + joinedload(Event.attendees).joinedload(Attendees.user), + joinedload(Event.attachments), + joinedload(Event.reminders) + ).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id, Event.user_id == user_id) + ).first() + + if not db_event: + return None + + event_schema = self._convert_db_event_to_schema(db_event) + + # Filter attendees based on maxAttendees parameter + if maxAttendees is not None and event_schema.attendees and len(event_schema.attendees) > maxAttendees: + event_schema.attendees = event_schema.attendees[:maxAttendees] + event_schema.attendeesOmitted = True + + return event_schema + + except Exception as e: + logger.error(f"Error getting event {event_id} from calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def _validate_email_pattern(self, email: str) -> bool: + """Validate email pattern using regex""" + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(email_pattern, email) is not None + + def _create_user_from_email(self, session, email: str) -> User: + """Create a new user from email address""" + try: + # Generate user data from email + name = email.split('@')[0] # Use email prefix as default name + user_data = { + "user_id": str(uuid.uuid4()), + "email": email, + "name": name, + "static_token": str(uuid.uuid4()) # Required field for User model + } + + # Create new user instance + user = User( + user_id=user_data["user_id"], + email=user_data["email"], + name=user_data["name"], + static_token=user_data["static_token"], + is_active=True, + is_verified=False, + timezone="UTC" + ) + + session.add(user) + session.commit() + logger.info(f"Created new user with email: {email}") + return user + + except Exception as e: + logger.error(f"Error creating user from email {email}: {e}") + raise ValueError(f"Failed to create user for email {email}: {e}") + + def check_attendees_email_id(self, session, value): + for v in value: + # Handle both dictionary and Pydantic model formats + if isinstance(v, dict): + email_id = v.get('email') + else: + # Assume it's a Pydantic model with .email attribute + email_id = getattr(v, 'email', None) + + if not email_id: + raise ValueError("Email address is required for all attendees") + + user = session.query(User).filter(User.email == email_id).first() + if user is None: + # Validate email pattern + if not self._validate_email_pattern(email_id): + raise ValueError(f"Invalid email format: {email_id}") + + # Create new user based on email + try: + user = self._create_user_from_email(session, email_id) + except Exception as e: + raise ValueError(f"Failed to create attendees for email {email_id}: {e}") + + def update_event( + self, + user_id: str, + calendar_id: str, + event_id: str, + event_request: EventUpdateRequest, + is_patch: bool = True, + query_params: Optional[Dict[str, Any]] = None + ) -> Optional[EventSchema]: + """ + Update an event (PATCH or PUT) + + PATCH /calendars/{calendarId}/events/{eventId} + PUT /calendars/{calendarId}/events/{eventId} + """ + session = get_session(self.database_id) + try: + # Set default query_params if not provided + if query_params is None: + query_params = {} + + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + db_event = session.query(Event).options( + joinedload(Event.attendees).joinedload(Attendees.user), + joinedload(Event.attachments) + ).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id, Event.user_id == user_id) + ).first() + + if not db_event: + return None + + # Update fields based on request + update_data = event_request.model_dump(exclude_none=is_patch) + + # Validation to check the eventType is not getting changed + if "eventType" in update_data.keys() and update_data.get("eventType") is not None: + if db_event.eventType.value != update_data.get("eventType").value: + raise ValueError(f"EventType once set can not be modified. Expecting value: {db_event.eventType.value}. Got {update_data.get('eventType').value}") + + for field, value in update_data.items(): + if field == "start" and value: + # Handle start DateTime object with timezone + start_dt, start_tz = self._parse_datetime_from_api_format(value) + db_event.start_datetime = start_dt + db_event.start_timezone = start_tz + + # Validate datetime consistency if end is also being updated + if hasattr(event_request, 'end') and event_request.end: + end_dt, _ = self._parse_datetime_from_api_format(event_request.end) + if end_dt <= start_dt: + raise ValueError("Event end time must be after start time") + elif field == "end" and value: + # Handle end DateTime object with timezone + end_dt, end_tz = self._parse_datetime_from_api_format(value) + db_event.end_datetime = end_dt + db_event.end_timezone = end_tz + + # Validate datetime consistency if start is also being updated + if hasattr(event_request, 'start') and event_request.start: + start_dt, _ = self._parse_datetime_from_api_format(event_request.start) + if end_dt <= start_dt: + raise ValueError("Event end time must be after start time") + # Also validate against existing start time if start is not being updated + elif end_dt <= db_event.start_datetime: + raise ValueError("Event end time must be after start time") + elif field == "colorId" and value: + db_event.color_id = value + elif field == "attendees" and value: + # Check whether email id exist + self.check_attendees_email_id(session, value) + + # Remove all the attendees before setting + attendees = session.query(Attendees).filter(Attendees.event_id == event_id).all() + for att in attendees: + session.delete(att) + session.commit() + + for v in value: + # Handle both dictionary and Pydantic model formats consistently + if isinstance(v, dict): + email = v.get('email') + display_name = v.get('displayName') + response_status = v.get('responseStatus', 'needsAction') + comment = v.get('comment') + additional_guests = v.get('additionalGuests', 0) + resource = v.get('resource', False) + optional = v.get('optional', False) + else: + # Assume it's a Pydantic model + email = getattr(v, 'email', None) + display_name = getattr(v, 'displayName', None) + response_status = getattr(v, 'responseStatus', 'needsAction') + comment = getattr(v, 'comment', None) + additional_guests = getattr(v, 'additionalGuests', 0) + resource = getattr(v, 'resource', False) + optional = getattr(v, 'optional', False) + + if not email: + continue + + user = session.query(User).filter(User.email == email).first() + if user is None: + continue + + att_obj = session.query(Attendees).filter( + and_(Attendees.event_id == event_id, Attendees.user_id == user.user_id) + ).first() + if att_obj is None: + att_obj = Attendees( + attendees_id=str(uuid.uuid4()), + event_id=event_id, + user_id=user.user_id + ) + session.add(att_obj) + session.flush() + + # Update attendee attributes + att_obj.responseStatus = response_status + att_obj.comment = comment + att_obj.displayName = display_name + att_obj.additionalGuests = additional_guests + att_obj.resource = resource + att_obj.optional = optional + + session.commit() + elif field == "recurrence" and value: + if isinstance(value, list): + db_event.recurrence = json.dumps(value) + else: + db_event.recurrence = value + elif field == "attachments" and value: + # Handle attachments (only if supported by client) + if query_params.get('supportsAttachments', True): + # Clear existing attachments first + from database.models.event import Attachment + session.query(Attachment).filter(Attachment.event_id == event_id).delete() + session.flush() + + # Process new attachments + self._process_create_attachments(db_event, value, session) + elif field == "conferenceData" and value and value != {}: + # Handle conference data (based on version) + conference_version = query_params.get('conferenceDataVersion', 0) + if conference_version and conference_version >= 1 and value and value != {}: + # Clear existing conference data first + from database.models.event import ConferenceData as DBConferenceData + session.query(DBConferenceData).filter(DBConferenceData.event_id == event_id).delete() + session.flush() + + # Process new conference data - ensure it's treated as dictionary + if not isinstance(value, dict): + # Convert Pydantic model to dict if needed + if hasattr(value, 'model_dump'): + value = value.model_dump() + elif hasattr(value, 'dict'): + value = value.dict() + else: + # Try to convert to dict manually + value = {k: getattr(value, k) for k in dir(value) if not k.startswith('_') and not callable(getattr(value, k))} + + # Process new conference data + self._process_create_conference_data(db_event, value, session) + elif field == "birthdayProperties" and value and value != {}: + # Handle birthday properties with type immutability check + existing_birthday = None + if hasattr(db_event, 'birthdayProperties') and db_event.birthdayProperties: + existing_birthday = db_event.birthdayProperties + + # Get birthday type from value + birthday_type = value.get('type', 'birthday') if isinstance(value, dict) else getattr(value, 'type', 'birthday') + + # Validate type immutability - birthday event type cannot be changed + if existing_birthday and birthday_type != existing_birthday.type: + raise ValueError(f"Birthday event type cannot be changed. Current type: {existing_birthday.type}") + + # For new birthday properties, validate type is "birthday" + if birthday_type != "birthday": + raise ValueError(f"Invalid birthday properties type: {birthday_type}. Must be 'birthday'.") + + # If no existing birthday properties and we have valid data, create new ones + if not existing_birthday: + self._process_create_birthday_properties(db_event, value, session) + elif field == "extendedProperties" and value and value != {}: + # Handle extended properties + from database.models.event import ExtendedProperty + # Clear existing extended properties first + session.query(ExtendedProperty).filter(ExtendedProperty.event_id == event_id).delete() + session.flush() + + # Ensure value is a dictionary + if not isinstance(value, dict): + if hasattr(value, 'model_dump'): + value = value.model_dump() + elif hasattr(value, 'dict'): + value = value.dict() + + # Process new extended properties + self._process_create_extended_properties(db_event, value, session) + elif field == "reminders" and value and value != {}: + # Handle reminders - only if not empty dict + # Ensure value is in the right format + processed_value = value + if not isinstance(value, dict): + if hasattr(value, 'model_dump'): + processed_value = value.model_dump() + elif hasattr(value, 'dict'): + processed_value = value.dict() + + # Check if it has the expected reminder structure + if (hasattr(processed_value, 'useDefault') or hasattr(processed_value, 'overrides') or + (isinstance(processed_value, dict) and ('useDefault' in processed_value or 'overrides' in processed_value))): + # Clear existing reminders first + + session.query(Reminder).filter(Reminder.event_id == event_id).delete() + session.flush() + + # Process new reminders + self._process_create_reminders_for_update(db_event, processed_value, session) + elif field in ["guestsCanInviteOthers", "guestsCanModify", "guestsCanSeeOtherGuests"]: + # Only update Boolean fields if value is not None to avoid null constraint violations + if value is not None: + setattr(db_event, field, value) + elif field in ["focusTimeProperties", "outOfOfficeProperties", "workingLocationProperties", "source"] and value and value != {}: + # Handle other object fields - only if not empty dict + if hasattr(db_event, field): + # Convert Pydantic models to dict for JSON storage + if field in ["focusTimeProperties", "outOfOfficeProperties"]: + try: + # Convert Pydantic model to dict + if hasattr(value, 'model_dump'): + processed_value = value.model_dump() + elif hasattr(value, 'dict'): + processed_value = value.dict() + setattr(db_event, field, processed_value) + except: + pass + elif field == "workingLocationProperties": + # Handle working location properties separately to avoid issues + if hasattr(value, 'model_dump'): + working_location_data = value.model_dump() + elif hasattr(value, 'dict'): + working_location_data = value.dict() + else: + working_location_data = value + + # Clear existing working location properties first + from database.models.event import WorkingLocationProperties + session.query(WorkingLocationProperties).filter(WorkingLocationProperties.event_id == event_id).delete() + session.flush() + + # Process new working location properties + self._process_create_working_location(db_event, working_location_data, session) + continue + + elif hasattr(db_event, field) and value is not None and value != {}: + # Only set attribute if value is not None and not empty dict to avoid null constraint violations + setattr(db_event, field, value) + # Incremenet event versioning + try: + db_event.sequence += 1 + except: + db_event.sequence = 0 + db_event.updated_at = datetime.now(timezone.utc) + + session.commit() + + # Reload the event with all relationships for the response + db_event_with_relations = session.query(Event).options( + joinedload(Event.attendees).joinedload(Attendees.user), + joinedload(Event.attachments) + ).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id, Event.user_id == user_id) + ).first() + + logger.info(f"Updated event {event_id} in calendar {calendar_id}") + logger.info(f"Event has {len(db_event_with_relations.attendees) if db_event_with_relations.attendees else 0} attendees") + logger.info(f"Event has {len(db_event_with_relations.attachments) if db_event_with_relations.attachments else 0} attachments") + + return self._convert_db_event_to_schema(db_event_with_relations) + + except Exception as e: + session.rollback() + logger.error(f"Error updating event {event_id} in calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def delete_event(self, user_id: str, calendar_id: str, event_id: str, send_updates: Optional[str] = "all") -> bool: + """ + Delete an event with notification control + + Args: + user_id: User performing the deletion + calendar_id: Calendar containing the event + event_id: Event to delete + send_updates: Who should receive notifications: + - "all": Notifications sent to all guests + - "externalOnly": Notifications sent to non-Google Calendar guests only + - "none": No notifications sent + + DELETE /calendars/{calendarId}/events/{eventId} + """ + session = get_session(self.database_id) + try: + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id, Event.user_id == user_id) + ).first() + + if not db_event: + return False + + try: + # Handle notification logic based on sendUpdates parameter + self._handle_notifications(db_event, send_updates, session) + except Exception as e: + logger.info(f"Error sending notifications: {str(e)}") + pass + + session.delete(db_event) + session.commit() + + logger.info(f"Deleted event {event_id} from calendar {calendar_id}") + return True + + except Exception as e: + session.rollback() + logger.error(f"Error deleting event {event_id} from calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def move_event( + self, + user_id: str, + calendar_id: str, + event_id: str, + move_request: EventMoveRequest + ) -> Optional[EventSchema]: + """ + Move an event to another calendar + + POST /calendars/{calendarId}/events/{eventId}/move + """ + session = get_session(self.database_id) + try: + # Verify source calendar belongs to user + source_calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not source_calendar: + raise ValueError(f"Source calendar {calendar_id} not found for user {user_id}") + + db_event = session.query(Event).filter( + and_(Event.calendar_id == calendar_id, Event.event_id == event_id, Event.user_id == user_id) + ).first() + + if not db_event: + return None + + # Check if destination calendar exists and belongs to user + dest_calendar = session.query(Calendar).filter( + Calendar.calendar_id == move_request.destination, + Calendar.user_id == user_id + ).first() + + if not dest_calendar: + raise ValueError(f"Destination calendar {move_request.destination} not found for user {user_id}") + + # Move event to destination calendar + db_event.calendar_id = move_request.destination + db_event.updated_at = datetime.now(timezone.utc) + + session.commit() + session.refresh(db_event) + + try: + # Handle notification logic based on sendUpdates parameter + self._handle_notifications(db_event, move_request.sendUpdates, session) + except Exception as e: + logger.info(f"Error sending notifications: {str(e)}") + pass + + logger.info(f"Moved event {event_id} from {calendar_id} to {move_request.destination}") + return self._convert_db_event_to_schema(db_event) + + except Exception as e: + session.rollback() + logger.error(f"Error moving event {event_id} from calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def quick_add_event( + self, + user_id: str, + calendar_id: str, + quick_add_request: EventQuickAddRequest + ) -> EventSchema: + """ + Quick add an event using natural language + + POST /calendars/{calendarId}/events/quickAdd + """ + session = get_session(self.database_id) + try: + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + # Simple parsing for quick add (in real implementation, use NLP) + text = quick_add_request.text + + # Basic parsing - just create event with text as summary + # In real implementation, parse text for date/time/location + import uuid + event_id = str(uuid.uuid4()) + + # Use current time + 1 hour as default + now = datetime.now(timezone.utc) + start_dt = now.replace(minute=0, second=0, microsecond=0) + end_dt = start_dt.replace(hour=start_dt.hour + 1) + + db_event = Event( + event_id=event_id, + calendar_id=calendar_id, + user_id=user_id, + summary=text, + start_datetime=start_dt, + end_datetime=end_dt, + status="confirmed", + visibility="default", + created_at=now, + updated_at=now + ) + + # Set organizer information for quick add events + creator_user = session.query(User).filter(User.user_id == user_id).first() + if creator_user: + db_event.organizer_id = creator_user.user_id + db_event.organizer_email = creator_user.email + db_event.organizer_display_name = creator_user.name + db_event.organizer_self = True + + session.add(db_event) + session.commit() + session.refresh(db_event) + + try: + # Handle notification logic based on sendUpdates parameter + self._handle_notifications(db_event, quick_add_request.sendUpdates, session) + except Exception as e: + logger.info(f"Error sending notifications: {str(e)}") + pass + + logger.info(f"Quick added event {event_id} in calendar {calendar_id}") + return self._convert_db_event_to_schema(db_event) + + except Exception as e: + session.rollback() + logger.error(f"Error quick adding event to calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def import_event( + self, + user_id: str, + calendar_id: str, + event_request: EventImportRequest, + query_params: EventImportQueryParams + ) -> EventImportResponse: + """ + Import an event as a private copy to the specified calendar. + This operation is used to add a private copy of an existing event to a calendar. + + Features: + - Validates event data and user permissions + - Handles event type conversion (non-default types may be converted to default) + - Manages iCalUID uniqueness + - Processes attachments and conference data based on client capabilities + - Creates a complete private copy with proper metadata + + POST /calendars/{calendarId}/events/import + """ + session = get_session(self.database_id) + try: + # Step 1: Validate calendar access + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + # Step 2: Validate import request + validation_result = self._validate_import_request( + user_id, calendar_id, event_request, session + ) + if not validation_result.success: + raise ValueError(validation_result.error.message) + + # Check for existing iCalUID conflicts + existing_event = session.query(Event).filter( + Event.user_id == user_id, + Event.calendar_id == calendar_id, + Event.iCalUID == event_request.iCalUID + ).first() + + if existing_event is None: + raise ValueError("Event does not exist with this calendar_id and iCalUID") + if existing_event.eventType.value != "default": + raise ValueError("Only event with eventType 'default' can be imported") + + # Step 4: Generate event ID and handle iCalUID + + event_id = str(uuid.uuid4()) + + # Step 5: Parse and validate datetime fields + start_date, start_dt, end_date, end_dt, start_tz, end_tz = self._parse_import_datetimes(event_request) + + # Validate originalStartTime fields if provided and prepare values for storage + original_start_date = None + original_start_datetime = None + original_start_timezone = None + + if event_request.originalStartTime: + # Validate that originalStartTime matches start field values + if event_request.start.date and event_request.originalStartTime.date: + # Both are all-day events - dates must match + if event_request.start.date != event_request.originalStartTime.date: + raise ValueError("originalStartTime.date must match start.date when both are provided") + original_start_date = parser.parse(event_request.originalStartTime.date).date() + elif event_request.start.dateTime and event_request.originalStartTime.dateTime: + # Both are timed events - dateTime must match + if event_request.start.dateTime != event_request.originalStartTime.dateTime: + raise ValueError("originalStartTime.dateTime must match start.dateTime when both are provided") + original_start_datetime = self._parse_datetime_string(event_request.originalStartTime.dateTime) + elif (event_request.start.date and event_request.originalStartTime.dateTime) or \ + (event_request.start.dateTime and event_request.originalStartTime.date): + # Mismatched types (one is all-day, other is timed) + raise ValueError("originalStartTime and start must both be either all-day (date) or timed (dateTime) events") + + # Validate timezone matches + if event_request.start.timeZone and event_request.originalStartTime.timeZone: + if event_request.start.timeZone != event_request.originalStartTime.timeZone: + raise ValueError("originalStartTime.timeZone must match start.timeZone when both are provided") + + # Store originalStartTime timezone + original_start_timezone = event_request.originalStartTime.timeZone + else: + # If originalStartTime not provided, use start field values + if event_request.start.date: + original_start_date = parser.parse(event_request.start.date).date() + elif event_request.start.dateTime: + original_start_datetime = start_dt + original_start_timezone = start_tz + + # Validate eventType and focusTimeProperties + if event_request.focusTimeProperties: + raise ValueError("focusTimeProperties is not required as only 'default' eventType can be imported") + + # Validate eventType and outOfOfficeProperties + if event_request.outOfOfficeProperties: + raise ValueError("outOfOfficeProperties is not required as only 'default' eventType can be imported") + + # Validate maximum number of override reminder + if event_request.reminders and event_request.reminders.overrides: + if len(event_request.reminders.overrides) > 5: + raise ValueError("The maximum number of override reminders is 5") + + # Validate organizer email and displayName + if event_request.organizer: + # Find or create user for organizer + organizer_user = session.query(User).filter(User.email == event_request.organizer.email).first() + if organizer_user is None: + raise ValueError("Organizer email does not exist") + else: + organizer_display_name = session.query(User).filter(User.email == event_request.organizer.email, User.name == event_request.organizer.displayName).first() + if organizer_display_name is None: + raise ValueError("Please enter valid name in the organizer displayName") + + # Validate conferenceId based on conference solution type + if event_request.conferenceData and event_request.conferenceData.conferenceSolution: + if event_request.conferenceData.conferenceSolution.key and event_request.conferenceData.conferenceSolution.key.type: + if event_request.conferenceData.conferenceSolution.key.type not in ["hangoutsMeet", "addOn"]: + raise ValueError("Value for type under key field in the conferenceSolution is not valid value. Please provide either 'hangoutsMeet' or 'addOn'") + if event_request.conferenceData.conferenceSolution.key.type == "hangoutsMeet": + if event_request.conferenceData.conferenceId: + raise ValueError("ConferenceId will be set internally. Please remove this field") + else: + chars = string.ascii_lowercase + parts = [ + ''.join(random.choices(chars, k=3)), + ''.join(random.choices(chars, k=4)), + ''.join(random.choices(chars, k=3)) + ] + code = '-'.join(parts) + + event_request.conferenceData.conferenceId = code + + elif event_request.conferenceData.conferenceSolution.key.type == "addOn": + if event_request.conferenceData.conferenceId is None: + raise ValueError("Please provide valid value of conferenceId key for conferenceData or add conferenceId key with valid value if not present") + + + # Check if this is an all-day event + is_all_day = self._is_all_day_event(start_date, end_date) + + # Step 6: Create the imported event as private copy + db_event = Event( + event_id=event_id, + calendar_id=calendar_id, + user_id=user_id, + summary=event_request.summary, + description=event_request.description, + location=event_request.location, + start_datetime=start_dt, + end_datetime=end_dt, + start_timezone=start_tz, + end_timezone=end_tz, + iCalUID=event_request.iCalUID, + status=event_request.status or "confirmed", + visibility=event_request.visibility or "default", + sequence=event_request.sequence or 0, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + + # Handle recurrence + if event_request.recurrence: + db_event.recurrence = json.dumps(event_request.recurrence) + + # Handle guest permissions + if hasattr(event_request, 'guestsCanInviteOthers') and event_request.guestsCanInviteOthers is not None: + db_event.guestsCanInviteOthers = event_request.guestsCanInviteOthers + if hasattr(event_request, 'guestsCanModify') and event_request.guestsCanModify is not None: + db_event.guestsCanModify = event_request.guestsCanModify + if hasattr(event_request, 'guestsCanSeeOtherGuests') and event_request.guestsCanSeeOtherGuests is not None: + db_event.guestsCanSeeOtherGuests = event_request.guestsCanSeeOtherGuests + + # Set organizer information based on import request or default to creator + if event_request.organizer: + # Find organizer user + organizer_user = session.query(User).filter(User.email == event_request.organizer.email).first() + if organizer_user: + # Set organizer fields from the provided organizer + db_event.organizer_id = organizer_user.user_id + db_event.organizer_email = organizer_user.email + db_event.organizer_display_name = event_request.organizer.displayName or organizer_user.name + # Check if organizer is the current user + db_event.organizer_self = (organizer_user.user_id == user_id) + + # Update event's user_id to organizer (import allows organizer change) + db_event.user_id = organizer_user.user_id + else: + logger.warning(f"Organizer email '{event_request.organizer.email}' not found in database") + # Default to current user as organizer + creator_user = session.query(User).filter(User.user_id == user_id).first() + if creator_user: + db_event.organizer_id = creator_user.user_id + db_event.organizer_email = creator_user.email + db_event.organizer_display_name = creator_user.name + db_event.organizer_self = True + else: + # Default to current user as organizer + creator_user = session.query(User).filter(User.user_id == user_id).first() + if creator_user: + db_event.organizer_id = creator_user.user_id + db_event.organizer_email = creator_user.email + db_event.organizer_display_name = creator_user.name + db_event.organizer_self = True + + session.add(db_event) + session.flush() # Get the event ID for related records + + # Step 8: Handle attendees + self._process_import_attendees(db_event, event_request.attendees, session, event_request.organizer) + + # Step 9: Handle attachments (if supported) + if (query_params.supportsAttachments if query_params.supportsAttachments is not None else True) and event_request.attachments: + self._process_import_attachments(db_event, event_request.attachments, session) + + # Step 10: Handle conference data (based on version) + try: + if event_request.conferenceData and (query_params.conferenceDataVersion or 0) >= 1: + self._process_create_conference_data(db_event, event_request.conferenceData, session) + except Exception as e: + logger.warning(f"Error while creating conference data{e}") + + + # Step 11: Handle extended properties + if event_request.extendedProperties: + self._process_import_extended_properties( + db_event, event_request.extendedProperties, session + ) + + # Step 12: Handle reminders + if event_request.reminders: + self._process_import_reminders(db_event, event_request.reminders, session) + + # Step 13: Handle source + if event_request.source: + db_event.source = event_request.source.model_dump() + + session.commit() + session.refresh(db_event) + + # Step 15: Build import response + response = self._convert_db_event_to_schema(db_event) + + # Handle attendeesOmitted from import request + if hasattr(event_request, 'attendeesOmitted') and event_request.attendeesOmitted: + response.attendeesOmitted = True + + logger.info(f"Successfully imported event {event_id} to calendar {calendar_id}") + return response + + except Exception as e: + session.rollback() + logger.error(f"Error importing event to calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def _validate_import_request( + self, user_id: str, calendar_id: str, + event_request: EventImportRequest, session + ) -> EventImportResult: + """Validate import request and permissions""" + try: + # Validate required fields + if not event_request.start or not event_request.end: + return EventImportResult( + success=False, + error=EventImportError( + domain="calendar", + reason="required", + message="Both 'start' and 'end' fields are required" + ) + ) + + # Validate datetime consistency + if event_request.start.dateTime and event_request.end.dateTime: + start_dt = self._parse_datetime_string(event_request.start.dateTime) + end_dt = self._parse_datetime_string(event_request.end.dateTime) + if end_dt <= start_dt: + return EventImportResult( + success=False, + error=EventImportError( + domain="calendar", + reason="invalid", + message="Event end time must be after start time" + ) + ) + + return EventImportResult(success=True) + + except Exception as e: + return EventImportResult( + success=False, + error=EventImportError( + domain="calendar", + reason="invalid", + message=f"Validation error: {str(e)}" + ) + ) + + def _handle_event_type_conversion( + self, original_type: str, event_request: EventImportRequest + ) -> tuple[str, list[str]]: + """Handle event type conversion and return warnings""" + warnings = [] + + # Check if event type is supported + supported_types = ["default", "birthday", "focusTime", "fromGmail", "outOfOffice", "workingLocation"] + + if original_type not in supported_types: + warnings.append(f"Unsupported event type '{original_type}' converted to 'default'") + return "default", warnings + + # Check if type-specific properties are present + if original_type == "workingLocation" and not event_request.workingLocationProperties: + warnings.append("workingLocation event type requires workingLocationProperties") + return "default", warnings + + if original_type == "focusTime" and not event_request.focusTimeProperties: + warnings.append("focusTime event type requires focusTimeProperties") + return "default", warnings + + if original_type == "outOfOffice" and not event_request.outOfOfficeProperties: + warnings.append("outOfOffice event type requires outOfOfficeProperties") + return "default", warnings + + return original_type, warnings + + def _parse_import_datetimes( + self, event_request: EventImportRequest + ) -> tuple[str, datetime, str, datetime, str, str]: + """Parse start and end datetime from import request with proper all-day event handling""" + from datetime import date + + # Handle start datetime + start_date = None + if event_request.start.dateTime: + start_dt = self._parse_datetime_string(event_request.start.dateTime) + start_tz = self._validate_timezone(event_request.start.timeZone or "UTC") + elif event_request.start.date: + # All-day event - parse date string and create datetime at midnight UTC + try: + start_date = event_request.start.date + parsed_date = date.fromisoformat(start_date) + start_dt = datetime.combine(parsed_date, datetime.min.time()) + start_tz = self._validate_timezone(event_request.start.timeZone or "UTC") + except ValueError as e: + raise ValueError(f"Invalid start date format '{event_request.start.date}': {e}") + else: + raise ValueError("Event start must have either dateTime or date") + + # Handle end datetime + end_date = None + if event_request.end.dateTime: + end_dt = self._parse_datetime_string(event_request.end.dateTime) + end_tz = self._validate_timezone(event_request.end.timeZone or "UTC") + elif event_request.end.date: + # All-day event - parse date string and create datetime at midnight UTC + try: + end_date = event_request.end.date + parsed_date = date.fromisoformat(end_date) + end_dt = datetime.combine(parsed_date, datetime.min.time()) + end_tz = self._validate_timezone(event_request.end.timeZone or "UTC") + except ValueError as e: + raise ValueError(f"Invalid end date format '{event_request.end.date}': {e}") + else: + raise ValueError("Event end must have either dateTime or date") + + # Validate datetime consistency + if end_dt <= start_dt: + raise ValueError("Event end time must be after start time") + + return start_date, start_dt, end_date, end_dt, start_tz, end_tz + + def _validate_timezone(self, tz_name: str) -> str: + """Validate IANA timezone name""" + if tz_name is None: + return tz_name + try: + from dateutil.tz import gettz + if gettz(tz_name) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + # If dateutil is unavailable or another error occurs + raise ValueError("Invalid timeZone; must be a valid IANA timezone name.") + return tz_name + + def _is_all_day_event(self, start_date: str, end_date: str) -> bool: + """Check if the event is an all-day event based on date fields""" + return start_date is not None and end_date is not None + + def _process_import_attendees(self, db_event: Event, attendees_data: list, session, organizer=None): + """Process attendees from import request with organizer handling""" + if not attendees_data: + return + + from database.models.event import Attendees + + organizer_email = organizer.email if organizer else None + + for attendee_data in attendees_data: + # Find or create user based on email + user = session.query(User).filter(User.email == attendee_data.email).first() + if not user: + continue # Skip unknown users + + # Check if this attendee is the organizer + is_organizer = (organizer_email and attendee_data.email == organizer_email) + + attendee = Attendees( + attendees_id=str(uuid.uuid4()), + event_id=db_event.event_id, + user_id=user.user_id, + displayName=attendee_data.displayName or (organizer.displayName if is_organizer and organizer else None), + optional=attendee_data.optional or False, + resource=attendee_data.resource or False, + responseStatus=attendee_data.responseStatus or "needsAction", + comment=attendee_data.comment, + additionalGuests=attendee_data.additionalGuests or 0 + ) + session.add(attendee) + + def _process_import_attachments(self, db_event: Event, attachments_data: list, session): + """Process attachments from import request""" + if not attachments_data: + return + + from database.models.event import Attachment + + for attachment_data in attachments_data: + attachment = Attachment( + attachment_id=str(uuid.uuid4()), + event_id=db_event.event_id, + file_url=attachment_data.fileUrl + ) + session.add(attachment) + + def _process_import_conference_data( + self, db_event: Event, conference_data, version: int, session + ): + """Process conference data from import request using new comprehensive schema""" + if version < 1: + return # Conference data not supported in version 0 + + from database.models.event import ConferenceData as DBConferenceData + + # Handle both old dict format and new Pydantic ConferenceData model + conference_id = None + request_id = None + solution_type = None + meeting_uri = None + label = None + status_code = None + + if hasattr(conference_data, 'conferenceId'): + # New Pydantic ConferenceData model + conference_id = conference_data.conferenceId + + # Extract from conferenceSolution if present + if conference_data.conferenceSolution: + solution_type = conference_data.conferenceSolution.key.type.value + label = conference_data.conferenceSolution.name + + # Extract meeting URI from first video entry point + if conference_data.entryPoints: + for entry_point in conference_data.entryPoints: + if hasattr(entry_point, 'entryPointType') and entry_point.entryPointType.value == 'video': + meeting_uri = entry_point.uri + break + + # Extract from createRequest if present + if conference_data.createRequest: + request_id = conference_data.createRequest.requestId + solution_type = conference_data.createRequest.conferenceSolutionKey.type.value + if conference_data.createRequest.status: + status_code = conference_data.createRequest.status.statusCode.value + else: + # Legacy dict format support for backward compatibility + conference_id = conference_data.get('conferenceId') if isinstance(conference_data, dict) else None + if isinstance(conference_data, dict): + if conference_data.get('createRequest'): + request_id = conference_data['createRequest'].get('requestId') + if conference_data.get('conferenceSolution'): + solution_type = conference_data['conferenceSolution'].get('key', {}).get('type') + label = conference_data['conferenceSolution'].get('name') + if conference_data.get('entryPoints') and len(conference_data['entryPoints']) > 0: + meeting_uri = conference_data['entryPoints'][0].get('uri') + + # Create database conference record + db_conference = DBConferenceData( + id=conference_id or str(uuid.uuid4()), + event_id=db_event.event_id, + request_id=request_id, + solution_type=solution_type, + status_code=status_code, + meeting_uri=meeting_uri, + label=label + ) + session.add(db_conference) + + def _process_import_extended_properties(self, db_event: Event, ext_props, session): + """Process extended properties from import request""" + from database.models.event import ExtendedProperty + + if ext_props.private: + private_prop = ExtendedProperty( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + scope="private", + properties=ext_props.private + ) + session.add(private_prop) + + if ext_props.shared: + shared_prop = ExtendedProperty( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + scope="shared", + properties=ext_props.shared + ) + session.add(shared_prop) + + def _process_import_working_location(self, db_event: Event, working_location, session): + """Process working location properties from import request""" + from database.models.event import WorkingLocationProperties, OfficeLocation + + # Create working location + working_loc = WorkingLocationProperties( + working_location_id=str(uuid.uuid4()), + event_id=db_event.event_id, + type=working_location.type, + homeOffice=working_location.homeOffice, + customLocationLabel=working_location.customLocation.get("label") if working_location.customLocation else None + ) + + # Handle office location if specified + if working_location.officeLocation: + office_loc = OfficeLocation( + id=str(uuid.uuid4()), + label=working_location.officeLocation.get("label", ""), + buildingId=working_location.officeLocation.get("buildingId"), + floorId=working_location.officeLocation.get("floorId"), + deskId=working_location.officeLocation.get("deskId"), + floorSectionId=working_location.officeLocation.get("floorSectionId") + ) + session.add(office_loc) + session.flush() + working_loc.officeLocationId = office_loc.id + + session.add(working_loc) + + def _process_import_reminders(self, db_event: Event, reminders_data, session): + """ + Process reminders from import request with proper Google Calendar logic + + Google Calendar reminder logic: + - If useDefault is True: Use calendar's default reminders (ignore overrides) + - If useDefault is False: Use custom overrides (if provided) + - If no reminders specified: No reminders for this event + """ + if not reminders_data: + return + + try: + # Handle useDefault logic + if reminders_data.useDefault: + # For useDefault=True, we should use calendar's default reminders + # Since we don't have calendar defaults in our schema, we'll create + # a standard default reminder (10 minutes popup) + default_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=True, + method="popup", # Default method + minutes=10 # Default 10 minutes before + ) + session.add(default_reminder) + logger.info(f"Added default reminder for event {db_event.event_id}") + + else: + # Handle custom reminder overrides + if reminders_data.overrides and len(reminders_data.overrides) > 0: + for override in reminders_data.overrides: + # Validate reminder data + if not hasattr(override, 'method') or not hasattr(override, 'minutes'): + logger.warning(f"Invalid reminder override for event {db_event.event_id}: missing method or minutes") + continue + + # Validate method + if override.method not in ['email', 'popup']: + logger.warning(f"Invalid reminder method '{override.method}' for event {db_event.event_id}, skipping") + continue + + # Validate minutes (should be non-negative) + if override.minutes < 0: + logger.warning(f"Invalid reminder minutes '{override.minutes}' for event {db_event.event_id}, skipping") + continue + + custom_reminder = Reminder( + id=str(uuid.uuid4()), + event_id=db_event.event_id, + use_default=False, + method=override.method, + minutes=override.minutes + ) + session.add(custom_reminder) + logger.info(f"Added custom reminder for event {db_event.event_id}: {override.method} {override.minutes} minutes before") + + else: + # useDefault=False but no overrides means no reminders + logger.info(f"No reminders set for event {db_event.event_id} (useDefault=False, no overrides)") + + except Exception as e: + logger.error(f"Error processing reminders for event {db_event.event_id}: {e}") + # Don't raise the exception - reminders are not critical for event creation + # Just log the error and continue + + def _build_import_response( + self, db_event: Event, original_type: str, final_type: str, + warnings: list[str], ical_uid: str, is_all_day: bool = False + ) -> EventImportResponse: + """Build comprehensive import response""" + import uuid + + # Build creator/organizer info + creator_organizer = { + "email": f"user-{db_event.user_id}@calendar.google.com", + "displayName": "Calendar User", + "self": True + } + + response = EventImportResponse( + kind="calendar#event", + id=db_event.event_id, + etag=f'"{int(db_event.updated_at.timestamp())}"', + status=db_event.status, + htmlLink=f"https://calendar.google.com/event?eid={db_event.event_id}", + created=db_event.created_at.isoformat(), + updated=db_event.updated_at.isoformat(), + summary=db_event.summary, + creator=creator_organizer, + organizer=creator_organizer, + start=self._build_event_datetime_response(db_event.start_datetime, db_event.start_timezone, is_all_day), + end=self._build_event_datetime_response(db_event.end_datetime, db_event.end_timezone, is_all_day), + description=db_event.description, + location=db_event.location, + transparency="opaque", # Default for imported events + visibility=db_event.visibility, + eventType=final_type, + iCalUID=ical_uid, + sequence=db_event.sequence, + # Guest permissions + guestsCanInviteOthers=db_event.guestsCanInviteOthers, + guestsCanModify=db_event.guestsCanModify, + guestsCanSeeOtherGuests=db_event.guestsCanSeeOtherGuests + ) + + # Add colorId if present + if hasattr(db_event, 'color_id') and db_event.color_id: + response.colorId = db_event.color_id + + # Add recurrence if present + if db_event.recurrence: + response.recurrence = json.loads(db_event.recurrence) + + # Add attendees if present + if db_event.attendees: + attendees_list = [] + for attendee in db_event.attendees: + attendee_data = { + "email": attendee.user.email if attendee.user else None, + "displayName": attendee.displayName, + "responseStatus": attendee.responseStatus, + "optional": attendee.optional, + "comment": attendee.comment, + "additionalGuests": attendee.additionalGuests, + "resource": attendee.resource + } + attendees_list.append(attendee_data) + response.attendees = attendees_list + + # Add attachments if present + if db_event.attachments: + attachments_list = [] + for attachment in db_event.attachments: + attachment_data = { + "fileUrl": attachment.file_url, + "title": attachment.file_url.split('/')[-1] if attachment.file_url else "attachment" + } + attachments_list.append(attachment_data) + response.attachments = attachments_list + + # Add conference data if present + if hasattr(db_event, 'conferenceData') and db_event.conferenceData: + conf = db_event.conferenceData + conference_data = { + "conferenceId": conf.id + } + + # Add conferenceSolution if available + if conf.solution_type or conf.solution_name or conf.solution_icon_uri: + conference_solution = {} + if conf.solution_icon_uri: + conference_solution["iconUri"] = conf.solution_icon_uri + if conf.solution_type: + conference_solution["key"] = {"type": conf.solution_type} + if conf.solution_name: + conference_solution["name"] = conf.solution_name + conference_data["conferenceSolution"] = conference_solution + + # Add createRequest if available + if conf.request_id or conf.create_solution_type or conf.status_code: + create_request = {} + if conf.request_id: + create_request["requestId"] = conf.request_id + if conf.create_solution_type: + create_request["conferenceSolution"] = { + "key": {"type": conf.create_solution_type} + } + if conf.status_code: + create_request["status"] = {"statusCode": conf.status_code} + conference_data["createRequest"] = create_request + + # Add entryPoints from JSON array or legacy field + entry_points = [] + if conf.entry_points: + # Use new JSON array format + entry_points = conf.entry_points + elif conf.meeting_uri: + # Fallback to legacy format + entry_points = [{ + "entryPointType": "video", + "uri": conf.meeting_uri + }] + + if entry_points: + conference_data["entryPoints"] = entry_points + + # Add notes and signature if available + if conf.notes: + conference_data["notes"] = conf.notes + if conf.signature: + conference_data["signature"] = conf.signature + + response.conferenceData = conference_data + + # Add source if present + if hasattr(db_event, 'source') and db_event.source: + response.source = db_event.source + + # Add extended properties if present + if hasattr(db_event, 'extendedProperties') and db_event.extendedProperties: + ext_props = {} + private_props = {} + shared_props = {} + + for prop in db_event.extendedProperties: + if prop.scope == "private": + private_props.update(prop.properties or {}) + elif prop.scope == "shared": + shared_props.update(prop.properties or {}) + + if private_props: + ext_props["private"] = private_props + if shared_props: + ext_props["shared"] = shared_props + + if ext_props: + response.extendedProperties = ext_props + + # Add working location properties if present + if hasattr(db_event, 'workingLocationProperties') and db_event.workingLocationProperties: + working_loc = { + "type": db_event.workingLocationProperties.type, + "homeOffice": db_event.workingLocationProperties.homeOffice + } + + if db_event.workingLocationProperties.customLocationLabel: + working_loc["customLocation"] = { + "label": db_event.workingLocationProperties.customLocationLabel + } + + if db_event.workingLocationProperties.officeLocation: + working_loc["officeLocation"] = { + "buildingId": db_event.workingLocationProperties.officeLocation.buildingId, + "floorId": db_event.workingLocationProperties.officeLocation.floorId, + "deskId": db_event.workingLocationProperties.officeLocation.deskId, + "floorSectionId": db_event.workingLocationProperties.officeLocation.floorSectionId, + "label": db_event.workingLocationProperties.officeLocation.label + } + + response.workingLocationProperties = working_loc + + # Add birthday properties if present + if hasattr(db_event, 'birthdayProperties') and db_event.birthdayProperties: + response.birthdayProperties = db_event.birthdayProperties + + # Add focus time properties if present + if hasattr(db_event, 'focusTimeProperties') and db_event.focusTimeProperties: + response.focusTimeProperties = db_event.focusTimeProperties + + # Add out of office properties if present + if hasattr(db_event, 'outOfOfficeProperties') and db_event.outOfOfficeProperties: + response.outOfOfficeProperties = db_event.outOfOfficeProperties + + # Add reminders if present + if db_event.reminders: + # Build reminders response format + reminders_response = { + "useDefault": False, + "overrides": [] + } + + # Check if any reminder uses default + use_default = any(reminder.use_default for reminder in db_event.reminders) + + if use_default: + reminders_response["useDefault"] = True + + # Build overrides list + overrides = [] + for reminder in db_event.reminders: + overrides.append({ + "method": reminder.method.value if hasattr(reminder.method, 'value') else reminder.method, + "minutes": reminder.minutes + }) + + reminders_response["overrides"] = overrides + + response.reminders = reminders_response + + return response + + def _build_datetime_for_schema(self, dt: datetime, tz: str) -> DateTime: + """Build DateTime response for regular event schema""" + # Check if this appears to be an all-day event (time is 00:00:00 and timezone is UTC) + is_all_day = (dt.time() == dt.min.time()) + + if is_all_day: + # For all-day events, return date format + return DateTime( + date=dt.date().isoformat(), + timeZone=tz + ) + else: + # For timed events, return dateTime format + return DateTime( + dateTime=dt.isoformat() if dt else None, + timeZone=tz + ) + + def _build_original_start_time_for_schema(self, db_event: Event) -> Optional[DateTime]: + """Build originalStartTime DateTime response for event schema""" + if hasattr(db_event, 'originalStartTime_date') and db_event.originalStartTime_date: + # All-day event + return DateTime( + date=db_event.originalStartTime_date.isoformat(), + timeZone=db_event.originalStartTime_timeZone + ) + elif hasattr(db_event, 'originalStartTime_dateTime') and db_event.originalStartTime_dateTime: + # Timed event + return DateTime( + dateTime=db_event.originalStartTime_dateTime.isoformat(), + timeZone=db_event.originalStartTime_timeZone + ) + else: + # No originalStartTime data stored + return None + + def _build_event_datetime_response(self, dt: datetime, tz: str, is_all_day: bool) -> EventDateTime: + """Build EventDateTime response with proper format for all-day vs timed events""" + if is_all_day: + # For all-day events, return date format (YYYY-MM-DD) + return EventDateTime( + date=dt.date().isoformat() if dt else None, + timeZone=tz + ) + else: + # For timed events, return dateTime format with timezone + return EventDateTime( + dateTime=dt.isoformat() if dt else None, + timeZone=tz + ) + + def get_event_instances( + self, + user_id: str, + calendar_id: str, + event_id: str, + max_attendees: Optional[int] = None, + max_results: Optional[int] = None, + original_start: Optional[str] = None, + page_token: Optional[str] = None, + show_deleted: Optional[bool] = None, + time_min: Optional[str] = None, + time_max: Optional[str] = None, + time_zone: Optional[str] = None + ) -> EventInstancesResponse: + """ + Returns instances of the specified recurring event following Google Calendar API v3 specification + + Supports all query parameters as defined in the official documentation: + - maxAttendees (limits attendees in response) + - maxResults (limits number of instances returned, max 2500) + - originalStart (filter for specific instance) + - pageToken (pagination support) + - showDeleted (includes cancelled instances) + - timeMin/timeMax (time range filtering) + - timeZone (response timezone) + + GET /calendars/{calendarId}/events/{eventId}/instances + """ + session = get_session(self.database_id) + try: + # Parse page token to get offset + offset = 0 + if page_token: + try: + offset = self._decode_page_token(page_token) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid page token: {page_token}, error: {e}") + offset = 0 + + # Verify calendar access with proper ACL permissions + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found") + + # Check user access to calendar and event + user_role = self._get_user_calendar_role(user_id, calendar) + if user_role == "none": + raise PermissionError(f"User '{user_id}' has no access to calendar '{calendar_id}'") + + # Get the recurring event with proper permissions check + db_event_query = session.query(Event).options( + joinedload(Event.attendees).joinedload(Attendees.user), + joinedload(Event.attachments), + joinedload(Event.reminders) + ).filter( + and_(Event.calendar_id == calendar_id, Event.recurring_event_id == event_id) + ) + + db_event = db_event_query.first() + + if not db_event: + raise ValueError(f"Recurring Event not found: {event_id}") + + # Check event visibility permissions + if not self.check_event_visibility_permission(user_id, calendar_id, db_event.event_id): + raise PermissionError(f"User '{user_id}' cannot view the recurring event '{event_id}'") + + if not db_event.recurrence: + raise ValueError(f"Event is not recurring: {event_id}") + + # Add timezone filter if provided + if time_zone: + # Validate timezone + self._validate_timezone(time_zone) + else: + time_zone = calendar.time_zone + + + # Apply other filters + if not show_deleted: + db_event_query = db_event_query.filter(Event.status != "cancelled") + + if time_min: + time_min_dt = self._parse_datetime_string(time_min) + db_event_query = db_event_query.filter(Event.end_datetime >= time_min_dt) + + if time_max: + time_max_dt = self._parse_datetime_string(time_max) + db_event_query = db_event_query.filter(Event.start_datetime < time_max_dt) + + if original_start: + original_start_time_dt = self._parse_datetime_string(original_start) + db_event_query = db_event_query.filter(Event.originalStartTime_dateTime == original_start_time_dt) + + + # Apply offset for pagination + if offset > 0: + db_event_query = db_event_query.offset(offset) + + db_event = db_event_query.all() + + + instances = [] + for event in db_event: + base_instance = self._convert_db_event_to_schema(event) + if max_attendees is not None and base_instance.attendees and len(base_instance.attendees) > max_attendees: + base_instance.attendees = base_instance.attendees[:max_attendees] + base_instance.attendeesOmitted = True + instances.append(base_instance) + + # Apply maxResults limit (default 250, max 2500) + if max_results is not None: + max_results = min(max_results, 2500) # Enforce API limit + instances = instances[:max_results] + + # Determine response timezone + response_timezone = time_zone + + # Build pagination response + next_page_token = None + # In a real implementation, this would handle pagination based on page_token + + # Determine access role for response + access_role = user_role if user_role != "freeBusyReader" else "reader" + + return EventInstancesResponse( + kind="calendar#events", + etag=f'"instances-{event_id}-{int(datetime.now(timezone.utc).timestamp())}"', + summary=calendar.summary if calendar else db_event.summary, + description=calendar.description if calendar else None, + updated=datetime.now(timezone.utc).isoformat(), + timeZone=response_timezone, + accessRole=access_role, + defaultReminders=[], # Calendar defaults would go here + nextPageToken=next_page_token, + items=instances + ) + + except Exception as e: + logger.error(f"Error getting instances for event {event_id}: {e}") + raise + finally: + session.close() + + def watch_events( + self, + user_id: str, + calendar_id: str, + watch_request: Dict[str, Any], + event_types: Optional[str] = None + ) -> Channel: + """ + Watch for changes to events with optional event type filtering + + Args: + user_id: User setting up the watch + calendar_id: Calendar to watch + watch_request: Watch request details + event_types: Optional comma-separated string of event types to watch + Acceptable values: "birthday", "default", "focusTime", "fromGmail", "outOfOffice", "workingLocation" + + POST /calendars/{calendarId}/events/watch + """ + session = get_session(self.database_id) + try: + # Generate unique resource ID for events watch + resource_id = f"events-{calendar_id}-{uuid.uuid4().hex[:8]}" + if event_types is not None: + resource_uri = f"/calendars/{calendar_id}/events?eventTypes={event_types}" + else: + resource_uri = f"/calendars/{calendar_id}/events" + + # Calculate expiration time (max 24 hours from now if not specified) + now = datetime.utcnow() + expires_at = now + timedelta(hours=24) + + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + if not calendar: + raise ValueError(f"Calendar {calendar_id} not found for user {user_id}") + + if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first(): + raise ValueError(f"Channel with Id {watch_request.id} already exists") + + # Prepare watch parameters including event types filter + watch_params = {} + if watch_request.params: + watch_params = watch_request.params.model_dump() + + # Add event types filter if specified + if event_types: + watch_params["eventTypes"] = event_types + logger.info(f"Watch channel will filter for event types: {event_types}") + else: + logger.info(f"Watch channel will monitor all event types") + + # Create watch channel record + watch_channel = WatchChannel( + id=watch_request.id, + resource_id=resource_id, + resource_uri=resource_uri, + resource_type="event", + calendar_id=calendar_id, + user_id=user_id, + webhook_address=watch_request.address, + webhook_token=watch_request.token, + webhook_type=watch_request.type, + params=json.dumps(watch_params) if watch_params else None, + created_at=now, + expires_at=expires_at, + is_active="true", + notification_count=0 + ) + + # Save to database + session.add(watch_channel) + session.commit() + + logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}") + + channel = Channel( + id=watch_request.id, + resourceId=resource_id, + resourceUri=resource_uri, + token=watch_channel.webhook_token, + expiration=expires_at.isoformat() + "Z" if expires_at else None + ) + + logger.info(f"Set up watch for events in calendar {calendar_id}") + return channel + + except Exception as e: + logger.error(f"Error setting up events watch for calendar {calendar_id}: {e}") + raise + finally: + session.close() + + def _handle_create_notifications(self, db_event: Event, send_updates: str, session): + """ + Handle notification logic for event creation based on sendUpdates parameter + + Args: + db_event: The event being created + send_updates: Notification scope ("all", "externalOnly", "none") + session: Database session + """ + + if send_updates == "none": + logger.info(f"No notifications will be sent for created event {db_event.event_id}") + return + + # Get event attendees + attendees = db_event.attendees if db_event.attendees else [] + + if not attendees: + logger.info(f"No attendees found for created event {db_event.event_id}, no notifications to send") + return + + # Determine which attendees should receive notifications + notification_recipients = [] + + for attendee in attendees: + if not attendee.user or not attendee.user.email: + continue + + should_notify = False + + if send_updates == "all": + # Send to all attendees + should_notify = True + elif send_updates == "externalOnly": + # Send only to non-Google Calendar guests (external email domains) + # For this implementation, we'll consider emails not ending with common Google domains as external + email_domain = attendee.user.email.split('@')[-1].lower() + google_domains = ['gmail.com', 'googlemail.com', 'google.com'] + if email_domain not in google_domains: + should_notify = True + + if should_notify: + notification_recipients.append({ + 'email': attendee.user.email, + 'displayName': attendee.displayName or attendee.user.email + }) + + # Log notification details (in a real implementation, this would send actual notifications) + if notification_recipients: + logger.info(f"Event creation notifications will be sent to {len(notification_recipients)} recipients:") + for recipient in notification_recipients: + logger.info(f" - {recipient['displayName']} ({recipient['email']})") + else: + logger.info(f"No attendees match notification criteria for sendUpdates='{send_updates}'") + + def _handle_notifications(self, db_event: Event, send_updates: str, session): + """ + Handle notification logic for event operations based on sendUpdates parameter + + Args: + db_event: The event being processed + send_updates: Notification scope ("all", "externalOnly", "none") + session: Database session + """ + + if send_updates == "none": + logger.info(f"No notifications will be sent for event {db_event.event_id}") + return + + # Get event attendees + attendees = db_event.attendees if db_event.attendees else [] + + if not attendees: + logger.info(f"No attendees found for event {db_event.event_id}, no notifications to send") + return + + # Determine which attendees should receive notifications + notification_recipients = [] + + for attendee in attendees: + if not attendee.user or not attendee.user.email: + continue + + should_notify = False + + if send_updates == "all": + # Send to all attendees + should_notify = True + elif send_updates == "externalOnly": + # Send only to non-Google Calendar guests (external email domains) + # For this implementation, we'll consider emails not ending with common Google domains as external + email_domain = attendee.user.email.split('@')[-1].lower() + google_domains = ['gmail.com', 'googlemail.com', 'google.com'] + if email_domain not in google_domains: + should_notify = True + + if should_notify: + notification_recipients.append({ + 'email': attendee.user.email, + 'displayName': attendee.displayName or attendee.user.email + }) + + # Log notification details (in a real implementation, this would send actual notifications) + if notification_recipients: + logger.info(f"Event notifications will be sent to {len(notification_recipients)} recipients:") + for recipient in notification_recipients: + logger.info(f" - {recipient['displayName']} ({recipient['email']})") + else: + logger.info(f"No attendees match notification criteria for sendUpdates='{send_updates}'") + + def _encode_page_token(self, offset: int) -> str: + """Encode offset as a page token""" + try: + token_data = str(offset) + return base64.b64encode(token_data.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error(f"Error encoding page token: {e}") + return "" + + def _decode_page_token(self, token: str) -> int: + """Decode page token to get offset""" + try: + # Handle legacy case where raw numbers might be passed + if token.isdigit(): + logger.warning(f"Raw numeric page token received: {token}. This should be a base64-encoded token.") + return int(token) + + # Add padding if needed for base64 decoding + missing_padding = len(token) % 4 + if missing_padding: + token += '=' * (4 - missing_padding) + + decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8') + return int(decoded) + except Exception as e: + logger.error(f"Error decoding page token: {e}") + raise ValueError(f"Invalid page token: {token}. Page tokens should only be generated by the API.") + + def _encode_sync_token(self, timestamp: datetime) -> str: + """Encode timestamp as a sync token""" + try: + # Use ISO format timestamp for sync token + token_data = timestamp.isoformat() + return base64.b64encode(token_data.encode('utf-8')).decode('utf-8') + except Exception as e: + logger.error(f"Error encoding sync token: {e}") + return "" + + def _decode_sync_token(self, token: str) -> datetime: + """Decode sync token to get timestamp""" + try: + # Add padding if needed for base64 decoding + missing_padding = len(token) % 4 + if missing_padding: + token += '=' * (4 - missing_padding) + + decoded = base64.b64decode(token.encode('utf-8')).decode('utf-8') + return datetime.fromisoformat(decoded) + except Exception as e: + logger.error(f"Error decoding sync token: {e}") + raise ValueError(f"Invalid sync token. Token may have expired or is malformed.") diff --git a/envs/calendar_env/server/database/managers/freebusy_manager.py b/envs/calendar_env/server/database/managers/freebusy_manager.py new file mode 100644 index 000000000..abb4370e6 --- /dev/null +++ b/envs/calendar_env/server/database/managers/freebusy_manager.py @@ -0,0 +1,249 @@ +""" +FreeBusy database manager with Google Calendar API v3 compatible operations +Handles FreeBusy query operations with database-per-user architecture +""" + +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime, timezone +from sqlalchemy.orm import sessionmaker +from sqlalchemy import and_, or_, desc, asc +from dateutil import parser + +from database.session_utils import get_session, init_database +from database.models.event import Event +from database.models.calendar import Calendar +from database.models.user import User +from schemas.freebusy import ( + FreeBusyQueryRequest, + FreeBusyQueryResponse, + FreeBusyCalendarResult as FreeBusyCalendarResultSchema, + FreeBusyError, + TimePeriod, + FreeBusyQueryValidation, + FreeBusyEventOverlap +) + +logger = logging.getLogger(__name__) + + +class FreeBusyManager: + """FreeBusy manager for database operations""" + + def __init__(self, database_id: str): + self.database_id = database_id + # Initialize database on first use + init_database(database_id) + + def _parse_datetime_string(self, datetime_str: str) -> datetime: + """Parse ISO datetime string to datetime object""" + try: + return parser.isoparse(datetime_str.replace('Z', '+00:00')) + except Exception as e: + logger.error(f"Error parsing datetime string {datetime_str}: {e}") + raise ValueError(f"Invalid datetime format: {datetime_str}") + + def _validate_query_request(self, request: FreeBusyQueryRequest, user_id: str) -> FreeBusyQueryValidation: + """Validate FreeBusy query request""" + time_min = self._parse_datetime_string(request.timeMin) + time_max = self._parse_datetime_string(request.timeMax) + calendar_ids = [item.id for item in request.items] + + validation = FreeBusyQueryValidation( + time_min=time_min, + time_max=time_max, + calendar_ids=calendar_ids, + user_id=user_id + ) + + if not validation.validate_time_range(): + raise ValueError("Time range is invalid or too large (max 366 days)") + + if not validation.validate_calendar_count(): + raise ValueError("Too many calendars requested (max 50)") + + # Validate that all calendar IDs exist in the database + self._validate_calendar_ids_exist(calendar_ids, user_id) + + return validation + + def _validate_calendar_ids_exist(self, calendar_ids: List[str], user_id: str) -> None: + """Validate that all calendar IDs exist in the database for the user""" + session = get_session(self.database_id) + try: + for calendar_id in calendar_ids: + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar: + raise ValueError(f"Calendar with ID '{calendar_id}' does not exist or is not accessible") + + finally: + session.close() + + def _get_busy_periods_for_calendar( + self, + session, + calendar_id: str, + user_id: str, + time_min: datetime, + time_max: datetime + ) -> List[TimePeriod]: + """Get busy periods for a specific calendar""" + try: + # Verify calendar belongs to user + calendar = session.query(Calendar).filter( + Calendar.calendar_id == calendar_id, + Calendar.user_id == user_id + ).first() + + if not calendar: + # This should not happen if validation passed, but handle gracefully + logger.error(f"Calendar {calendar_id} not found for user {user_id} during query execution") + raise ValueError(f"Calendar with ID '{calendar_id}' does not exist or is not accessible") + + # Query events that overlap with the time range + events = session.query(Event).filter( + and_( + Event.calendar_id == calendar_id, + Event.user_id == user_id, + Event.status == "confirmed", # Only confirmed events block time + # Event overlaps with query range + Event.start_datetime < time_max, + Event.end_datetime > time_min + ) + ).all() + + busy_periods = [] + for event in events: + # Check if event is transparent (doesn't block time) + if hasattr(event, 'transparency') and event.transparency == "transparent": + continue + + # Create overlap object for validation + overlap = FreeBusyEventOverlap( + event_id=event.event_id, + start=event.start_datetime, + end=event.end_datetime, + transparency=getattr(event, 'transparency', 'opaque') + ) + + if overlap.is_busy(): + # Clip event times to query range + period_start = max(event.start_datetime, time_min) + period_end = min(event.end_datetime, time_max) + + busy_periods.append(TimePeriod( + start=period_start.isoformat(), + end=period_end.isoformat() + )) + + # Merge overlapping periods + busy_periods = self._merge_overlapping_periods(busy_periods) + + return busy_periods + + except Exception as e: + logger.error(f"Error getting busy periods for calendar {calendar_id}: {e}") + raise + + def _merge_overlapping_periods(self, periods: List[TimePeriod]) -> List[TimePeriod]: + """Merge overlapping time periods""" + if not periods: + return [] + + # Sort periods by start time + sorted_periods = sorted(periods, key=lambda p: p.start) + + merged = [sorted_periods[0]] + + for current in sorted_periods[1:]: + last_merged = merged[-1] + + # Parse times for comparison + current_start = self._parse_datetime_string(current.start) + current_end = self._parse_datetime_string(current.end) + last_end = self._parse_datetime_string(last_merged.end) + + # If current period overlaps with last merged period + if current_start <= last_end: + # Extend the last merged period if necessary + if current_end > last_end: + merged[-1] = TimePeriod( + start=last_merged.start, + end=current.end + ) + else: + # No overlap, add as new period + merged.append(current) + + return merged + + def query_freebusy( + self, + user_id: str, + request: FreeBusyQueryRequest + ) -> FreeBusyQueryResponse: + """ + Query free/busy information for calendars + + POST /freeBusy + """ + session = get_session(self.database_id) + try: + # Validate request + validation = self._validate_query_request(request, user_id) + + # Process each calendar + calendar_results = {} + + for calendar_item in request.items: + calendar_id = calendar_item.id + + try: + # Get busy periods for this calendar + busy_periods = self._get_busy_periods_for_calendar( + session, + calendar_id, + user_id, + validation.time_min, + validation.time_max + ) + + + # Add to response + calendar_results[calendar_id] = FreeBusyCalendarResultSchema( + busy=busy_periods + ) + + except Exception as e: + logger.error(f"Error processing calendar {calendar_id}: {e}") + + # Add error to response + calendar_results[calendar_id] = FreeBusyCalendarResultSchema( + errors=[FreeBusyError( + domain="calendar", + reason="backendError" + )] + ) + + + response = FreeBusyQueryResponse( + timeMin=request.timeMin, + timeMax=request.timeMax, + calendars=calendar_results + ) + + logger.info(f"Processed FreeBusy query for user {user_id}") + return response + + except Exception as e: + session.rollback() + logger.error(f"Error processing FreeBusy query for user {user_id}: {e}") + raise ValueError(f"Error processing FreeBusy query for user {user_id}: {e}") + finally: + session.close() + + \ No newline at end of file diff --git a/envs/calendar_env/server/database/managers/settings_manager.py b/envs/calendar_env/server/database/managers/settings_manager.py new file mode 100644 index 000000000..66eb4c55d --- /dev/null +++ b/envs/calendar_env/server/database/managers/settings_manager.py @@ -0,0 +1,134 @@ +""" +Setting Manager - Database operations for settings management using SQLAlchemy +""" + +import logging +from typing import Optional, List, Dict +from datetime import datetime, timedelta +from database.models.settings import Settings +from database.models.watch_channel import WatchChannel +from database.session_utils import get_session, init_database +from schemas.settings import SettingItem, SettingsWatchRequest, Channel +import uuid +import json + +logger = logging.getLogger(__name__) + + +class SettingManager: + """Manager for settings database operations using SQLAlchemy""" + + def __init__(self, database_id: str): + self.database_id = database_id + init_database(database_id) + + def list_settings(self, user_id: str) -> list[SettingItem]: + """List all settings""" + session = get_session(self.database_id) + try: + settings = ( + session.query(Settings) + .filter(Settings.user_id == user_id) + .all() + ) + return [SettingItem.model_validate(s) for s in settings] + except Exception as e: + logger.error(f"Error listing settings for user '{user_id}': {e}") + raise + finally: + session.close() + + def get_setting_by_id(self, setting_id: str, user_id: str) -> Optional[Dict]: + """Get a setting by its ID""" + session = get_session(self.database_id) + try: + setting = session.query(Settings).filter( + Settings.id == setting_id, + Settings.user_id == user_id + ).first() + if setting: + return self._format_setting(setting) + return None + except Exception as e: + logger.error(f"Error retrieving setting '{setting_id}' for user '{user_id}': {e}") + raise + finally: + session.close() + + def _format_setting(self, setting: Settings) -> Dict: + """Format a setting for API response""" + return { + "kind": "calendar#setting", + "etag": setting.etag, + "id": setting.id, + "value": setting.value, + "user_id": setting.user_id + } + + def watch_settings(self, watch_request: SettingsWatchRequest, user_id: str) -> Channel: + """ + Set up a watch channel for settings changes + + Args: + watch_request: The watch request parameters + user_id: The user setting up the watch + + Returns: + Channel: The created watch channel + """ + session = get_session(self.database_id) + try: + # Generate unique resource ID for settings watch + resource_id = f"settings-{user_id}-{uuid.uuid4().hex[:8]}" + resource_uri = f"/calendars/{user_id}/settings" + + # Calculate expiration time (max 24 hours from now if not specified) + now = datetime.utcnow() + expires_at = now + timedelta(hours=24) + + if session.query(WatchChannel).filter(WatchChannel.id == watch_request.id).first(): + raise ValueError(f"Channel with Id {watch_request.id} already exists") + + # Create watch channel record + watch_channel = WatchChannel( + id=watch_request.id, + resource_id=resource_id, + resource_uri=resource_uri, + resource_type="settings", + calendar_id="", + user_id=user_id, + webhook_address=watch_request.address, + webhook_token=watch_request.token, + webhook_type=watch_request.type, + params=json.dumps(watch_request.params.model_dump()) if watch_request.params else None, + created_at=now, + expires_at=expires_at, + is_active="true", + notification_count=0 + ) + + # Save to database + session.add(watch_channel) + session.commit() + + logger.info(f"Created settings watch channel {watch_request.id} for user {user_id}") + + # Return channel response + return Channel( + kind="api#channel", + id=watch_channel.id, + resourceId=resource_id, + resourceUri=resource_uri, + token=watch_channel.webhook_token, + expiration=expires_at.isoformat() + "Z" if expires_at else None + + ) + + except Exception as e: + session.rollback() + logger.error(f"Error creating settings watch channel: {e}") + raise + finally: + session.close() + + diff --git a/envs/calendar_env/server/database/managers/user_manager.py b/envs/calendar_env/server/database/managers/user_manager.py new file mode 100644 index 000000000..e2f262409 --- /dev/null +++ b/envs/calendar_env/server/database/managers/user_manager.py @@ -0,0 +1,281 @@ +""" +User Manager - Database operations for user management using SQLAlchemy +""" + +import logging +import uuid +from typing import Dict, List, Optional +from datetime import datetime, timezone +from sqlalchemy.orm import Session +from database.models import User +from database.session_utils import get_session, init_database + +logger = logging.getLogger(__name__) + + +class UserManager: + """Manager for user database operations using SQLAlchemy""" + + def __init__(self, database_id: str): + self.database_id = database_id + # Initialize database on first use + init_database(database_id) + + + def create_user(self, user_data: Dict) -> Dict: + """Create a new user""" + session = get_session(self.database_id) + try: + # Generate unique user ID if not provided + user_id = user_data.get("user_id") or str(uuid.uuid4()) + + # Check if user with email already exists + existing_user = session.query(User).filter(User.email == user_data["email"]).first() + if existing_user: + raise ValueError(f"User with email {user_data['email']} already exists") + + # Create User model instance + user = User( + user_id=user_id, + email=user_data["email"], + name=user_data["name"], + given_name=user_data.get("given_name"), + family_name=user_data.get("family_name"), + picture=user_data.get("picture"), + locale=user_data.get("locale", "en"), + timezone=user_data.get("timezone", "UTC"), + is_active=user_data.get("is_active", True), + is_verified=user_data.get("is_verified", False), + provider=user_data.get("provider"), + provider_id=user_data.get("provider_id"), + access_token_hash=user_data.get("access_token_hash"), + refresh_token_hash=user_data.get("refresh_token_hash") + ) + + session.add(user) + session.commit() + + # Return the created user + result = self._format_user_response(user) + return result + + except Exception as e: + session.rollback() + logger.error(f"Error creating user: {e}") + raise + finally: + session.close() + + def get_user_by_id(self, user_id: str) -> Optional[Dict]: + """Get a user by ID""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + + if not user: + return None + + return self._format_user_response(user) + + except Exception as e: + logger.error(f"Error getting user {user_id}: {e}") + raise + finally: + session.close() + + def get_first_user_from_db(self) -> Optional[Dict]: + """Get the first user from db""" + session = get_session(self.database_id) + try: + user = session.query(User).first() + if not user: + return None + + return self._format_user_response(user) + except Exception as e: + logger.error(f"Error getting user from db: {e}") + raise + finally: + session.close() + + def get_user_by_access_token(self, static_token: str) -> Optional[Dict]: + """Get a user by access token""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.static_token == static_token).first() + + if not user: + return None + + return self._format_user_response(user) + + except Exception as e: + logger.error(f"Error getting user by access token {static_token}: {e}") + raise + finally: + session.close() + + def get_user_by_email(self, email: str) -> Optional[Dict]: + """Get a user by email""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.email == email).first() + + if not user: + return None + + return self._format_user_response(user) + + except Exception as e: + logger.error(f"Error getting user by email {email}: {e}") + raise + finally: + session.close() + + def update_user(self, user_id: str, user_data: Dict) -> Optional[Dict]: + """Update a user""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + + if not user: + return None + + # Update fields if provided + updateable_fields = [ + "name", "given_name", "family_name", "picture", "locale", + "timezone", "is_active", "is_verified", "provider", "provider_id", + "access_token_hash", "refresh_token_hash" + ] + + for field in updateable_fields: + if field in user_data: + setattr(user, field, user_data[field]) + + # Update last login if provided + if user_data.get("update_last_login"): + user.last_login_at = datetime.now(timezone.utc) + + # SQLAlchemy will automatically update updated_at due to onupdate=datetime.utcnow + session.commit() + + return self._format_user_response(user) + + except Exception as e: + session.rollback() + logger.error(f"Error updating user {user_id}: {e}") + raise + finally: + session.close() + + def delete_user(self, user_id: str) -> bool: + """Delete a user and all related data""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + + if not user: + return False + + # Delete user (cascade will delete related calendars and events) + session.delete(user) + session.commit() + return True + + except Exception as e: + session.rollback() + logger.error(f"Error deleting user {user_id}: {e}") + raise + finally: + session.close() + + def deactivate_user(self, user_id: str) -> Optional[Dict]: + """Deactivate a user (soft delete)""" + session = get_session(self.database_id) + try: + user = session.query(User).filter(User.user_id == user_id).first() + + if not user: + return None + + user.is_active = False + session.commit() + + return self._format_user_response(user) + + except Exception as e: + session.rollback() + logger.error(f"Error deactivating user {user_id}: {e}") + raise + finally: + session.close() + + def list_users(self, limit: int = 100, offset: int = 0, active_only: bool = True) -> List[Dict]: + """List users""" + session = get_session(self.database_id) + try: + query = session.query(User) + + if active_only: + query = query.filter(User.is_active == True) + + users = query.order_by(User.created_at.desc()).offset(offset).limit(limit).all() + + return [self._format_user_response(user) for user in users] + + except Exception as e: + logger.error(f"Error listing users: {e}") + raise + finally: + session.close() + + def authenticate_user(self, email: str, provider: str = None, provider_id: str = None) -> Optional[Dict]: + """Authenticate user by email and optionally by provider""" + session = get_session(self.database_id) + try: + query = session.query(User).filter( + User.email == email, + User.is_active == True + ) + + if provider: + query = query.filter(User.provider == provider) + + if provider_id: + query = query.filter(User.provider_id == provider_id) + + user = query.first() + + if not user: + return None + + # Update last login + user.last_login_at = datetime.now(timezone.utc) + session.commit() + + return self._format_user_response(user) + + except Exception as e: + logger.error(f"Error authenticating user {email}: {e}") + raise + finally: + session.close() + + def _format_user_response(self, user: User) -> Dict: + """Format user model for API response (without sensitive data)""" + return { + "id": user.user_id, + "email": user.email, + "name": user.name, + "given_name": user.given_name, + "family_name": user.family_name, + "picture": user.picture, + "locale": user.locale, + "timezone": user.timezone, + "is_active": user.is_active, + "is_verified": user.is_verified, + "provider": user.provider, + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None, + "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None + } \ No newline at end of file diff --git a/envs/calendar_env/server/database/models/__init__.py b/envs/calendar_env/server/database/models/__init__.py new file mode 100644 index 000000000..02fa5fb2d --- /dev/null +++ b/envs/calendar_env/server/database/models/__init__.py @@ -0,0 +1,27 @@ +""" +Database models package +""" + +from .base import Base +from .user import User +from .calendar import Calendar +from .event import Event, Attendees, Attachment, WorkingLocationProperties +from .color import Color +from .settings import Settings +from .acl import ACLs, Scope +from .watch_channel import WatchChannel + +__all__ = [ + "Base", + "User", + "Calendar", + "Event", + "Attendees", + "Attachment", + "WorkingLocationProperties", + "Color", + "Settings", + "ACLs", + "Scope", + "WatchChannel" +] \ No newline at end of file diff --git a/envs/calendar_env/server/database/models/acl.py b/envs/calendar_env/server/database/models/acl.py new file mode 100644 index 000000000..2959c40b1 --- /dev/null +++ b/envs/calendar_env/server/database/models/acl.py @@ -0,0 +1,55 @@ +from datetime import datetime +from sqlalchemy import Column, String, ForeignKey, Enum, DateTime +from sqlalchemy.orm import relationship +from .base import Base +import enum + + +class AclRole(enum.Enum): + none = "none" + freeBusyReader = "freeBusyReader" + reader = "reader" + writer = "writer" + owner = "owner" + + +class ScopeType(enum.Enum): + default = "default" + user = "user" + group = "group" + domain = "domain" + + +class Scope(Base): + __tablename__ = "scopes" + + id = Column(String, primary_key=True, index=True) + type = Column(Enum(ScopeType), nullable=False) + value = Column(String, nullable=True) # Optional for default + + # Relationship to ACLs + acls = relationship("ACLs", back_populates="scope", cascade="all, delete-orphan") + + def as_dict(self): + return {"type": self.type.value, "value": self.value} + + +class ACLs(Base): + __tablename__ = "acls" + + id = Column(String, primary_key=True, index=True) + + calendar_id = Column(String, ForeignKey("calendars.calendar_id"), nullable=False, index=True) + user_id = Column(String, ForeignKey("users.user_id"), nullable=False, index=True) + scope_id = Column(String, ForeignKey("scopes.id"), nullable=False) + + role = Column(Enum(AclRole), nullable=False) + + etag = Column(String) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + calendar = relationship("Calendar", back_populates="acls") + user = relationship("User", back_populates="acls") + scope = relationship("Scope", back_populates="acls") diff --git a/envs/calendar_env/server/database/models/base.py b/envs/calendar_env/server/database/models/base.py new file mode 100644 index 000000000..b9bcda55f --- /dev/null +++ b/envs/calendar_env/server/database/models/base.py @@ -0,0 +1,7 @@ +""" +Base database model +""" + +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/envs/calendar_env/server/database/models/calendar.py b/envs/calendar_env/server/database/models/calendar.py new file mode 100644 index 000000000..bf261dc2a --- /dev/null +++ b/envs/calendar_env/server/database/models/calendar.py @@ -0,0 +1,53 @@ +""" +Calendar database model +""" + +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Index +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base + + +class Calendar(Base): + """Calendar database model""" + + __tablename__ = "calendars" + + calendar_id = Column(String(255), primary_key=True, nullable=False) + user_id = Column(String(255), ForeignKey("users.user_id"), nullable=False, index=True) + summary = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + location = Column(String(500), nullable=True) + time_zone = Column(String(100), nullable=False, default="UTC") + conference_properties = Column(Text, nullable=True) # JSON string + is_primary = Column(Boolean, default=False, nullable=False) # Primary vs secondary calendar + + # Calendar List specific fields + summary_override = Column(String(255), nullable=True) # Custom summary override for list display + color_id = Column(String(50), nullable=True) # Calendar color ID + background_color = Column(String(7), nullable=True) # Hex color for background + foreground_color = Column(String(7), nullable=True) # Hex color for foreground + hidden = Column(Boolean, default=False, nullable=False) # Hidden from calendar list + selected = Column(Boolean, default=True, nullable=False) # Selected in UI + default_reminders = Column(Text, nullable=True) # JSON string of default reminders + notification_settings = Column(Text, nullable=True) # JSON string of notification settings + deleted = Column(Boolean, default=False, nullable=False) # Soft delete flag + + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + ) + + # Relationships + user = relationship("User", back_populates="calendars") + events = relationship("Event", back_populates="calendar", cascade="all, delete-orphan") + acls = relationship("ACLs", back_populates="calendar", cascade="all, delete-orphan") + + # Table constraints + __table_args__ = ( + # Ensure each user has exactly one primary calendar (only when is_primary=True) + Index("idx_unique_primary_per_user", "user_id", unique=True, sqlite_where=Column("is_primary") == True), + ) + + def __repr__(self): + return f"" diff --git a/envs/calendar_env/server/database/models/color.py b/envs/calendar_env/server/database/models/color.py new file mode 100644 index 000000000..817c2f725 --- /dev/null +++ b/envs/calendar_env/server/database/models/color.py @@ -0,0 +1,49 @@ +""" +Color database model for Google Calendar API v3 color definitions +""" + +from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base +import enum + + +class ColorType(enum.Enum): + """Color type enumeration""" + CALENDAR = "calendar" + EVENT = "event" + + +class Color(Base): + """Color database model for calendar and event color definitions""" + + __tablename__ = "colors" + + id = Column(Integer, primary_key=True, autoincrement=True) + color_id = Column(String(10), nullable=False, index=True) # "1", "2", "3", etc. + color_type = Column(Enum(ColorType), nullable=False, index=True) + background = Column(String(7), nullable=False) # Hex color like "#ac725e" + foreground = Column(String(7), nullable=False) # Hex color like "#1d1d1d" + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + events = relationship("Event", back_populates="color") + + # Composite unique constraint + __table_args__ = ( + {'mysql_charset': 'utf8mb4'} + if hasattr(__builtins__, 'mysql') + else {} + ) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary format for API responses""" + return { + "background": self.background, + "foreground": self.foreground + } \ No newline at end of file diff --git a/envs/calendar_env/server/database/models/event.py b/envs/calendar_env/server/database/models/event.py new file mode 100644 index 000000000..9fe81dd8b --- /dev/null +++ b/envs/calendar_env/server/database/models/event.py @@ -0,0 +1,403 @@ +""" +Event database model +""" + +from sqlalchemy import Column, Integer, String, Text, DateTime, Date, ForeignKey, Boolean, JSON, Enum +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base +from sqlalchemy import event +from sqlalchemy.orm import object_session +from sqlalchemy.exc import IntegrityError + +import enum + + +class ExtendedPropertyScope(enum.Enum): + private = "private" + shared = "shared" + +class EventTypeEnum(enum.Enum): + birthday = "birthday" + default = "default" + focusTime = "focusTime" + fromGmail = "fromGmail" + outOfOffice = "outOfOffice" + workingLocation = "workingLocation" + + +class ReminderMethodEnum(enum.Enum): + email = "email" + popup = "popup" + +class RecurrenceFrequency(enum.Enum): + """RRULE frequency values from RFC 5545""" + SECONDLY = "SECONDLY" + MINUTELY = "MINUTELY" + HOURLY = "HOURLY" + DAILY = "DAILY" + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + YEARLY = "YEARLY" + + +class RecurrenceWeekday(enum.Enum): + """Days of the week for RRULE BYDAY parameter""" + MO = "MO" # Monday + TU = "TU" # Tuesday + WE = "WE" # Wednesday + TH = "TH" # Thursday + FR = "FR" # Friday + SA = "SA" # Saturday + SU = "SU" # Sunday + + + + +class Attendees(Base): + """Attendees database model""" + + __tablename__ = "attendees" + + attendees_id = Column(String(255), primary_key=True, nullable=False) + comment = Column(String(255), nullable = True) + displayName = Column(String(255), nullable = True) + additionalGuests = Column(Integer, default = 0) + optional = Column(Boolean, default=False, nullable = False) + resource = Column(Boolean, default=False, nullable = False) + responseStatus = Column(String(50), nullable = False, default="needsAction") + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable = False) + user_id = Column(String(255), ForeignKey("users.user_id"), nullable=True) + + # Relationships + event = relationship("Event", back_populates = "attendees") + user = relationship("User", back_populates="attendees") + + + +class Attachment(Base): + """Attachment model for storing file URLs linked to events""" + + __tablename__ = "attachments" + + attachment_id = Column(String(255), primary_key=True, nullable=False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False) + file_url = Column(String(2000), nullable=False) # long enough for Drive links + + # Relationship + event = relationship("Event", back_populates="attachments") + + def __repr__(self): + return f"" + + +class OfficeLocation(Base): + + __tablename__ = "office_locations" + + id = Column(String(255), primary_key=True, nullable=False) + buildingId = Column(String(255), nullable = True) + deskId = Column(String(255), nullable = True) + floorId = Column(String(255), nullable = True) + floorSectionId = Column(String(255), nullable = True) + label = Column(String(255), nullable = False) + + workingLocation = relationship("WorkingLocationProperties", back_populates="officeLocation", uselist=False) + + + +class WorkingLocationProperties(Base): + """Working location properties model for events""" + + __tablename__ = "working_location_properties" + + working_location_id = Column(String(255), primary_key=True, nullable=False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False) + type = Column( + String(50), + nullable=False, + doc=( + "Type of working location. Required. Values: " + "'homeOffice', 'officeLocation', 'customLocation'" + ) + ) + homeOffice = Column( + JSON, + nullable=True, + doc="If present, specifies that the user is working at home." + ) + customLocationLabel = Column( + String(255), + nullable=True, + doc="Optional extra label for custom location additional information." + ) + officeLocationId = Column(String(255), ForeignKey("office_locations.id"), unique=True, nullable=True) + + # Relationship + officeLocation = relationship("OfficeLocation", back_populates="workingLocation", uselist=False) + event = relationship("Event", back_populates="workingLocationProperties") + + def __repr__(self): + return f"" + +class ConferenceData(Base): + __tablename__ = "conference_data" + + id = Column(String(50), primary_key=True, nullable = False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False, unique=True) + + # Conference solution fields + solution_type = Column(String(100), nullable=True) # conferenceSolution.key.type + solution_name = Column(String(255), nullable=True) # conferenceSolution.name + solution_icon_uri = Column(String(500), nullable=True) # conferenceSolution.iconUri + + # Create request fields + request_id = Column(String(255), nullable=True) # createRequest.requestId + create_solution_type = Column(String(100), nullable=True) # createRequest.conferenceSolution.key.type + status_code = Column(String(50), nullable=True) # createRequest.status.statusCode + + # Entry points (stored as JSON array for flexibility) + entry_points = Column(JSON, nullable=True) # entryPoints array + + # Additional conference fields + notes = Column(Text, nullable=True) # notes + signature = Column(String(500), nullable=True) # signature + + # Legacy field for backward compatibility + meeting_uri = Column(String(500), nullable=True) # Primary meeting URI + label = Column(String(255), nullable=True) # Deprecated - use solution_name + + # Relationships + event = relationship("Event", back_populates="conferenceData") + + +class BirthdayProperties(Base): + __tablename__ = "birthday_properties" + + id = Column(String(50), primary_key=True, nullable = False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False, unique=True) + type = Column(String(50), nullable=False, default="birthday") # Default enforced + + # Relationship back to event + event = relationship("Event", back_populates="birthdayProperties") + + +class ExtendedProperty(Base): + __tablename__ = "extended_properties" + + id = Column(String(255), primary_key=True, nullable=False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False) + scope = Column(Enum(ExtendedPropertyScope), nullable=False) # private or shared + properties = Column(JSON, nullable=True) + + event = relationship("Event", back_populates="extendedProperties") + + + +class Reminder(Base): + __tablename__ = "reminders" + + id = Column(String(255), primary_key=True, nullable=False) + event_id = Column(String(255), ForeignKey("events.event_id", ondelete="CASCADE"), nullable=False) + + method = Column(Enum(ReminderMethodEnum, name="reminder_method_enum"), nullable=False) + minutes = Column(Integer, nullable=False, doc="Minutes before the event to trigger reminder") + use_default = Column(Boolean, nullable=False, default=True) + + event = relationship("Event", back_populates="reminders") + + + +class Event(Base): + """Event database model""" + + __tablename__ = "events" + + event_id = Column(String(255), primary_key=True, nullable=False) + calendar_id = Column(String(255), ForeignKey("calendars.calendar_id"), nullable=False) + user_id = Column(String(255), ForeignKey("users.user_id"), nullable=False, index=True) + + # Organizer information stored separately from user_id + organizer_id = Column(String(255), nullable=True, doc="Organizer user ID") + organizer_email = Column(String(255), nullable=True, doc="Organizer email address") + organizer_display_name = Column(String(255), nullable=True, doc="Organizer display name") + organizer_self = Column(Boolean, default=False, doc="Whether the organizer is the current user") + + # Foreign key to recurring event (if this is an instance of a recurring event) + recurring_event_id = Column(String(255), ForeignKey("recurring_events.recurring_event_id", ondelete="CASCADE"), nullable=True, index=True) + + summary = Column(String(255), nullable=True) + description = Column(Text, nullable=True) + location = Column(String(500), nullable=True) + start_datetime = Column(DateTime, nullable=False) + end_datetime = Column(DateTime, nullable=False) + start_timezone = Column(String(100), nullable=True) + end_timezone = Column(String(100), nullable=True) + recurrence = Column(Text, nullable=True) # RRULE format + status = Column(String(50), nullable=False, default="confirmed") + visibility = Column(String(50), nullable=False, default="default") + color_id = Column(String(50), ForeignKey("colors.id", ondelete="SET NULL"), nullable = True) + + + eventType = Column( + Enum(EventTypeEnum, name="event_type_enum"), + nullable=False, + default=EventTypeEnum.default, + doc=( + "Specific type of the event. Cannot be modified after creation. " + "Allowed values: 'birthday', 'default', 'focusTime', " + "'fromGmail', 'outOfOffice', 'workingLocation'." + ), + ) + + focusTimeProperties = Column( + JSON, + nullable=True, + doc="Focus Time event data. Used if eventType is focusTime.", + ) + guestsCanInviteOthers = Column( + Boolean, + nullable=False, + default=True, + doc="Whether attendees other than the organizer can invite others to the event.", + ) + guestsCanModify = Column( + Boolean, + nullable=False, + default=False, + doc="Whether attendees other than the organizer can modify the event.", + ) + guestsCanSeeOtherGuests = Column( + Boolean, + nullable=False, + default=True, + doc="Whether attendees other than the organizer can see who the event's attendees are.", + ) + outOfOfficeProperties = Column( + JSON, + nullable=True, + doc="Out of office event data. Used if eventType is outOfOffice.", + ) + sequence = Column( + Integer, + nullable=True, + doc="Sequence number as per iCalendar.", + ) + source = Column( + JSON, + nullable=True, + doc=( + "Source information for the event. Contains 'title' and 'url' properties. " + "URL scheme must be HTTP or HTTPS." + ), + ) + + transparency = Column( + String(50), + nullable=True, + default="opaque", + doc="Whether event blocks time. Values: 'opaque', 'transparent'" + ) + iCalUID = Column( + String(255), + nullable=True, + doc="Event iCalendar UID for external integration" + ) + privateCopy = Column( + Boolean, + nullable=True, + default=False, + doc="Whether this is a private copy of the event" + ) + locked = Column( + Boolean, + nullable=True, + default=False, + doc="Whether the event is locked against changes" + ) + hangoutLink = Column( + String(500), + nullable=True, + doc="Hangout video call link" + ) + + # Original start time fields for recurring events and event tracking + originalStartTime_date = Column( + Date, + nullable=True, + doc="The date, in the format 'yyyy-mm-dd', if this is an all-day event." + ) + originalStartTime_dateTime = Column( + DateTime, + nullable=True, + doc="The time, as a combined date-time value (formatted according to RFC3339). A time zone offset is required unless a time zone is explicitly specified in timeZone." + ) + originalStartTime_timeZone = Column( + String(100), + nullable=True, + doc="The time zone in which the time is specified. (Formatted as an IANA Time Zone Database name, e.g. 'Europe/Zurich'.) For recurring events this field is required and specifies the time zone in which the recurrence is expanded. For single events this field is optional and indicates a custom time zone for the event start/end." + ) + + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + calendar = relationship("Calendar", back_populates="events") + user = relationship("User") + recurring_event = relationship("RecurringEvent", back_populates="event_instances") + attendees = relationship("Attendees", back_populates = "event", cascade="all, delete-orphan") + attachments = relationship("Attachment", back_populates="event", cascade="all, delete-orphan") + conferenceData = relationship("ConferenceData", uselist=False, back_populates="event", cascade="all, delete-orphan") + birthdayProperties = relationship("BirthdayProperties", uselist=False, back_populates="event", cascade="all, delete-orphan") + color = relationship("Color", back_populates="events") + extendedProperties = relationship("ExtendedProperty", back_populates="event", cascade="all, delete-orphan", lazy="joined") + reminders = relationship("Reminder", back_populates="event", cascade="all, delete-orphan") + workingLocationProperties = relationship("WorkingLocationProperties", uselist=False, back_populates="event", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class RecurringEvent(Base): + """ + RecurringEvent database model + """ + + __tablename__ = "recurring_events" + + # Primary key + recurring_event_id = Column(String(255), primary_key=True, nullable=False) + + # Original recurrence string for reference + original_recurrence = Column(Text, nullable=True, doc="Original recurrence string array as provided by user") + + # One-to-many relationship with Event instances + event_instances = relationship("Event", back_populates="recurring_event", cascade="all, delete-orphan") + + + + + +@event.listens_for(Event, "before_update", propagate=True) +def prevent_event_type_update(mapper, connection, target): + """Ensure eventType is immutable after creation""" + session = object_session(target) + + if session: + db_event_type = ( + session.query(Event.eventType) + .filter(Event.event_id == target.event_id) + .scalar() + ) + + if db_event_type: + # Normalize both values to strings for comparison + db_type_str = db_event_type.value if hasattr(db_event_type, 'value') else str(db_event_type) + target_type_str = target.eventType.value if hasattr(target.eventType, 'value') else str(target.eventType) + + if db_type_str != target_type_str: + raise IntegrityError( + None, None, + f"eventType cannot be modified once set (was '{db_type_str}', tried to change to '{target_type_str}')" + ) + diff --git a/envs/calendar_env/server/database/models/settings.py b/envs/calendar_env/server/database/models/settings.py new file mode 100644 index 000000000..dd15a4acd --- /dev/null +++ b/envs/calendar_env/server/database/models/settings.py @@ -0,0 +1,29 @@ +""" +Settings database model +""" + +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Index +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base + +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Index +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base + +class Settings(Base): + """Settings database model""" + + __tablename__ = "settings" + + id = Column(String(255), primary_key=True, nullable=False) # e.g., "timezone" + value = Column(String(255), nullable=False) # e.g., "Asia/Karachi" + etag = Column(String(255), nullable=True) + user_id = Column(String(255), ForeignKey("users.user_id"), nullable=True, index=True) + + # Relationships + user = relationship("User") + + def __repr__(self): + return f"" diff --git a/envs/calendar_env/server/database/models/user.py b/envs/calendar_env/server/database/models/user.py new file mode 100644 index 000000000..67c9e823d --- /dev/null +++ b/envs/calendar_env/server/database/models/user.py @@ -0,0 +1,66 @@ +""" +User database model +""" + +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from .base import Base + + +class User(Base): + """User database model""" + + __tablename__ = "users" + + user_id = Column(String(255), primary_key=True, nullable=False) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + given_name = Column(String(255), nullable=True) + family_name = Column(String(255), nullable=True) + picture = Column(String(500), nullable=True) # Profile picture URL + locale = Column(String(10), nullable=True, default="en") + timezone = Column(String(100), nullable=False, default="UTC") + + # Account status + is_active = Column(Boolean, default=True, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + + # OAuth/Authentication data + provider = Column(String(50), nullable=True) # google, microsoft, etc. + provider_id = Column(String(255), nullable=True) # OAuth provider user ID + # Static API token to authenticate MCP calls (mapped 1:1 to a user) + static_token = Column(String(255), unique=True, nullable=False, index=True) + access_token_hash = Column(Text, nullable=True) # Hashed for security + refresh_token_hash = Column(Text, nullable=True) # Hashed for security + + # Metadata + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + last_login_at = Column(DateTime, nullable=True) + + # Relationships + calendars = relationship("Calendar", back_populates="user", cascade="all, delete-orphan") + attendees = relationship("Attendees", back_populates="user", cascade="all, delete-orphan") + acls = relationship("ACLs", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary format for API responses""" + return { + "id": self.user_id, + "email": self.email, + "name": self.name, + "given_name": self.given_name, + "family_name": self.family_name, + "picture": self.picture, + "locale": self.locale, + "timezone": self.timezone, + "is_active": self.is_active, + "is_verified": self.is_verified, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_login_at": self.last_login_at.isoformat() if self.last_login_at else None + } \ No newline at end of file diff --git a/envs/calendar_env/server/database/models/watch_channel.py b/envs/calendar_env/server/database/models/watch_channel.py new file mode 100644 index 000000000..467438465 --- /dev/null +++ b/envs/calendar_env/server/database/models/watch_channel.py @@ -0,0 +1,54 @@ +from datetime import datetime +from sqlalchemy import Column, String, DateTime, Text, Integer +from .base import Base +import json + + +class WatchChannel(Base): + """Database model for storing watch channel subscriptions""" + __tablename__ = "watch_channels" + + id = Column(String, primary_key=True, index=True) # Channel ID + resource_id = Column(String, nullable=False, index=True) # e.g., "acl-{calendar_id}" + resource_uri = Column(String, nullable=False) # e.g., "/calendars/{calendar_id}/acl" + resource_type = Column(String, nullable=False, default="acl") # Type of resource being watched + + calendar_id = Column(String, nullable=False, index=True) # Calendar being watched + user_id = Column(String, nullable=False, index=True) # User who created the watch + + # Webhook details + webhook_address = Column(String, nullable=False) # URL to send notifications to + webhook_token = Column(String, nullable=True) # Optional verification token + webhook_type = Column(String, nullable=False, default="web_hook") + + # Channel metadata + params = Column(Text, nullable=True) # JSON string of additional parameters + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + expires_at = Column(DateTime, nullable=True) # When this channel expires + last_notification_at = Column(DateTime, nullable=True) # Last time a notification was sent + + # Status tracking + is_active = Column(String, default="true", nullable=False) # "true" or "false" as string + notification_count = Column(Integer, default=0, nullable=False) # Number of notifications sent + + def is_expired(self) -> bool: + """Check if the channel has expired""" + if self.expires_at is None: + return False + return datetime.utcnow() > self.expires_at + + def to_channel_dict(self): + """Convert to Channel schema format""" + return { + "kind": "api#channel", + "id": self.id, + "resourceId": self.resource_id, + "resourceUri": self.resource_uri, + "token": self.webhook_token, + "expiration": self.expires_at.isoformat() if self.expires_at else None, + "type": self.webhook_type, + "address": self.webhook_address, + "params": json.loads(self.params) if self.params else None + } \ No newline at end of file diff --git a/envs/calendar_env/server/database/seed_database.py b/envs/calendar_env/server/database/seed_database.py new file mode 100644 index 000000000..2f328d89e --- /dev/null +++ b/envs/calendar_env/server/database/seed_database.py @@ -0,0 +1,70 @@ +""" +Separate database for seed data storage +This keeps seed SQL content isolated from the main Calendar database +""" + +import os +import logging +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer, String, Text, create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +logger = logging.getLogger(__name__) + +# Separate Base for seed database +SeedBase = declarative_base() + +# Seed database path - stored in mcp_databases folder for persistence +SEED_DATABASE_PATH = os.getenv("SEED_DATABASE_PATH", "./mcp_databases/seed_store.db") +SEED_DATABASE_URL = f"sqlite:///{SEED_DATABASE_PATH}" + +# Create engine for seed database +seed_engine = create_engine( + SEED_DATABASE_URL, + connect_args={"check_same_thread": False}, + echo=False +) + +# Create session factory for seed database +SeedSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=seed_engine) + + +class SeedData(SeedBase): + """Model to store seed SQL content for databases""" + + __tablename__ = "seed_data" + + id = Column(Integer, primary_key=True, index=True) + database_id = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=True) + description = Column(Text, nullable=True) + sql_content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def __repr__(self): + return f"" + + +def init_seed_database(): + """Initialize the seed database and create tables""" + # Ensure the mcp_databases directory exists + db_dir = os.path.dirname(SEED_DATABASE_PATH) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + logger.info(f"Created directory: {db_dir}") + + logger.info(f"Initializing seed database at {SEED_DATABASE_PATH}") + SeedBase.metadata.create_all(bind=seed_engine) + logger.info("Seed database initialized successfully") + + +def get_seed_session(): + """Get a session for the seed database""" + return SeedSessionLocal() + + +__all__ = ["SeedData", "get_seed_session", "init_seed_database"] + diff --git a/envs/calendar_env/server/database/session_manager.py b/envs/calendar_env/server/database/session_manager.py new file mode 100644 index 000000000..c861696d7 --- /dev/null +++ b/envs/calendar_env/server/database/session_manager.py @@ -0,0 +1,164 @@ +""" +Calendar Session Manager - Database session handling for Calendar services using SQLAlchemy +""" + +import logging +import os +from typing import Dict +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database.models import Base + +logger = logging.getLogger(__name__) + + +class CalendarSessionManager: + """Calendar session manager with SQLAlchemy support for calendar services""" + + def __init__(self): + self._engines = {} # Cache engines per database + self._session_makers = {} # Cache session makers per database + + def get_db_path(self, db_id: str) -> str: + """Get database path for a specific session ID""" + databases_dir = "./mcp_databases" + os.makedirs(databases_dir, exist_ok=True) + return os.path.join(databases_dir, f"calendar_{db_id}.sqlite") + + def get_engine(self, db_id: str): + """Get SQLAlchemy engine for the specified database""" + if db_id not in self._engines: + db_path = self.get_db_path(db_id) + database_url = f"sqlite:///{db_path}" + self._engines[db_id] = create_engine( + database_url, + echo=False, # Set to True for SQL logging + connect_args={"check_same_thread": False}, # Needed for SQLite + pool_pre_ping=True, # Verify connections before using them + pool_recycle=3600, # Recycle connections after 1 hour + ) + return self._engines[db_id] + + def get_session_maker(self, db_id: str): + """Get SQLAlchemy session maker for the specified database""" + if db_id not in self._session_makers: + engine = self.get_engine(db_id) + self._session_makers[db_id] = sessionmaker(bind=engine) + return self._session_makers[db_id] + + def get_session(self, db_id: str): + """Get SQLAlchemy session for the specified database""" + session_maker = self.get_session_maker(db_id) + return session_maker() + + def close_session(self, db_id: str): + """Close and remove session for the specified database""" + if db_id in self._session_makers: + del self._session_makers[db_id] + if db_id in self._engines: + self._engines[db_id].dispose() + del self._engines[db_id] + logger.info(f"Closed session for database id '{db_id}'") + + def init_database(self, db_id: str, create_tables: bool = False): + """Initialize the database with SQLAlchemy models""" + try: + engine = self.get_engine(db_id) + + # Create all tables + if create_tables: + Base.metadata.create_all(engine) + + logger.info(f"Calendar database {db_id} initialized successfully with SQLAlchemy") + + except Exception as e: + logger.error(f"Failed to initialize database {db_id}: {e}") + raise + + def get_database_schema(self) -> Dict: + """Get the database schema definition dynamically from SQLAlchemy models""" + from sqlalchemy import inspect + + schema = {} + + # Get all mapped classes (models) from the Base metadata + for table_name, table in Base.metadata.tables.items(): + table_info = {"table_name": table_name, "columns": {}, "foreign_keys": [], "indexes": []} + + # Process columns + for column in table.columns: + column_def = [] + + # Column type + column_type = str(column.type) + if hasattr(column.type, "python_type"): + if column.type.python_type == int: + column_def.append("INTEGER") + elif column.type.python_type == str: + column_def.append("TEXT") + elif column.type.python_type == bool: + column_def.append("BOOLEAN") + elif column.type.python_type == float: + column_def.append("REAL") + else: + column_def.append(column_type.upper()) + else: + # Handle special SQLAlchemy types + type_str = str(column.type).upper() + if "VARCHAR" in type_str or "STRING" in type_str: + column_def.append("TEXT") + elif "INTEGER" in type_str: + column_def.append("INTEGER") + elif "TEXT" in type_str: + column_def.append("TEXT") + elif "BOOLEAN" in type_str: + column_def.append("BOOLEAN") + elif "DATETIME" in type_str: + column_def.append("DATETIME") + else: + column_def.append(type_str) + + # Primary key + if column.primary_key: + column_def.append("PRIMARY KEY") + if column.autoincrement: + column_def.append("AUTOINCREMENT") + + # Unique constraint + if column.unique: + column_def.append("UNIQUE") + + # Not null constraint + if not column.nullable: + column_def.append("NOT NULL") + + # Default value + if column.default is not None: + if hasattr(column.default, "arg"): + if callable(column.default.arg): + column_def.append("DEFAULT (function)") + else: + column_def.append(f"DEFAULT '{column.default.arg}'") + else: + column_def.append(f"DEFAULT '{column.default}'") + + table_info["columns"][column.name] = " ".join(column_def) + + # Process foreign keys + for fk in table.foreign_keys: + ref_table = fk.column.table.name + ref_column = fk.column.name + local_column = fk.parent.name + table_info["foreign_keys"].append(f"FOREIGN KEY ({local_column}) REFERENCES {ref_table}({ref_column})") + + # Process indexes + for index in table.indexes: + index_columns = [col.name for col in index.columns] + index_type = "UNIQUE" if index.unique else "INDEX" + table_info["indexes"].append( + f"{index_type} INDEX {index.name} ON {table_name} ({', '.join(index_columns)})" + ) + + schema[table_name] = table_info + + return schema \ No newline at end of file diff --git a/envs/calendar_env/server/database/session_utils.py b/envs/calendar_env/server/database/session_utils.py new file mode 100644 index 000000000..b90997933 --- /dev/null +++ b/envs/calendar_env/server/database/session_utils.py @@ -0,0 +1,19 @@ +""" +Database session utilities - Simplified session management +""" + +from sqlalchemy.orm import Session +from database.session_manager import CalendarSessionManager + +# Global session manager instance +_session_manager = CalendarSessionManager() + + +def get_session(database_id: str) -> Session: + """Get database session for the specified database ID""" + return _session_manager.get_session(database_id) + + +def init_database(database_id: str): + """Initialize database for the specified database ID""" + return _session_manager.init_database(database_id) \ No newline at end of file diff --git a/envs/calendar_env/server/google_calendar_apis.csv b/envs/calendar_env/server/google_calendar_apis.csv new file mode 100644 index 000000000..f7951e3b3 --- /dev/null +++ b/envs/calendar_env/server/google_calendar_apis.csv @@ -0,0 +1,38 @@ +Resource , API Endpoint , Description +Acl , DELETE /calendars/calendarId/acl/ruleId , Deletes an access control rule +Acl , GET /calendars/calendarId/acl/ruleId , Returns an access control rule +Acl , POST /calendars/calendarId/acl , Creates an access control rule +Acl , GET /calendars/calendarId/acl , Returns rules in the access control list +Acl , PATCH /calendars/calendarId/acl/ruleId , Updates an access control rule +Acl , PUT /calendars/calendarId/acl/ruleId , Updates an access control rule +Acl , POST /calendars/calendarId/acl/watch , Watch for changes to ACL resources +CalendarList, DELETE /users/me/calendarList/calendarId , Removes a calendar from user's list +CalendarList, GET /users/me/calendarList/calendarId , Returns a calendar from user's list +CalendarList, POST /users/me/calendarList , Inserts an existing calendar into user's list +CalendarList, GET /users/me/calendarList , Returns calendars on user's calendar list +CalendarList, PATCH /users/me/calendarList/calendarId , Updates an existing calendar +CalendarList, PUT /users/me/calendarList/calendarId , Updates an existing calendar +CalendarList, POST /users/me/calendarList/watch , Watch for changes to CalendarList resources +Calendars , POST /calendars/calendarId/clear , Clears a primary calendar +Calendars , DELETE /calendars/calendarId , Deletes a secondary calendar +Calendars , GET /calendars/calendarId , Returns metadata for a calendar +Calendars , POST /calendars , Creates a secondary calendar +Calendars , PATCH /calendars/calendarId , Updates calendar metadata +Calendars , PUT /calendars/calendarId , Updates calendar metadata +Channels , POST /channels/stop , Stop watching resources through this channel +Colors , GET /colors , Returns the color definitions for calendars and events +Events , DELETE /calendars/calendarId/events/eventId , Deletes an event +Events , GET /calendars/calendarId/events/eventId , Returns an event +Events , POST /calendars/calendarId/events/import , Imports an event +Events , POST /calendars/calendarId/events , Creates an event +Events , POST /calendars/calendarId/events/eventId/instances, Returns instances of the recurring event +Events , GET /calendars/calendarId/events , Returns events on the specified calendar +Events , POST /calendars/calendarId/events/eventId/move , Moves an event to another calendar +Events , PATCH /calendars/calendarId/events/eventId , Updates an event +Events , POST /calendars/calendarId/events/quickAdd , Creates an event based on a simple text string +Events , PUT /calendars/calendarId/events/eventId , Updates an event +Events , POST /calendars/calendarId/events/watch , Watch for changes to Events resources +Freebusy , POST /freeBusy , Returns free/busy information for a set of calendars +Settings , GET /users/me/settings/setting , Returns a single user setting +Settings , GET /users/me/settings , Returns all user settings +Settings , POST /users/me/settings/watch , Watch for changes to Settings resources \ No newline at end of file diff --git a/envs/calendar_env/server/handlers/__init__.py b/envs/calendar_env/server/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/handlers/mcp_handler.py b/envs/calendar_env/server/handlers/mcp_handler.py new file mode 100644 index 000000000..ed65a3a4b --- /dev/null +++ b/envs/calendar_env/server/handlers/mcp_handler.py @@ -0,0 +1,165 @@ +""" +MCP Protocol Handler using official MCP library +""" + +import json +import logging +from typing import Union, Optional +from fastapi import Request +from mcp.types import ( + JSONRPCRequest, + JSONRPCResponse, + InitializeResult, + ServerCapabilities, + ListToolsResult, + CallToolResult, + TextContent, + Implementation, +) +from handlers.tool_handlers import MCP_TOOLS_LIST, TOOL_HANDLERS +from database.managers.user_manager import UserManager + +logger = logging.getLogger(__name__) + + +async def handle_mcp_request(request: Request) -> Optional[JSONRPCResponse]: + """Handle MCP protocol messages from FastAPI Request""" + try: + # Parse JSON body from FastAPI Request + body = await request.json() + + # Extract JSON-RPC fields + jsonrpc = body.get("jsonrpc", "2.0") + method = body.get("method") + params = body.get("params") + request_id = body.get("id") # None for notifications + + # Check if it's a notification (no id field) + is_notification = request_id is None + + logger.info(f"Received MCP request: method={method}, id={request_id}, is_notification={is_notification}") + + # Handle notifications/initialized - return 204 No Content + if method == "notifications/initialized": + logger.info("MCP client initialized") + return None # This triggers 204 response in router + + # Create JSONRPCRequest object for requests with id + if not is_notification: + mcp_request = JSONRPCRequest(jsonrpc=jsonrpc, method=method, params=params, id=request_id) + + if method == "initialize": + return await handle_initialize(mcp_request) + elif method == "tools/list": + return await handle_tools_list(mcp_request) + elif method == "tools/call": + return await handle_tools_call(mcp_request, request) + elif method == "resources/list": + logger.info("Resources list request received") + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result={"resources": []}) + elif method == "resources/templates/list": + logger.info("Resource templates list request received") + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result={"resourceTemplates": []}) + else: + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result={"error": f"Method not found: {method}"}) + else: + # Handle other notifications if needed + logger.info(f"Received notification: {method}") + return None + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in request: {e}") + return JSONRPCResponse(jsonrpc="2.0", id=None, result={"error": "Invalid JSON"}) + except Exception as e: + logger.error(f"MCP error: {e}") + # Try to get request_id if available + try: + body = await request.json() + request_id = body.get("id") + except: + request_id = None + + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result={"error": f"Internal error: {str(e)}"}) + + +async def handle_initialize(request: JSONRPCRequest) -> JSONRPCResponse: + """Handle MCP initialize request""" + result = InitializeResult( + protocolVersion="2024-11-05", + capabilities=ServerCapabilities(tools={}, logging={}, prompts={}, resources={}), + serverInfo=Implementation(name="calendar-api-clone-server", version="1.0.0"), + ) + return JSONRPCResponse(jsonrpc="2.0", id=request.id, result=result.model_dump(exclude_none=True)) + + +async def handle_tools_list(request: JSONRPCRequest) -> JSONRPCResponse: + """Handle MCP tools/list request""" + logger.info(f"Tools list request received. Calendar tools: {len(MCP_TOOLS_LIST)} tools") + + # Use the official ListToolsResult class + result = ListToolsResult(tools=MCP_TOOLS_LIST) + return JSONRPCResponse(jsonrpc="2.0", id=request.id, result=result.model_dump(exclude_none=True)) + + +async def handle_tools_call(request: JSONRPCRequest, fastapi_request: Request) -> JSONRPCResponse: + """Handle MCP tools/call request""" + try: + params = request.params or {} + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + logger.info(f"Tool call: {tool_name} with arguments: {arguments}") + + # Extract database_id from headers (following Ola pattern) + headers = dict(fastapi_request.headers) + database_id = headers.get("x-database-id") + + # Require database_id early (needed for user manager) + if not database_id: + raise ValueError("database_id is required") + + user_manager = UserManager(database_id) + + # Extract access token from headers + access_token = headers.get("x-access-token") + if not access_token: + user = user_manager.get_first_user_from_db() + if not user: + raise ValueError("User not found") + else: + # Validate user using static token mapped to user + user = user_manager.get_user_by_access_token(access_token) + if not user: + raise ValueError(f"User not found for given access token") + + # user_id is used to scope operations to this user's calendars + user_id = user["id"] + + # Check if we have a handler for this tool + if tool_name not in TOOL_HANDLERS: + result = CallToolResult( + content=[TextContent(type="text", text=f"Unknown tool: {tool_name}")] + ) + return JSONRPCResponse(jsonrpc="2.0", id=request.id, result=result.model_dump(exclude_none=True)) + + # Execute the tool using the generic handler + tool_result = await TOOL_HANDLERS[tool_name](tool_name, arguments, database_id, user_id) + + # Format the response + if tool_result.get("isError", False): + text_content = tool_result.get('text', 'Unknown error') + else: + text_content = tool_result.get("text", "Operation completed successfully") + + result = CallToolResult( + content=[TextContent(type="text", text=text_content)] + ) + + return JSONRPCResponse(jsonrpc="2.0", id=request.id, result=result.model_dump(exclude_none=True)) + + except Exception as e: + logger.error(f"Error in tools/call: {e}") + result = CallToolResult( + content=[TextContent(type="text", text=f"Tool execution failed: {str(e)}")] + ) + return JSONRPCResponse(jsonrpc="2.0", id=request.id, result=result.model_dump(exclude_none=True)) \ No newline at end of file diff --git a/envs/calendar_env/server/handlers/tool_handlers.py b/envs/calendar_env/server/handlers/tool_handlers.py new file mode 100644 index 000000000..75c2cd0da --- /dev/null +++ b/envs/calendar_env/server/handlers/tool_handlers.py @@ -0,0 +1,450 @@ +""" +MCP Tool Handlers - Generic API Caller for Calendar API + +This module provides a generic handler that dynamically calls internal Calendar API endpoints +based on tool configuration. This approach is much more scalable and maintainable. +""" + +import httpx +import json +import os +import logging +from typing import Dict +from datetime import datetime +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +def log_tool_response(tool_name, tool_input, result, database_id): + """Log tool responses to file for debugging""" + try: + # Only log during test runs (when database_id looks like test ID) + if not database_id or "test" not in database_id.lower(): + return + + # Create logs directory + logs_dir = Path("tests/tool_responses") + logs_dir.mkdir(exist_ok=True) + + # Create timestamped log entry + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] # Include milliseconds + log_entry = { + "timestamp": timestamp, + "tool_name": tool_name, + "database_id": database_id, + "input": tool_input, + "result": result + } + + # Append to daily log file + date_str = datetime.now().strftime("%Y%m%d") + log_file = logs_dir / f"tool_responses_{date_str}.json" + + # Read existing logs or create new + logs = [] + if log_file.exists(): + try: + with open(log_file, 'r') as f: + logs = json.load(f) + except: + logs = [] + + # Add new entry + logs.append(log_entry) + + # Write back to file + with open(log_file, 'w') as f: + json.dump(logs, f, indent=2) + + except Exception: + # Don't let logging errors break the tool execution + pass + +def get_query_parameters_for_tool(tool_name: str) -> set: + """ + Automatically determine which parameters should be query parameters + by inspecting the FastAPI route dependencies and parameters + """ + from fastapi.params import Query as QueryParam + import inspect + + # Import routers to inspect their endpoints + from apis.calendars.router import router as calendars_router + from apis.calendarList.router import router as calendar_list_router + from apis.events.router import router as events_router + from apis.colors.router import router as colors_router + from apis.users.router import router as users_router + from apis.settings.router import router as settings_router + from apis.acl.router import router as acl_router + + # Check all routes in all routers + routers = [calendars_router, calendar_list_router, events_router, colors_router, users_router, settings_router, acl_router] + + for router in routers: + for route in router.routes: + if hasattr(route, 'endpoint') and route.endpoint.__name__ == tool_name: + query_params = set() + + # Method 1: Check route's dependant (FastAPI's internal parameter analysis) + if hasattr(route, 'dependant') and route.dependant: + for dep in route.dependant.query_params: + query_params.add(dep.alias or dep.name) + + # Method 2: Fallback - manual inspection of function signature + if not query_params: + sig = inspect.signature(route.endpoint) + for param_name, param in sig.parameters.items(): + # Skip obvious path/header params + if param_name in ['calendarId', 'eventId', 'x_database_id']: + continue + + # Check if it has Query() as default or in annotation + if param.default is not inspect.Parameter.empty: + # Check if default is a Query instance + if isinstance(param.default, QueryParam): + query_params.add(param_name) + # Check string representation for Query() + elif 'Query(' in str(param.default): + query_params.add(param_name) + + return query_params + + return set() # Return empty set if tool not found + +# Internal API base URL (same server) - configurable via environment or .env +API_PORT = os.getenv("API_PORT", "8004") +API_BASE_URL = f"http://localhost:{API_PORT}" + +# Import tools from the modular structure +from calendar_mcp.tools import MCP_TOOLS + +# Dynamic endpoint mapping based on FastAPI router inspection +def get_api_endpoint_for_tool(tool_name: str) -> tuple: + """ + Dynamically determine API endpoint by matching tool name to router function names. + This eliminates the need for static mapping and ensures consistency. + """ + # Import routers to inspect their endpoints + from apis.calendars.router import router as calendars_router + from apis.calendarList.router import router as calendar_list_router + from apis.events.router import router as events_router + from apis.colors.router import router as colors_router + from apis.users.router import router as users_router + from apis.settings.router import router as settings_router + from apis.acl.router import router as acl_router + from apis.freebusy.router import router as freebusy_router + + # Check all routes in all routers + routers = [calendars_router, calendar_list_router, events_router, colors_router, users_router, settings_router, acl_router, freebusy_router] + + for router in routers: + for route in router.routes: + if hasattr(route, 'endpoint') and route.endpoint.__name__ == tool_name: + # Extract method and path (path already includes router prefix) + methods = list(route.methods) + method = methods[0] if methods else "GET" + full_path = route.path + return (method, full_path) + + # Return None if not found + return None + + + +async def execute_tool_generic(tool_name: str, arguments: Dict, database_id: str, user_id: str) -> Dict: + """ + Generic tool executor that dynamically calls API endpoints based on tool configuration. + This eliminates the need for individual handler functions. + Supports path parameters for endpoints like /calendars/{calendarId} + """ + try: + # Find the tool configuration + tool_config = None + for tool in MCP_TOOLS: + if tool["name"] == tool_name: + tool_config = tool + break + + if not tool_config: + result = {"text": f"Tool configuration not found: {tool_name}", "isError": True} + log_tool_response(tool_name, arguments, result, database_id) + return result + + # Get API endpoint dynamically from router inspection + endpoint_info = get_api_endpoint_for_tool(tool_name) + if not endpoint_info: + result = {"text": f"API endpoint not found for tool: {tool_name}. Check that router function name matches tool name.", "isError": True} + log_tool_response(tool_name, arguments, result, database_id) + return result + + method, api_endpoint = endpoint_info + + # Handle path parameters (like {calendarId}) + final_endpoint = substitute_path_parameters(api_endpoint, arguments) + + # Check for missing path parameters + if final_endpoint.startswith("ERROR_MISSING_PARAM_"): + missing_param = final_endpoint.replace("ERROR_MISSING_PARAM_", "") + result = { + "text": f"The parameter '{missing_param}' is required and cannot be empty.", + "isError": True + } + log_tool_response(tool_name, arguments, result, database_id) + return result + + # Build full URL + full_url = f"{API_BASE_URL}{final_endpoint}" + + # Prepare headers + headers = { + "Content-Type": "application/json", + "x-database-id": database_id, + "x-user-id": user_id + } + + # Add user_id header if present in arguments + # if "user_id" in arguments: + # headers["x-user-id"] = arguments["user_id"] + + # Prepare request data (separate body and query parameters) + # RL Gym style: + # - Allow nested 'body' field to carry JSON body for non-GET/DELETE methods + body_data, query_data = prepare_request_data(arguments, api_endpoint, tool_name) + + + # Make HTTP request + async with httpx.AsyncClient(timeout=30.0) as client: + if method.upper() == "GET": + # For GET requests, use query parameters (combine body_data and query_data) + all_query_params = {**body_data, **query_data} + response = await client.get(full_url, headers=headers, params=all_query_params) + elif method.upper() == "DELETE": + # DELETE requests typically don't have body, but may have query params + response = await client.delete(full_url, headers=headers, params=query_data) + else: + # POST, PUT, PATCH requests with JSON body and optional query params + response = await client.request( + method.upper(), full_url, headers=headers, json=body_data, params=query_data + ) + + # Handle response + if response.status_code == 204: + # No content responses (like DELETE) + result = { + "text": f"Operation completed successfully", + "status_code": response.status_code, + "isError": False + } + elif 200 <= response.status_code < 300: + # Success responses + try: + response_data = response.json() + result = { + "text": json.dumps(response_data, indent=2), + "status_code": response.status_code, + "isError": False + } + except: + result = { + "text": f"Operation completed successfully\nStatus: {response.status_code}", + "status_code": response.status_code, + "isError": False + } + else: + # Error responses: prefer a friendly, contextual message + friendly_text = None + error_data = None + try: + error_data = response.json() + if isinstance(error_data, dict): + # Prefer FastAPI 'detail' string + if isinstance(error_data.get("detail"), str): + friendly_text = f"{error_data['detail']}" + # Handle FastAPI/Pydantic validation error list + elif isinstance(error_data.get("detail"), list): + details = error_data.get("detail") + lines = [] + for item in details: + if not isinstance(item, dict): + continue + msg = item.get("msg") or item.get("message") + loc = item.get("loc") or [] + # Build a full path like defaultReminders[0].method, skipping generic roots + path_parts = [] + if isinstance(loc, list): + for part in loc: + if part in ("body", "query", "path"): + continue + if isinstance(part, int): + # attach index to previous token if exists + if path_parts: + path_parts[-1] = f"{path_parts[-1]}[{part}]" + else: + path_parts.append(f"[{part}]") + else: + path_parts.append(str(part)) + path_str = ".".join(path_parts) if path_parts else "" + + # Rewrite common messages to friendlier phrasing + if isinstance(msg, str): + lower = msg.lower() + if lower.startswith("string should have at least"): + msg = msg.replace("String should have", "must have").replace("string should have", "must have") + elif lower == "field required": + msg = "is required" + elif lower.startswith("value error"): + msg = msg.replace("Value error, ", "") + + if path_str: + lines.append(f"- {path_str}: {msg}") + else: + lines.append(f"- {msg}") + + if lines: + friendly_text = "Validation errors:\n" + "\n".join(lines) + # Fallback to Google-style error.message + if friendly_text is None and isinstance(error_data.get("error"), dict) and isinstance(error_data["error"].get("message"), str): + friendly_text = f"{error_data['error']['message']}" + except Exception: + # Non-JSON error body + pass + + # Calendars-specific enhancement: synthesize message if missing + if not friendly_text and "/calendars" in final_endpoint: + # For 404s, attempt to compose a clear message with user and calendarId + if response.status_code == 404: + cal_id = arguments.get("calendarId") or arguments.get("id") + if cal_id: + friendly_text = f"User '{user_id}' has no calendar '{cal_id}'" + + if friendly_text is not None: + result = { + "text": friendly_text, + "status_code": response.status_code, + "isError": True + } + else: + # Fall back to structured error output + try: + if error_data is None: + error_data = response.json() + result = { + "text": f"API Error: {json.dumps(error_data, indent=2)}", + "status_code": response.status_code, + "isError": True + } + except Exception: + result = { + "text": f"HTTP {response.status_code}: {response.text}", + "status_code": response.status_code, + "isError": True + } + + # Log the tool response for debugging + log_tool_response(tool_name, arguments, result, database_id) + return result + + except httpx.RequestError as e: + result = { + "text": f"Request Error: {str(e)}\nFull URL: {full_url}", + "isError": True + } + log_tool_response(tool_name, arguments, result, database_id) + return result + except Exception as e: + result = { + "text": f"Unexpected Error: {str(e)}", + "isError": True + } + log_tool_response(tool_name, arguments, result, database_id) + return result + + +def extract_api_endpoint(description: str) -> str: + """Extract API endpoint from tool description""" + import re + + # Look for pattern like "API Endpoint: POST /calendars" + match = re.search(r"API Endpoint:\s*[A-Z]+\s*(/[^\s\n]+)", description) + if match: + return match.group(1) + + return "" + + +def extract_http_method(description: str) -> str: + """Extract HTTP method from tool description""" + import re + + # Look for pattern like "API Endpoint: POST /calendars" + match = re.search(r"API Endpoint:\s*([A-Z]+)\s*/", description) + if match: + return match.group(1) + + return "GET" # Default to GET + + +def substitute_path_parameters(endpoint: str, arguments: Dict) -> str: + """Replace path parameters like {calendarId} with actual values""" + import re + + # Find all path parameters in the format {paramName} + path_params = re.findall(r'\{(\w+)\}', endpoint) + + result_endpoint = endpoint + for param in path_params: + param_value = None + + if param in arguments and arguments[param]: + param_value = str(arguments[param]) + elif param + "Id" in arguments and arguments[param + "Id"]: + # Handle cases like {calendarId} mapped to calendarId argument + param_value = str(arguments[param + "Id"]) + elif param.replace("Id", "") in arguments and arguments[param.replace("Id", "")]: + # Handle cases like {calendarId} mapped to calendar argument + base_param = param.replace("Id", "") + param_value = str(arguments[base_param]) + + if param_value: + result_endpoint = result_endpoint.replace(f"{{{param}}}", param_value) + else: + # If path parameter is missing or empty, return error indicator + return f"ERROR_MISSING_PARAM_{param}" + + return result_endpoint + + +def prepare_request_data(arguments: Dict, endpoint: str, tool_name: str) -> tuple[Dict, Dict]: + """Prepare request data by separating body and query parameters""" + import re + + # Find all path parameters + path_params = set(re.findall(r'\{(\w+)\}', endpoint)) + + # Dynamically get query parameters for this tool by inspecting router + query_params_for_tool = get_query_parameters_for_tool(tool_name) + + body_data = {} + query_data = {} + + for key, value in arguments.items(): + # Skip path parameters and their variations + if key in path_params or key + "Id" in path_params or key.replace("Id", "") in path_params: + continue + + # Decide if this should be query parameter or body parameter + if key in query_params_for_tool: + query_data[key] = value + else: + body_data[key] = value + + return body_data, query_data + + +# Create the MCP_TOOLS_LIST and TOOL_HANDLERS for compatibility with the MCP handler +MCP_TOOLS_LIST = MCP_TOOLS + +# All tools use the same generic handler +TOOL_HANDLERS = {tool["name"]: execute_tool_generic for tool in MCP_TOOLS} \ No newline at end of file diff --git a/envs/calendar_env/server/main.py b/envs/calendar_env/server/main.py new file mode 100644 index 000000000..b4e751e73 --- /dev/null +++ b/envs/calendar_env/server/main.py @@ -0,0 +1,213 @@ +""" +Calendar API Clone - FastAPI Application +Complete implementation of Google Calendar APIs +""" + +import logging +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +from dotenv import load_dotenv + +load_dotenv() + +# Import API routers +from apis.core_apis import router as core_router +from apis.database_router import router as database_router +from database.seed_database import init_seed_database +from apis.mcp.router import router as mcp_router +from apis.calendars.router import router as calendars_router +from apis.calendarList.router import router as calendar_list_router +from apis.events.router import router as events_router +from apis.colors.router import router as colors_router +from apis.users.router import router as users_router, api_router as user_api_router +from apis.settings.router import router as settings_router +from apis.acl.router import router as acl_router +from apis.freebusy.router import router as freebusy_router + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +# Import OpenEnv modules +try: + from openenv_wrapper.custom_http_server import MCPHTTPEnvServer + from openenv_wrapper.mcp_env_environment import MCPEnvironment + from openenv_wrapper.data_models import MCPAction, MCPObservation + from openenv_wrapper.config import MCP_NAME + OPENENV_AVAILABLE = True +except ImportError as e: + logger.warning(f"OpenEnv modules not available: {e}") + OPENENV_AVAILABLE = False + + +def create_calendar_environment(): + """ + Factory function for creating Calendar environment with config. + + This function is called for each WebSocket session to create an isolated + environment instance. + """ + database_id = os.getenv("DEFAULT_DATABASE_ID", "default") + auth_token = os.getenv("DEFAULT_AUTH_TOKEN") + return MCPEnvironment(database_id=database_id, auth_token=auth_token) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Generic lifespan event handler for OpenEnv integration. + + This lifespan function is fully generic and can be copied to any MCP project. + It dynamically loads the MCP configuration from openenv_wrapper/config.py. + + To use in a different MCP: + 1. Copy the entire openenv_wrapper folder to your MCP project + 2. Update openenv_wrapper/config.py with your MCP-specific settings + 3. Copy this lifespan function and the imports above to your main.py + 4. Pass lifespan=lifespan to FastAPI(...) + """ + # Startup + logger.info("Starting Calendar API Backend...") + + # Initialize separate seed storage database + init_seed_database() + logger.info("Seed database initialized successfully") + + if OPENENV_AVAILABLE: + logger.info(f"Initializing {MCP_NAME} OpenEnv environment...") + try: + # Pass the factory function (not an instance!) + # The new OpenEnv expects a callable (class or factory function) + # It will call this function to create environment instances as needed + http_server = MCPHTTPEnvServer( + env=create_calendar_environment, # Pass function, don't call it! + action_cls=MCPAction, + observation_cls=MCPObservation + ) + + # Register all custom routes (reset, step, state) + http_server.register_routes(app) + + logger.info(f"{MCP_NAME} OpenEnv environment initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize {MCP_NAME} OpenEnv: {e}", exc_info=True) + # Continue without OpenEnv routes if initialization fails + else: + logger.warning("OpenEnv routes not registered - modules not available") + + yield + + # Shutdown + if OPENENV_AVAILABLE: + logger.info(f"Shutting down {MCP_NAME} OpenEnv environment...") + logger.info("Shutting down Calendar API Backend...") + + +# Create FastAPI app +app = FastAPI( + lifespan=lifespan, + title="Calendar API Clone", + description=""" + Google Calendar API implementation with FastAPI + + ## Features + + * **Calendar Management**: Create, read, update, delete calendars + * **Event Management**: Full event lifecycle management + * **Database APIs**: Database management and inspection + * **Official APIs**: Google Calendar API v3 endpoints implemented + + ## API Categories + + * **Core APIs**: Health check and system status + * **Calendar APIs**: Calendar CRUD operations (SQLAlchemy-based) + * **CalendarList APIs**: User calendar list management and settings + * **Event APIs**: Event management and scheduling with 11 endpoints + * **ACL APIs**: Access control list management with 7 endpoints + * **Colors APIs**: Static color definitions for calendars and events + * **Database APIs**: Database management and inspection + * **MCP APIs**: Model Context Protocol support + + ## Getting Started + + 1. Check API health using `GET /health` + 2. Initialize database using database APIs + 3. Start creating calendars and events + + ## Google Calendar APIs + + This implementation includes the major Google Calendar API v3 endpoints for: + - Calendar management (6 endpoints) + - Calendar list management (7 endpoints) + - Event management (11 endpoints) + - Access control (7 endpoints) + - Colors (1 endpoint) + - Free/busy queries (1 endpoint) + """, + version="1.0.0", + contact={ + "name": "Calendar API Clone", + "url": "https://github.com/calendar-api-clone", + }, + license_info={ + "name": "MIT", + "url": "https://opensource.org/licenses/MIT", + }, +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify actual origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include MCP router for Model Context Protocol support (no prefix) +app.include_router(mcp_router, tags=["MCP"]) + +# Include API routers +app.include_router(core_router) +app.include_router(database_router) +app.include_router(calendars_router) +app.include_router(calendar_list_router) +app.include_router(events_router) +app.include_router(colors_router) +app.include_router(users_router) +app.include_router(user_api_router) +app.include_router(settings_router) +app.include_router(acl_router) +app.include_router(freebusy_router) + + +@app.get("/", include_in_schema=False) +async def root(): + """Redirect root to API documentation""" + return RedirectResponse(url="/docs") + + +@app.get("/api", include_in_schema=False) +async def api_root(): + """API root endpoint""" + return RedirectResponse(url="/docs") + + +@app.exception_handler(500) +async def internal_error_handler(request, exc): + """Custom 500 handler""" + from fastapi.responses import JSONResponse + + logger.error(f"Internal server error on {request.url}: {exc}") + return JSONResponse( + status_code=500, + content={ + "error": "Internal server error", + "message": "An unexpected error occurred. Please try again later.", + "support": "Check logs for more details", + }, + ) diff --git a/envs/calendar_env/server/mcp_databases/seed_store.db b/envs/calendar_env/server/mcp_databases/seed_store.db new file mode 100644 index 0000000000000000000000000000000000000000..c2f2ed141e42ace106948f602647c0f8ef794cb8 GIT binary patch literal 45056 zcmeHQTW=%Db=K^7?d%x##>j(V1j$y$NjL+G61#a5Wf(AZnUQ8BjYM%~cGfV6Cc8=X zsL7_-q%>M14|ah3iXeZ&dCQ~!Nsu3qr@SN&`Oc~8X7zWIES8Cp|OUi-_n2M^YMDa6{^+Q<0+`}jZnd5DYegg5y6K)YUa`SIGu zzdZc`o<98HKdk-mzkm3z_>=$S6v!!%Qy`~6PJx^PIR$bG{m;ds zT;kPtYLz`ym1Wf8+KIwl`|0m~@Zftt{_L{{|Lme3 z&xvY|E_gmuRRa>0AJ~~<#k3P|wEZ#I*y9dqI6SG=< z8hdGE4V~Ci&glfbi94RU{z$awNdJPxSrXU!`t4hIh*=oZ%3FVvsN<9 zddH|#%yONQi)x}1{fR#m&u@pAh$q+1q*xTkGjHk^U*lfvTAnkqt%*3dhGWkWd$1kI zlP!kyPG^R9Iz9EyBV>K>h?NQ|I!()i>W(wzVt`P;07J5H$^VK z1c}_f>!8%RrLZpMR>4!4AV;tF&mTQ0Sf1N+Izww@4V+0~UC4wDmGH}{)4S;TlkrB+ zAJWq%y)It(7o)%*MG17FrQKR)W20GWwkEc9T)b?&xvD?^@|`(8stnKiTfvF(p z%Cm#si?`R++m~OS)|_YN#?{UKq<#DP&>uI?_xjH-UR)l1`FZb6_he|8opPmEt#O4z zbgpM@o;Ww1cmCuOX$Jq2$wLHrQW*I+g{SKxk??dK-R=6_j*W5g{ITpV5;lS%1~Jvu z9ltBi=?T?}8~PL7$GiB&hpm&F@>ym4rdvA-8ZBekF7=+9vuD-YgZkwQdn;%fM_1+s zIyikdJM>SjbNkug;QdSQix0ckJEP;qR=rcJ7As}Fhev+UX^sXC%+u96*|R3Thi(S$ z0Gv)ma>~Zd1@?GS{oJLu#anttuktNjL@DvUcIyYd>YI^yHr#3%LAx?ODt&m@xOrKg zJ*&O{!gN|aW8=(v8|>SyetU3m`(k!@`N}(0Bl+_E!JywUjbf>>MV0K&CK!p$BY$Kw zKdms7wsq}v0?0K{r$Bcb-HCrQN_B7#kHii=q8I6o{zeJ$?v2YwVAyW$?8c-~r5+9s z>H~~qt2BGjGo90`4K-*Ub>>-rFR;!e=en=P%|LQ)r(Q1_2G!G4{JMJqt{wPDx?>-I z&Wa;cZgJ%?*E3JTORT&g;-u^7BA5+_*5npKFkf3pebI>&#(3h&cY40(%WM~}ZIfaN zz;r!mNXqXyd~r8TQdEUf&r_-;r94h$3q;rl)~vC&L{Xeh{P$>#IG*dMkfw1RP#}7j zn7;ToA^oWYRtMZfw3%yQK1ww@Ui}ano%$B04jt&6(I6l~S+?hn99)v_ht76_DReWJ zn$ci1u~5HfgQt$g+BntNHXdNk@+Y=C!bBleG$+m*Y83rZ(NN8#lGHp!-CEF2=H%5V z3%#oI0&e4aH>O=P`5<}&JaF!eTukVDy56rGR70}_7ht)?$v%wXqb~NkH#_8 z_*3;zv@t`{8&QG&I1R!C zF}^$Yfny+egG4V9mQMKnKA{D&u@;0?Fe)p96&Q@BgF`qWhF42q(lYTO+B-mqy#5^M zkU8Kj16NkKZ%ZYRj>wR1sH{`Gl|aOTx(%El0Qd&z4+3NX3lcciq<0~aK@6k%K#GX3=^vY)pVaNk9AY0*{?Tq2} z=B<>qHVMh6t+ zRUCc?sUXy>7d{kE9tr#q@UA;^J%FzUhm4&IF6nRI10pNBI0r?+9CYtlUB`Q*p>buw z#jubfBM4)6GTKQ0x6lSgcNZ94AmrQ;(9py2EWilvsFnx=U|2w3lVzJlZdZ}0Q;6yy z>{J09e_4E6E>@~^r@eLqOk42=-J|rcs@Gf>ōye{4%qj<~kD9FgAkcI2*QsY~k z`2MhH#o9zvkUdsyVX`1Ps_#skQO_w_h=-yaKx?Zc(LynznIMB4+LqIsO@P@NhXROPev~wk|>lu~z z1w-NIqY<@jI)kRTb_2KTdhQgc*g4|W-L<^ehkIx;@k0A{jMDplZx&ERKzRaHM~X-m zHJE{ogI#NMID&q1UQI6ms{>j;Og{E)w~ykZgmXu}^qk+2h4{1StG-k(TKRz7Ud@Pw z0KWBS6Z)HtxYb#V+gt=bAAJT$S2CIc-Y!SArjVnYu<>33`%@6=W&-vP2y=lXl7vp- zA5RtjQT(H5Vy**udehm&5iJHn(u;czLMc3iT?BE&B)ZPirHrMZi89Izj>S;WT*}bk zvx?{=@)5-b8&D~Qg6AoqrJQI1xSh~o2@Dgk%{b7==_3$iDkok-XbKFOprD2k(Ab`^ z#^Z@^^)5Ctwa2=yL(u_>P*I^GrN(xtgx{a>zm!{hXc)#@yP$Vdh*%`tA@D;kjc7MkH2a!VXbDCi8fHREg> z>1ulWH;*kujqN!-0^&m%GppM2_5_myF{rvdhR#9A6Y=U{p)drcrP^n!d_$ZvsmXFz zNN-F>xPz(?(BGI#EOG8xy-Oa?*<`>Lsx`%E1shaL@4}j5Lc_#EHQ4@%KizW!Xs#}E zJ0wf~N*dr=Uffd(*o5Q7iM{|xuT~bM+$k_fmxShMG=ug$awxKlqK5C}H#1y0kF*a#fQ^h_Um68j^)%GV-j8Ig?01%gziwF30&; zvkLHj#1>KoFJ8wph(MilYhqo9Cm^sDkE(mRAs}l! zX9r_Me9<@$r5qxV^1>O3L&z9}WW+M0$?|?^oRK*F3Np--8+9;}f4aGWE(8t2McdiGYDKm{`LhiDT%OAi0V$I>KKY8PXC>A&s_C|3+tt ztlCzXB_iSCnJ#S>RN}&w;ESmD2^;6@atg1Om%Cx&P8qtpOVFaOd&x^vC^LJ4vm1Sxc&Dz2t=7%XOL9$4UQRu~a$*{NcitOW-#0uo+zKLhae?QFp3VJGba&T*Nv!kX&m*G^$|7)t@?GsYLmxsNIZV!d zZ$^U60*ytWJ%ie`@zN-mDv>9#K6VBcJ9Z97JyO6hDM?>LafjuHt09WmgihzRElGZW z#f>EwyBi37CD})VdaMI35VEK|ywN%+7(2ayXWOXG1H?04&9EeA6>pejn3n;?HwL0f zVdhx22#UYP)jQ(BP*;>o?ezLbf4I=x)D}XKt*NEO_|Pb3))T4_En9})1+9%S>wM|M zA*5=urUjzQYw9?w&fk_Tg4U3;w7iAaOJ6AMwvNU5Z7_9)V1S6YdyeJNgq2t=^(Mpk zVhKT|Ua&a?AJF=#2r_=H)e^4+b19&i@Q*D8#xg0u!*5Covg*mDKpp|X?&b8oOZ+JOJ~aNa=7>5=fG)oHp~LGjnjcKlCi%Yc{QJ4fRr?=sW})ra3tHu<$4goj^w3^- z6{2Qq8>Gm7mzFXAXuibXz-2IoElWPyotZswrsS4$M%ce|aGcO+K*ksu8rbbXR(UxT`5I5Vaum z0fr3(fAZNCi+atRmhLsTD%NXA`fxUNuq6wJhwV(L3DQm3y%k$4sA42Gfi=UjlW<~ zV;1b}B=xQ8KwPMn>{Y0Rn0 z%!uxJM&jc6Dmu!FL>g)vTZ9t6aAE-gz-R@_wt*6&GjG)Xy6}0Y%)&W(*tt>L=(vKj zIdJ55jJ|!2s_)F1yg#m=lXv9%yu|@W%?;%CS$Rcvw8QG|TFnAc;K?h!Q^cD!?*S1MZeNUi^7iwdgE|5+26PJ@%|wLV z*zs~`oAm4z=wxH)KR7d4%9Au$AZgS%*=5ynOf_-H4_=T2AgAeaIxi4LX6U#-@N`1z zn)h?z$s=UJ#D!;ucS|r6kYkPdx(Bymbkf{ihhd3Ohl5KN3i<9#4Z6TS|DiMNV(O&2 z>HI~p=hN0HTu`#|ujv0Wb-=qf9oo64!pUvrO#W<>gd_A{qlOp-udGFVEFp= z)p+W4gL>uF;CkPjjfcS|oh=s04VoWo#WM8~wZezLS6dyyB89(Kdc{_4i~}vaurUji z3^RSym<1%6^Osx^Ve9^qE%N34U!XefRHYK;B2%KMHGo{N%d1_cO`@JA$Zo@@Pb>+N zlc=$XVa*F?yjJ(7B(R)R3GP9vAl&1m;}O;BF2>es%PYdD#@1Urd#2tk`5iY zF$p}{4EU;HSn&~ISAoRiy~r@}(AMYI8!yI}Jh7zh2Oh3?F;Zn1d7ntD7?^^Ik4+1}g&8CkJdM$bk$`xa|FY1_D_R-@&5PyCVB*h4 zFmo}{JK=RUG*43w+}GXa(f-L^vqc*8r&h0SRvFmxQ^ThJka{P$2a9v8D7#ea)|hN7 z7RACCE2_U$woKH3_5UYmnw(i#|p59_bH7v_8xUnm?ih)tFqMUxMS1K`^tu}%NXNr~8@7Js4`_Nq; zcN;6KV%oI^^Y4mXG}26O)U0}q*<{tS(p+Ef+D3&(WYx0LEZ;5Ly&403RxK;d^Zl;V z*y84{T2`9r`<89ok6vl6Z}1h%#l0@!4sICENA^B_OryDbgcG>2?1_68dEZERsipaz zqzu1NWNHvuh=LJ$vOjsj&6L?d4LuWXv*HJ|3`_z&3nyfOt+h-<0X+}hXCcwFEVL*Y zQ=aGoJ6QxNE0T{W9?PR;vwehrkcXx)zoJEG7^xwb^s?`BINu!oR8Wc0s7UNK?fuXP zC0aNhJw85G4<0k#fdsM&K443Jb0FelPK zXOhM9C+d-s9R+{obPP{5Z6-VOH6^|tGL8x@r-oUO+@6i#kYz3})LtKv;|GSk#_mv2jL zRq7_a%e&;-x20~ZDZ+~oIW!tM9{Gmkm!@d88~Iaw&?yW>r_68p)}or>)hyu@jKC-oK7>ipRnaYb z)oAB?s6uLWPnz>#jPnNYB`a+9c^1G%dbc@M`^;7BSr0C{Y0ULV&;WxwH z1)FiU*u?uJsNo;lbXYzUgx+3#Hr-StP^D9$MZF6FS3zaVk9&21R|4poQjxPm)TL{L zU`{E((x{lF5=FN}xNFmF8t-9 z%d~*Qhol6Os=mdReI_Z56e+7;MaueiWicsBTZ>3p)hMZEN}VY)CQpr)iKhvICY};! zASpg4hEyeH3N`|YUu0+;%;0M`7B~K>It?qzgfr( z>5me0(;rbTzK@wQAqPr(r8xC{FIXVpq#)%N3t2VF9N3VCaPy?n8C^(;yR;RONw#%tB41dTTDEJmL+E<#f@b{qn@02r5P|k%_5p$ zusItBU`7%)pRM^>f{fuz9_3p&)A*hZVo{tag~N*af%Q zlL{y|E-Grplp>&kQ_4v%D6);OCN=4c%px1j#}U!^zzqZOr|FJsIuEwZLhh?%?0SVZCqW81X}%80U_tpXLBMp*g)~ggC#o5dTvJ0>aT7h|Lwxp?Lgp1= zal$}@4jWn^*0rq!Va-h9=!9)7)`>OxVD2KZR(tbr^<|3uGVi(kH03s}V&KaolNVp% z#Wp>G!NTQFAbutcp@dDox#M>iy{A~6^GLGHKPk!dk|pdC6b-g>qAvMx4?1?yv$j9A zkg=vR!X^%zMwj?jE>1&0EWK)mDlZD>#5ro$7BIkw-c<4-Ubl|01mhDV)&)WY@zvfD z*04Y_istDdAPP*I^9%nb0PL~P2)Ai$6^P(&ioVB}i7DQ5MrTy>CMT>(>Kz1snB_Vp7nOHV^e6sMJii?RSU$nW?J+wZ<8U*#_?nJa-HkR) zc_l8b3_3qyNbhube}K2A`+NzJF+_Dtjt)) zfVV8Eq4C`?Y8Gq&ZXF{p=`gl+F{0=OF@PyF>d0$(Y?8X6SWCAjtv&fY@91;h(tv^` z5p+Nzc!BBj-MV{KuBL4`QuSRmb~(a?fdMfg!;#I^?9EU-Wphms)*SI#^g1Wdg_aio zqB=gOepP?|a$ z*s6DG)nbMHy@_U%@`Jvg|1F+03`<(;aLeEI%h(C?T= zvDA=wkQu?3H;-@{6!X&xLm799&|TI(IXXg?eH4|!chtR|3AUx^BHq1m`3MZ#t)1PN zG^*6Y;X!?Xk!+P_FM6hPdbObj&7;md>+c2DndDse)wmf*&h6CeMG1%uM>7O5+<7G9 zh>*DQ2-O)BNO;NHH<32UKD3pf7M)08D0hl}8tR2>+s=qilkxaOQAic8pxN%KN%T!e z)+D-DKczfQr7t2tewost2GpQRp$lV#@$9S?V8C4(Hr1_b7zDYTAc8X z1ulg<=rm2K$pZN`S8uJ3I{?*EEmoBi2LOF^;n)DUP&?E?0%7nUiIeRh{*YpH6qU82 z1;u5@XdQ1qu?1~at=|4{e}USRYW%4Ruoi8o0D2=zu*NX*KIZlR+P|;S@1M%gza4(` z3GB;X{KsF-&CB$M57Opky;7}IcCoQf78_fZ0m8+KHVlC)?~i3#;-r)McDcTR*zj6u zD>p4Caa tuple[str, str]: + """ + Authenticate user with access token and return database_id and user_id. + """ + from database.managers.user_manager import UserManager + + try: + # Validate header format + missing_headers = [] + if not x_database_id: + missing_headers.append("x-database-id") + if not x_access_token: + missing_headers.append("x-access-token") + + if missing_headers: + detail = f"Missing required headers: {', '.join(missing_headers)}" + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + + # Get user manager and authenticate with token + user_manager = UserManager(x_database_id) + user_info = user_manager.get_user_by_access_token(x_access_token) + + if not user_info: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid access token" + ) + + if not user_info.get("is_active", False): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive" + ) + + return x_database_id, user_info["id"] + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error authenticating user with token: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authentication error" + ) + + +def get_user_context( + x_database_id: str = Header(alias="x-database-id"), + x_user_id: str = Header(alias="x-user-id") +) -> tuple[str, str]: + """ + Extract database_id and user_id from headers for multi-user support. + """ + try: + # Validate header format if needed + missing_headers = [] + if not x_database_id: + missing_headers.append("x-database-id") + if not x_user_id: + missing_headers.append("x-user-id") + + if missing_headers: + detail = f"Missing required headers: {', '.join(missing_headers)}" + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + + return x_database_id, x_user_id + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error extracting user context: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="User context error" + ) \ No newline at end of file diff --git a/envs/calendar_env/server/openenv_wrapper/__init__.py b/envs/calendar_env/server/openenv_wrapper/__init__.py new file mode 100644 index 000000000..aa4b2296e --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/__init__.py @@ -0,0 +1,42 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +OpenEnv Wrapper - Generic MCP Integration Package + +This package provides a fully generic OpenEnv integration that can be +copied to any MCP project. See README.md for usage instructions. +""" + +from .config import MCP_NAME +from .data_models import MCPAction, MCPObservation, ListToolsAction, ToolCallAction +from .mcp_env_environment import MCPEnvironment +from .custom_http_server import MCPHTTPEnvServer +from .client import MCPEnvClient + +__all__ = [ + # Configuration + "MCP_NAME", + + # Data Models + "MCPAction", + "MCPObservation", + "ListToolsAction", + "ToolCallAction", + + # Environment + "MCPEnvironment", + + # HTTP Server + "MCPHTTPEnvServer", + + # Client + "MCPEnvClient", +] + +__version__ = "1.0.0" +__author__ = "OpenEnv MCP Integration" +__description__ = "Generic OpenEnv wrapper for any MCP integration" diff --git a/envs/calendar_env/server/openenv_wrapper/client.py b/envs/calendar_env/server/openenv_wrapper/client.py new file mode 100644 index 000000000..65dae46d9 --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/client.py @@ -0,0 +1,128 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Generic MCP Environment Client with WebSocket Support. + +This module provides a generic client for connecting to any MCP Environment +server over WebSocket (for persistent sessions) or HTTP. It's fully reusable +across different MCP integrations. +""" + +from typing import Any, Dict + +from openenv.core.client_types import StepResult +from openenv.core.env_server.types import State +from openenv.core.env_client import EnvClient + +from .data_models import MCPAction, MCPObservation + + +class MCPEnvClient(EnvClient[MCPAction, MCPObservation, State]): + """ + Generic WebSocket client for MCP Environment. + + This client connects to any MCPEnvironment server via WebSocket and provides + methods to interact with it: reset(), step(), and state access. + Each client maintains a persistent WebSocket connection for better performance. + + Example: + >>> # Connect to a running server using context manager (recommended) + >>> with MCPEnvClient(base_url="http://localhost:8004") as client: + ... result = client.reset() + ... print(result.observation.metadata) + ... + ... # List tools + ... result = client.step(MCPAction(action_type="ListToolsAction")) + ... print(result.observation.tools_list) + ... + ... # Call a tool + ... result = client.step(MCPAction( + ... action_type="ToolCallAction", + ... tool_name="create_resource", + ... arguments={"name": "Test"} + ... )) + ... print(result.observation.tool_result) + ... print(result.reward) + + Example with manual cleanup: + >>> try: + ... client = MCPEnvClient(base_url="http://localhost:8004") + ... result = client.reset() + ... result = client.step(MCPAction(action_type="ListToolsAction")) + ... finally: + ... client.close() + + Example with Docker: + >>> # Automatically start container and connect + >>> client = MCPEnvClient.from_docker_image("calendar-env:latest") + >>> result = client.reset() + >>> result = client.step(MCPAction(action_type="ListToolsAction")) + """ + + def _step_payload(self, action: MCPAction) -> Dict: + """ + Convert MCPAction to JSON payload for step request. + + Args: + action: MCPAction instance + + Returns: + Dictionary representation suitable for JSON encoding + """ + payload = { + "action_type": action.action_type, + } + + if action.tool_name is not None: + payload["tool_name"] = action.tool_name + + if action.arguments is not None: + payload["arguments"] = action.arguments + + return payload + + def _parse_result(self, payload: Dict) -> StepResult[MCPObservation]: + """ + Parse server response into StepResult[MCPObservation]. + + Args: + payload: JSON response from server + + Returns: + StepResult with MCPObservation + """ + obs_data = payload.get("observation", {}) + observation = MCPObservation( + success=obs_data.get("success", True), + error_message=obs_data.get("error_message"), + tools_list=obs_data.get("tools_list"), + tool_result=obs_data.get("tool_result"), + done=payload.get("done", False), + reward=payload.get("reward"), + metadata=obs_data.get("metadata", {}), + ) + + return StepResult( + observation=observation, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def _parse_state(self, payload: Dict) -> State: + """ + Parse server response into State object. + + Args: + payload: JSON response from /state endpoint + + Returns: + State object with episode_id and step_count + """ + return State( + episode_id=payload.get("episode_id"), + step_count=payload.get("step_count", 0), + ) \ No newline at end of file diff --git a/envs/calendar_env/server/openenv_wrapper/config.py b/envs/calendar_env/server/openenv_wrapper/config.py new file mode 100644 index 000000000..bf3646bf4 --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/config.py @@ -0,0 +1,174 @@ +""" +OpenEnv Wrapper Configuration for MCP Integration + +This file contains MCP-specific configuration that needs to be customized +when copying openenv_wrapper to a different MCP project. + +INSTRUCTIONS FOR NEW MCP: +1. Copy the entire openenv_wrapper folder to your MCP project +2. Update this config.py file with your MCP-specific values: + - MCP_NAME: Your MCP name (e.g., "Slack", "GitHub", "Jira") + - SESSION_MANAGER_CLASS: Import path to your SessionManager + - MCP_TOOLS_MODULE: Import path to your MCP tools + - SEED_DATA_FUNCTION: Import path to your seed data function +3. Ensure your database session manager follows the expected interface +4. That's it! The rest of the openenv_wrapper files are fully generic +""" + +# ============================================================================ +# MCP-SPECIFIC CONFIGURATION - CUSTOMIZE THIS SECTION FOR YOUR MCP +# ============================================================================ + +# Name of your MCP service (used in logs and class names) +MCP_NAME = "Calendar" + +# Import path for your SessionManager class +# Your SessionManager should have these methods: +# - get_db_path(db_id: str) -> str +# - init_database(db_id: str, create_tables: bool = False) +# - dispose_engine(db_id: str) +SESSION_MANAGER_MODULE = "database.session_manager" +SESSION_MANAGER_CLASS = "CalendarSessionManager" + +# Import path for your MCP tools list +# Should export: MCP_TOOLS (list of tool schemas) +MCP_TOOLS_MODULE = "calendar_mcp.tools" +MCP_TOOLS_EXPORT = "MCP_TOOLS" + +# Import path for your tool handlers +# Should export: MCP_TOOLS_LIST and TOOL_HANDLERS +TOOL_HANDLERS_MODULE = "handlers.tool_handlers" + +# Import path for your seed data function +# Function should return SQL content as a string +SEED_DATA_MODULE = "data.multi_user_sample" +SEED_DATA_FUNCTION = "get_multi_user_sql" + +# Import path for your UserManager (for access token validation) +# Your UserManager should have these methods: +# - get_first_user_token() -> Optional[str] +# - get_user_by_static_token(static_token: str) -> Optional[Dict] +USER_MANAGER_MODULE = "database.managers.user_manager" +USER_MANAGER_CLASS = "UserManager" + +# Database directory name (relative to project root) +DATABASE_DIR = "mcp_databases" + +# Database file prefix (e.g., "Calendar_" results in "Calendar_db_id.sqlite") +DATABASE_PREFIX = f"{MCP_NAME}_" + +# HTTP Headers configuration +# These headers are used in the OpenEnv HTTP endpoints (/reset, /step, /state) +# Customize these based on your MCP's authentication and database selection needs +# +# EXAMPLES FOR DIFFERENT MCPs: +# +# For Calendar MCP (current): +# HTTP_HEADERS = { +# "database_id": "x-database-id", +# "access_token": "x-access-token", +# } +# +# For Teams MCP: +# HTTP_HEADERS = { +# "database_id": "x-database-id", +# "access_token": "x-teams-access-token", +# } +# +# For Slack MCP: +# HTTP_HEADERS = { +# "database_id": "x-database-id", +# "access_token": "x-slack-token", +# } +# +HTTP_HEADERS = { + # Header name for database/session ID + # This header is used to specify which database instance to use (multi-tenancy) + "database_id": "x-database-id", + + # Header name for access token/authentication + # This header is used to authenticate the user making the request + # Calendar MCP uses "x-access-token" for authentication + "access_token": "x-access-token", +} + +# Default values for headers (used when header is not provided) +HTTP_HEADER_DEFAULTS = { + "database_id": "default", + "access_token": None, +} + +# ============================================================================ +# HELPER FUNCTIONS - DO NOT MODIFY UNLESS NECESSARY +# ============================================================================ + +def get_session_manager_class(): + """Dynamically import and return the SessionManager class""" + import importlib + module = importlib.import_module(SESSION_MANAGER_MODULE) + return getattr(module, SESSION_MANAGER_CLASS) + + +def get_mcp_tools(): + """Dynamically import and return the MCP tools list""" + import importlib + module = importlib.import_module(MCP_TOOLS_MODULE) + return getattr(module, MCP_TOOLS_EXPORT) + + +def get_tool_handlers(): + """Dynamically import and return tool handlers""" + import importlib + module = importlib.import_module(TOOL_HANDLERS_MODULE) + return { + 'MCP_TOOLS_LIST': getattr(module, 'MCP_TOOLS_LIST'), + 'TOOL_HANDLERS': getattr(module, 'TOOL_HANDLERS') + } + + +def get_seed_data_function(): + """Dynamically import and return the seed data function""" + import importlib + module = importlib.import_module(SEED_DATA_MODULE) + return getattr(module, SEED_DATA_FUNCTION) + + +def get_user_manager_class(): + """ + Dynamically import and return the UserManager class with compatibility wrappers. + + This function adds method aliases to handle different method names across MCPs. + Calendar MCP uses different method names than Teams MCP: + - get_first_user_from_db() → get_first_user_token() + - get_user_by_access_token() → get_user_by_static_token() + """ + import importlib + module = importlib.import_module(USER_MANAGER_MODULE) + user_mgr_cls = getattr(module, USER_MANAGER_CLASS) + + # Add compatibility wrapper for get_first_user_token + # Calendar uses: get_first_user_from_db() + # Generic code expects: get_first_user_token(db_id) + if hasattr(user_mgr_cls, 'get_first_user_from_db') and not hasattr(user_mgr_cls, 'get_first_user_token'): + def get_first_user_token_wrapper(self, db_id: str): + """Wrapper to get first user's token - Calendar compatibility""" + user_dict = self.get_first_user_from_db() + if user_dict: + # Return the static_token from the user dict + return user_dict.get('static_token') + return None + + user_mgr_cls.get_first_user_token = get_first_user_token_wrapper + + # Add compatibility wrapper for get_user_by_static_token + # Calendar uses: get_user_by_access_token(static_token) + # Generic code expects: get_user_by_static_token(token, db_id=None) + # Note: Calendar's method doesn't need db_id since UserManager is already initialized with it + if hasattr(user_mgr_cls, 'get_user_by_access_token') and not hasattr(user_mgr_cls, 'get_user_by_static_token'): + def get_user_by_static_token_wrapper(self, token: str, db_id: str = None): + """Wrapper to get user by static token - Calendar compatibility""" + return self.get_user_by_access_token(token) + + user_mgr_cls.get_user_by_static_token = get_user_by_static_token_wrapper + + return user_mgr_cls diff --git a/envs/calendar_env/server/openenv_wrapper/custom_http_server.py b/envs/calendar_env/server/openenv_wrapper/custom_http_server.py new file mode 100644 index 000000000..db2c16627 --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/custom_http_server.py @@ -0,0 +1,348 @@ +""" +Generic HTTP Environment Server for MCP Integration. + +This module provides a custom HTTP server that wraps any MCP environment +and exposes OpenEnv-compliant HTTP endpoints (/reset, /step, /state). + +The server is fully generic and works with any MCP integration. +All MCP-specific configuration is loaded from config.py. +""" + +import asyncio +import logging +import sqlite3 +from typing import Any, Dict, Optional, List + +from fastapi import Body, FastAPI, Request, Query +from openenv.core.env_server.http_server import HTTPEnvServer + +from .config import ( + MCP_NAME, + HTTP_HEADERS, + HTTP_HEADER_DEFAULTS, + get_session_manager_class, + get_seed_data_function +) + +logger = logging.getLogger(__name__) + + +class MCPHTTPEnvServer(HTTPEnvServer): + """ + Generic HTTP Environment Server for any MCP integration. + + This server wraps any MCP environment and provides HTTP endpoints + for OpenEnv integration. It's fully generic and works with any MCP. + + HTTP headers are configured via config.py:HTTP_HEADERS, making it easy + to adapt to different MCP authentication and multi-tenancy patterns. + """ + + def __init__(self, env, action_cls, observation_cls): + """Initialize custom HTTP server with MCP session manager.""" + # Store classes before calling super().__init__() + self.action_cls = action_cls + self.observation_cls = observation_cls + + # Call parent init + super().__init__(env=env, action_cls=action_cls, observation_cls=observation_cls) + + # Create a persistent environment instance for HTTP endpoints + # The parent class stores env_factory in self._env_factory + # We create one instance for the HTTP endpoints (not WebSocket) + if callable(self._env_factory): + self.env = self._env_factory() + else: + self.env = self._env_factory + + # Dynamically load the session manager from config + SessionManagerClass = get_session_manager_class() + self.session_manager = SessionManagerClass() + + def _get_header_value(self, headers: dict, header_key: str) -> Optional[str]: + """ + Get header value using configured header name. + + Args: + headers: Request headers dictionary + header_key: Key in HTTP_HEADERS config (e.g., "database_id", "access_token") + + Returns: + Header value or default from config + """ + header_name = HTTP_HEADERS.get(header_key) + if not header_name: + logger.warning(f"Header key '{header_key}' not configured in HTTP_HEADERS") + return HTTP_HEADER_DEFAULTS.get(header_key) + + value = headers.get(header_name) + if value is None: + value = HTTP_HEADER_DEFAULTS.get(header_key) + + return value + + def register_routes(self, app: Any) -> None: + if not isinstance(app, FastAPI): + raise TypeError("app must be a FastAPI instance") + + # Register custom reset endpoint + @app.post("/reset") + async def reset_with_database_refresh( + request: Request, + body: Optional[Dict[str, Any]] = Body(default=None) + ) -> Dict[str, Any]: + """ + Reset the environment and optionally reset the database. + + The database_id can be provided via: + 1. Request body: {"database_id": "my_db", "sql_content": "INSERT INTO..."} + 2. HTTP header: x-database-id + 3. Default value if neither provided + + Args (in request body, all optional): + database_id: Database identifier for multi-tenancy + sql_content: Custom SQL content to use for seeding instead of default + + Returns: + Observation with reset status and database reset result + """ + headers = dict(request.headers) + + # Get database_id from body first, then header, then default + body = body or {} + database_id = body.get("database_id") or self._get_header_value(headers, "database_id") + access_token = self._get_header_value(headers, "access_token") + + # Get optional sql_content from body + sql_content = body.get("sql_content") + + logger.info(f"Reset request for database_id={database_id}, custom_sql={'yes' if sql_content else 'no'}") + + # Reset database to original state (with optional custom SQL) + db_reset_result = self._reset_database(database_id, sql_content=sql_content) + + # Set request context in environment + self.env.set_request_context( + database_id=database_id, + access_token=access_token + ) + + # Execute reset in thread pool (environments may use sync code) + loop = asyncio.get_event_loop() + observation = await loop.run_in_executor(None, self.env.reset) + + # Serialize observation manually + result = { + "observation": observation.model_dump() if hasattr(observation, 'model_dump') else observation.__dict__, + "done": getattr(observation, 'done', False), + "reward": getattr(observation, 'reward', 0.0) + } + + # Add database reset info to observation metadata + if isinstance(result, dict) and "observation" in result: + if "metadata" not in result["observation"]: + result["observation"]["metadata"] = {} + result["observation"]["metadata"]["database_reset_result"] = db_reset_result + + logger.info(f"Environment reset completed for database {database_id}, DB refresh: {db_reset_result['success']}") + return result + + # Register custom step endpoint + @app.post("/step") + async def step_with_headers(request: Request, body: Dict[str, Any] = Body(...)) -> Dict[str, Any]: + # Extract headers using dynamic header names from config + headers = dict(request.headers) + database_id = self._get_header_value(headers, "database_id") + access_token = self._get_header_value(headers, "access_token") + + # Debug logging to see what headers we're receiving + logger.info(f"Step request - database_id: {database_id}, has_access_token: {bool(access_token)}") + if not access_token: + logger.warning(f"No access token found in headers. Available headers: {list(headers.keys())}") + + # Set request context in environment + self.env.set_request_context(database_id=database_id, access_token=access_token) + + logger.debug(f"Step request with database_id={database_id}, has_token={bool(access_token)}") + + # Support both {"action": {...}} and direct action fields + action_data = body.get("action", body) + + # Deserialize action manually using Pydantic + try: + action = self.action_cls(**action_data) + except Exception as e: + logger.error(f"Failed to deserialize action: {e}") + return { + "observation": { + "success": False, + "error_message": f"Invalid action: {str(e)}", + "done": False, + "reward": -1.0, + "metadata": {} + }, + "done": False, + "reward": -1.0 + } + + # Execute step in thread pool (environments may use sync code) + loop = asyncio.get_event_loop() + observation = await loop.run_in_executor(None, self.env.step, action) + + # Serialize observation manually + result = { + "observation": observation.model_dump() if hasattr(observation, 'model_dump') else observation.__dict__, + "done": getattr(observation, 'done', False), + "reward": getattr(observation, 'reward', 0.0) + } + + return result + + # Register state endpoint + @app.get("/state") + async def get_state( + request: Request, + verify_queries: List[str] = Query(default=[]) + ) -> Dict[str, Any]: + headers = dict(request.headers) + database_id = headers.get("x-database-id", "default") + + state = self.env.state + result = { + "episode_id": state.episode_id, + "step_count": state.step_count, + "database_id": database_id + } + + db_path = self.session_manager.get_db_path(database_id) + + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + if verify_queries: + result["verification_results"] = [] + + for query in verify_queries: + try: + cursor.execute(query) + rows = cursor.fetchall() + result["verification_results"].append({ + "query": query, + "result": [dict(row) for row in rows], + "success": True + }) + except Exception as query_error: + result["verification_results"].append({ + "query": query, + "error": str(query_error), + "success": False + }) + + conn.close() + except Exception as e: + result["db_error"] = str(e) + + return result + + def _reset_database(self, database_id: str, sql_content: Optional[str] = None) -> Dict[str, Any]: + """ + Reset database to clean state with seed data. + + This method is generic and works with any MCP that follows + the standard session manager interface. + + Args: + database_id: Database identifier for multi-tenancy + sql_content: Optional custom SQL content for seeding. If provided, + this will be used instead of the default seed data. + + Returns: + Dictionary with reset status and details + """ + try: + # Dispose any cached engine connections to prevent stale connections + # (Only if the SessionManager has this method - some MCPs may not have it) + if hasattr(self.session_manager, 'dispose_engine'): + self.session_manager.dispose_engine(database_id) + + # Get database path using session manager + db_path = self.session_manager.get_db_path(database_id) + + # Drop all existing tables using sqlite3 + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + tables = cursor.fetchall() + + for table in tables: + table_name = table[0] + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + + conn.commit() + conn.close() + + # Recreate tables using session manager + self.session_manager.init_database(database_id, create_tables=True) + + # Use custom SQL content if provided, otherwise use default seed data + if sql_content: + logger.info(f"Using custom SQL content for database {database_id}") + seed_sql = sql_content + used_custom_sql = True + else: + seed_data_fn = get_seed_data_function() + seed_sql = seed_data_fn() + used_custom_sql = False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Parse and execute SQL statements + statements = [] + for line in seed_sql.split("\n"): + line = line.strip() + if line and not line.startswith("--"): + statements.append(line) + + full_sql = " ".join(statements) + individual_statements = [stmt.strip() for stmt in full_sql.split(";") if stmt.strip()] + + executed_count = 0 + for statement in individual_statements: + try: + if not statement.strip(): + continue + cursor.execute(statement) + executed_count += 1 + except Exception as e: + logger.error(f"Error executing statement during seeding: {statement[:100]}...") + logger.error(f"Error details: {e}") + raise e + + conn.commit() + conn.close() + + seed_source = "custom SQL" if used_custom_sql else "default seed data" + logger.info(f"Database {database_id} reset and seeded with {seed_source} ({executed_count} statements)") + + return { + "success": True, + "message": f"Database reset to clean state and seeded with {seed_source}", + "database_id": database_id, + "seeded": True, + "used_custom_sql": used_custom_sql, + "statements_executed": executed_count, + } + + except Exception as e: + logger.error(f"Error resetting database {database_id}: {e}", exc_info=True) + return { + "success": False, + "message": f"Database reset failed: {str(e)}", + "database_id": database_id, + "seeded": False, + "used_custom_sql": sql_content is not None, + } diff --git a/envs/calendar_env/server/openenv_wrapper/data_models.py b/envs/calendar_env/server/openenv_wrapper/data_models.py new file mode 100644 index 000000000..4b79e4278 --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/data_models.py @@ -0,0 +1,112 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Generic Data Models for MCP Environment with Tool Support. + +Following RFC 003 - Traditional Tool Calling Approach: +- MCPAction: Wrapper action that dispatches to ListToolsAction or ToolCallAction +- ListToolsAction: Discover available MCP tools +- ToolCallAction: Execute a specific MCP tool + +These models are fully generic and work with any MCP integration. +""" + +from typing import Any, Dict, List, Optional, Literal +from pydantic import Field + +from openenv.core.env_server.types import Action, Observation + + +class MCPAction(Action): + """ + Generic wrapper action for MCP environment that supports multiple action types. + + This action uses an action_type discriminator to determine which action to execute: + - "ListToolsAction": Discover available MCP tools + - "ToolCallAction": Execute a specific MCP tool + + Args: + action_type: Type of action ("ListToolsAction" or "ToolCallAction") + tool_name: Name of tool to call (required for ToolCallAction) + arguments: Arguments for tool (optional, for ToolCallAction) + + Examples: + >>> # List tools + >>> action = MCPAction(action_type="ListToolsAction") + >>> + >>> # Call a tool + >>> action = MCPAction( + ... action_type="ToolCallAction", + ... tool_name="create_resource", + ... arguments={"name": "New Resource", "type": "example"} + ... ) + """ + action_type: Literal["ListToolsAction", "ToolCallAction"] = Field( + ..., description="Type of action to perform" + ) + tool_name: Optional[str] = Field( + None, description="Name of tool to call (required for ToolCallAction)" + ) + arguments: Optional[Dict[str, Any]] = Field( + default_factory=dict, description="Arguments for the tool" + ) + + +# Internal action types (not exposed to HTTP API) +class ListToolsAction(Action): + """ + Internal: Request list of available tools from MCP server. + + This action corresponds to the MCP tools/list API. + Use MCPAction with action_type="ListToolsAction" instead. + """ + pass # No parameters needed + + +class ToolCallAction(Action): + """ + Internal: Call a specific MCP tool with arguments. + + This action corresponds to the MCP tools/call API. + Use MCPAction with action_type="ToolCallAction" instead. + """ + tool_name: str = Field(..., description="Name of the tool to call") + arguments: Dict[str, Any] = Field( + default_factory=dict, description="Arguments to pass to the tool" + ) + + +class MCPObservation(Observation): + """ + Generic observation from the MCP Environment. + + Depending on the action type, different fields will be populated: + - For ListToolsAction: tools_list contains available tool schemas + - For ToolCallAction: tool_result contains the execution result + + Args: + success: Whether the action succeeded + error_message: Error message if action failed + tools_list: List of available tools (for ListToolsAction) + tool_result: Result from tool execution (for ToolCallAction) + metadata: Additional metadata about the execution + done: Whether the episode is complete + reward: Reward for the action + """ + success: bool = Field(True, description="Whether the action succeeded") + error_message: Optional[str] = Field(None, description="Error message if action failed") + tools_list: Optional[List[Dict[str, Any]]] = Field( + None, description="List of available tools (for ListToolsAction)" + ) + tool_result: Optional[Dict[str, Any]] = Field( + None, description="Result from tool execution (for ToolCallAction)" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata about the execution" + ) + done: bool = Field(False, description="Whether the episode is complete") + reward: Optional[float] = Field(None, description="Reward for the action") \ No newline at end of file diff --git a/envs/calendar_env/server/openenv_wrapper/mcp_env_environment.py b/envs/calendar_env/server/openenv_wrapper/mcp_env_environment.py new file mode 100644 index 000000000..b99813b2b --- /dev/null +++ b/envs/calendar_env/server/openenv_wrapper/mcp_env_environment.py @@ -0,0 +1,441 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Generic MCP Environment Implementation with MCP Tool Support. + +This environment integrates any MCP server following RFC 003's +traditional tool calling approach. Agents can: +1. Discover available tools using ListToolsAction +2. Execute tools using ToolCallAction + +The environment wraps any MCP server and provides reward signals +based on successful tool execution. + +This file is fully generic and doesn't require modification when +copying to different MCP projects. All MCP-specific configuration +is in config.py. +""" + +import asyncio +import logging +from typing import Union +from uuid import uuid4 + +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.types import State + +from .data_models import MCPAction, ListToolsAction, ToolCallAction, MCPObservation +from .config import ( + MCP_NAME, + get_tool_handlers, + get_user_manager_class +) + +logger = logging.getLogger(__name__) + +# Load tool handlers dynamically from config +_tool_handlers = get_tool_handlers() +MCP_TOOLS_LIST = _tool_handlers['MCP_TOOLS_LIST'] +TOOL_HANDLERS = _tool_handlers['TOOL_HANDLERS'] + + +class MCPEnvironment(Environment): + """ + Generic MCP Environment with Tool Integration. + + This environment provides access to any MCP operations through + MCP tools. It supports two action types: + + 1. ListToolsAction - Discover available MCP tools + 2. ToolCallAction - Execute a specific MCP tool + + Example: + >>> env = MCPEnvironment(database_id="test") + >>> + >>> # Discover tools + >>> obs = env.reset() + >>> list_action = ListToolsAction() + >>> obs = env.step(list_action) + >>> print(len(obs.tools_list)) # Number of available tools + >>> + >>> # Call a tool + >>> call_action = ToolCallAction( + ... tool_name="create_resource", + ... arguments={"name": "Example", "type": "test"} + ... ) + >>> obs = env.step(call_action) + >>> print(obs.success) # True if tool executed successfully + >>> print(obs.tool_result) # Result from the tool + """ + + def __init__(self, database_id: str = "default", auth_token: str = None): + """ + Initialize the MCP environment. + + Args: + database_id: Default database identifier for multi-tenancy support + auth_token: Optional default authentication token (for future use) + """ + self._state = State(episode_id=str(uuid4()), step_count=0) + self._default_database_id = database_id + self._default_auth_token = auth_token + self._reset_count = 0 + self._successful_tool_calls = 0 + self._failed_tool_calls = 0 + + # Current request context (set from HTTP headers) + self._current_database_id = database_id + self._current_access_token = auth_token + + logger.info(f"{MCP_NAME} environment initialized with database_id: {database_id}") + + def set_request_context(self, database_id: str = None, access_token: str = None): + """ + Set the current request context from HTTP headers. + + This method should be called before step() to provide database_id and access_token + from the incoming HTTP request headers. + + Args: + database_id: Database ID from x-database-id header + access_token: Access token from x-{mcp}-access-token header + """ + self._current_database_id = database_id or self._default_database_id + self._current_access_token = access_token or self._default_auth_token + + logger.debug(f"Request context set: database_id={self._current_database_id}") + + def reset(self) -> MCPObservation: + """ + Reset the environment to initial state. + + Returns: + MCPObservation with environment information and available tool count + """ + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count += 1 + self._successful_tool_calls = 0 + self._failed_tool_calls = 0 + + logger.info(f"Environment reset (episode {self._reset_count})") + + return MCPObservation( + success=True, + done=False, + reward=0.0, + metadata={ + "episode_id": self._state.episode_id, + "available_tools": len(MCP_TOOLS_LIST), + "database_id": self._current_database_id, + "message": f"{MCP_NAME} environment ready. Use ListToolsAction to discover tools.", + }, + ) + + def step(self, action: MCPAction) -> MCPObservation: # type: ignore[override] + """ + Execute an action in the environment. + + Supports two action types via action_type discriminator: + 1. "ListToolsAction" - Returns list of available MCP tools + 2. "ToolCallAction" - Executes a specific MCP tool + + Args: + action: MCPAction with action_type discriminator + + Returns: + MCPObservation with results and reward + """ + self._state.step_count += 1 + + try: + # Dispatch based on action_type + if action.action_type == "ListToolsAction": + internal_action = ListToolsAction() + return self._handle_list_tools(internal_action) + elif action.action_type == "ToolCallAction": + if not action.tool_name: + return MCPObservation( + success=False, + error_message="tool_name is required for ToolCallAction", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count}, + ) + internal_action = ToolCallAction(tool_name=action.tool_name, arguments=action.arguments or {}) + return self._handle_tool_call(internal_action) + else: + logger.error(f"Unknown action_type: {action.action_type}") + return MCPObservation( + success=False, + error_message=f"Unknown action_type: {action.action_type}. Must be 'ListToolsAction' or 'ToolCallAction'", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count}, + ) + + except Exception as e: + logger.error(f"Error executing action: {e}", exc_info=True) + return MCPObservation( + success=False, + error_message=f"Internal error: {str(e)}", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "error_type": type(e).__name__}, + ) + + def _handle_list_tools(self, action: ListToolsAction) -> MCPObservation: + """ + Handle ListToolsAction by returning available MCP tools. + + Args: + action: ListToolsAction instance + + Returns: + MCPObservation with tools_list populated + """ + logger.info("Listing available MCP tools") + + # MCP_TOOLS_LIST already contains dictionaries, no need to convert + tools_list = MCP_TOOLS_LIST + + return MCPObservation( + success=True, + tools_list=tools_list, + done=False, + reward=0.1, # Small positive reward for successful discovery + metadata={ + "step": self._state.step_count, + "action_type": "list_tools", + "tools_count": len(tools_list), + }, + ) + + def _handle_tool_call(self, action: ToolCallAction) -> MCPObservation: + """ + Handle ToolCallAction by executing the specified MCP tool. + + Args: + action: ToolCallAction with tool_name and arguments + + Returns: + MCPObservation with tool_result populated + """ + tool_name = action.tool_name + arguments = action.arguments or {} + + logger.info(f"Executing tool: {tool_name} with arguments: {arguments}") + + # Check if tool exists + if tool_name not in TOOL_HANDLERS: + logger.warning(f"Tool not found: {tool_name}") + self._failed_tool_calls += 1 + return MCPObservation( + success=False, + error_message=f"Tool not found: {tool_name}", + done=False, + reward=-0.5, + metadata={ + "step": self._state.step_count, + "action_type": "tool_call", + "tool_name": tool_name, + "available_tools": len(TOOL_HANDLERS), + }, + ) + + # Execute the tool + try: + # Get the tool handler + handler = TOOL_HANDLERS[tool_name] + # Get database_id and access_token from current request context + database_id = self._current_database_id + access_token = self._current_access_token + + # Handle access token validation (same logic as openenv_routes) + UserManager = get_user_manager_class() + user_manager = UserManager(database_id) + + # If no access token provided, get first user's token + if not access_token or (isinstance(access_token, str) and access_token.strip() == ""): + fallback_token = user_manager.get_first_user_token(db_id=database_id) + if not fallback_token: + return MCPObservation( + success=False, + error_message="Access token is required and no users are available for fallback", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "tool_name": tool_name}, + ) + access_token = fallback_token + + # Clean access token (remove invisible characters) + if access_token: + invisible_chars = ["\u200b", "\u200c", "\u200d", "\ufeff", "\u2060"] + for char in invisible_chars: + access_token = access_token.replace(char, "") + access_token = access_token.strip() + + # Get user_id from access token (same logic as openenv_routes) + try: + user = user_manager.get_user_by_static_token(access_token) + except Exception as e: + logger.error(f"Error validating access token: {e}") + return MCPObservation( + success=False, + error_message=f"Failed to validate access token: {str(e)}", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "tool_name": tool_name}, + ) + + if not user: + return MCPObservation( + success=False, + error_message="Invalid or expired access token. User not found.", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "tool_name": tool_name}, + ) + + try: + if isinstance(user, dict) and "id" in user: + user_id = user["id"] + else: + return MCPObservation( + success=False, + error_message=f"Invalid user object structure: expected dict with 'id' field, got {type(user)}", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "tool_name": tool_name}, + ) + except (KeyError, TypeError) as e: + return MCPObservation( + success=False, + error_message=f"Failed to extract user_id from user object: {e}", + done=False, + reward=-1.0, + metadata={"step": self._state.step_count, "tool_name": tool_name}, + ) + + # Execute the tool asynchronously + # Check if handler accepts access_token parameter (some MCPs don't need it) + import inspect + handler_signature = inspect.signature(handler) + handler_params = handler_signature.parameters + + # Build kwargs based on what the handler accepts + handler_kwargs = { + "tool_name": tool_name, + "arguments": arguments, + "database_id": database_id, + "user_id": user_id, + } + + # Only add access_token if the handler accepts it + if "access_token" in handler_params: + handler_kwargs["access_token"] = access_token + + result = asyncio.run(handler(**handler_kwargs)) + + self._successful_tool_calls += 1 + + # Compute reward based on success + reward = self._compute_reward(tool_name, result) + + logger.info(f"Tool {tool_name} executed successfully") + + return MCPObservation( + success=True, + tool_result=result, + done=False, + reward=reward, + metadata={ + "step": self._state.step_count, + "action_type": "tool_call", + "tool_name": tool_name, + "arguments": arguments, + }, + ) + + except Exception as e: + logger.error(f"Tool execution failed: {tool_name} - {e}", exc_info=True) + self._failed_tool_calls += 1 + return MCPObservation( + success=False, + error_message=f"Tool execution failed: {str(e)}", + tool_result={"error": str(e)}, + done=False, + reward=-1.0, + metadata={ + "step": self._state.step_count, + "action_type": "tool_call", + "tool_name": tool_name, + "error_type": type(e).__name__, + }, + ) + + def _compute_reward(self, tool_name: str, result: dict) -> float: + """ + Compute reward based on tool execution result. + + Reward structure: + - Successful read operations: +0.5 + - Successful write operations: +1.0 + - Operations with errors: Based on HTTP status code + + Args: + tool_name: Name of the executed tool + result: Result dictionary from tool execution + + Returns: + Float reward value + """ + # Check for errors in result + if isinstance(result, dict): + if "error" in result: + return -0.5 + + # Check status code if present + status = result.get("status_code") or result.get("statusCode") + if status: + if status >= 400: + return -0.5 + elif status >= 200 and status < 300: + return 1.0 + + # Default positive reward for successful execution + return 0.5 + + @property + def state(self) -> State: + """ + Get the current environment state. + + Returns: + Current State with episode_id and step_count + """ + return self._state + + @property + def stats(self) -> dict: + """ + Get environment statistics. + + Returns: + Dictionary with execution statistics + """ + total_calls = self._successful_tool_calls + self._failed_tool_calls + success_rate = self._successful_tool_calls / total_calls if total_calls > 0 else 0.0 + + return { + "episode_id": self._state.episode_id, + "step_count": self._state.step_count, + "reset_count": self._reset_count, + "successful_tool_calls": self._successful_tool_calls, + "failed_tool_calls": self._failed_tool_calls, + "success_rate": success_rate, + "database_id": self._current_database_id, + } diff --git a/envs/calendar_env/server/schemas/__init__.py b/envs/calendar_env/server/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/schemas/acl.py b/envs/calendar_env/server/schemas/acl.py new file mode 100644 index 000000000..243f4519b --- /dev/null +++ b/envs/calendar_env/server/schemas/acl.py @@ -0,0 +1,180 @@ +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field, field_validator, Extra, model_validator +from enum import Enum +from urllib.parse import urlparse +import re + +class ScopeType(str, Enum): + default = "default" + user = "user" + group = "group" + domain = "domain" + +class AclRole(str, Enum): + none = "none" + freeBusyReader = "freeBusyReader" + reader = "reader" + writer = "writer" + owner = "owner" + +class ScopeInput(BaseModel): + type: ScopeType + value: Optional[str] = Field(None, description="The email address of a user or group, or the name of a domain, depending on the scope type. Omitted for type 'default'.") # Optional only for default + + @model_validator(mode='after') + def validate_scope_value(self) -> 'ScopeInput': + """ + Validate scope value based on type: + - default: value must be None/omitted + - user/group: value must be a valid email address + - domain: value must be a valid domain name + """ + if self.type == ScopeType.default: + if self.value is not None: + raise ValueError("scope.value must be omitted for type 'default'") + else: + value = self.value.strip() + + if self.type in (ScopeType.user, ScopeType.group): + # Validate email address + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, value): + raise ValueError(f"scope.value must be a valid email address for type '{self.type.value}'") + + elif self.type == ScopeType.domain: + # Validate domain name + domain_pattern = r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + if not re.match(domain_pattern, value) or len(value) > 253: + raise ValueError(f"scope.value must be a valid domain name for type '{self.type.value}'") + + return self + +class ACLRuleInput(BaseModel): + scope: ScopeInput + role: Optional[AclRole] = Field(None, description="The role assigned to the scope") + +class PatchACLRuleInput(BaseModel): + scope: Optional[ScopeInput] = Field(None, description="The extent to which calenda access is granted") + role: Optional[AclRole] = Field(None, description="The role assigned to the scope") + +class ScopeOutput(BaseModel): + type: ScopeType + value: Optional[str] = Field(None, description="The email address of a user or group, or the name of a domain, depending on the scope type. Omitted for type 'default'.") # Optional only for default + + +class ACLRule(BaseModel): + id: str + calendar_id: str + user_id: str + role: AclRole + etag: str + scope: ScopeOutput + +class InsertACLRule(BaseModel): + kind: str + etag: str + id: str + scope: ScopeInput + role: str + +class Channel(BaseModel): + """Channel model for watch notifications""" + + kind: str = Field(default="api#channel", description="Resource type identifier") + id: str = Field(..., description="Channel identifier") + resourceId: Optional[str] = Field(None, description="Resource ID") + resourceUri: Optional[str] = Field(None, description="Resource URI") + token: Optional[str] = Field(None, description="Channel token") + expiration: Optional[str] = Field(None, description="Expiration time") + +class WatchParams(BaseModel): + """Watch parameters""" + ttl: Optional[str] = Field(None, description="Time to live (seconds)") + + class Config: + extra = Extra.forbid + + @field_validator("ttl") + @classmethod + def _validate_ttl(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + s = str(v).strip() + if not s.isdigit(): + raise ValueError("params.ttl must be an integer string representing seconds") + if int(s) <= 0: + raise ValueError("params.ttl must be greater than 0") + return s + + +class ACLWatchRequest(BaseModel): + """Request model for watching ACL changes""" + + + id: str = Field(..., description="Channel identifier") + type: str = Field(default="web_hook", description="Channel type") + address: str = Field(..., description="Webhook address") + token: Optional[str] = Field(None, description="Channel token") + params: Optional[WatchParams] = Field(None, description="Optional parameters object; supports 'ttl' as string seconds per Google spec") + + @field_validator("type") + @classmethod + def _validate_type(cls, v: str) -> str: + if v is None: + raise ValueError("type is required") + s = str(v).strip().lower() + if s not in ("web_hook", "webhook"): + raise ValueError("Only channel type 'web_hook' is supported") + # Normalize to canonical 'web_hook' + return "web_hook" + + @field_validator("address") + @classmethod + def _validate_address(cls, v: str) -> str: + if v is None: + raise ValueError("address is required") + s = str(v).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'address': must be an https URL") + return s + + +class NotificationEvent(BaseModel): + """Model for notification event payload""" + + kind: str = Field(default="api#channel", description="Resource type identifier") + id: str = Field(..., description="Channel identifier") + resourceId: str = Field(..., description="Resource ID") + resourceUri: str = Field(..., description="Resource URI") + eventType: str = Field(..., description="Type of event (insert, update, delete)") + resourceState: str = Field(default="sync", description="State of the resource") + timestamp: str = Field(..., description="Event timestamp") + data: Dict[str, Any] = Field(..., description="The actual data that changed") + + + + +class WatchChannelInfo(BaseModel): + """Information about a watch channel""" + + id: str = Field(..., description="Channel identifier") + resource_id: str = Field(..., description="Resource ID") + resource_uri: str = Field(..., description="Resource URI") + calendar_id: str = Field(..., description="Calendar ID") + webhook_address: str = Field(..., description="Webhook address") + webhook_type: str = Field(..., description="Webhook type") + created_at: str = Field(..., description="Creation timestamp") + expires_at: Optional[str] = Field(None, description="Expiration timestamp") + is_active: str = Field(..., description="Whether channel is active") + notification_count: int = Field(..., description="Number of notifications sent") + + +class ACLListResponse(BaseModel): + """Response model for paginated ACL list""" + + kind: str = Field(default="calendar#acl", description="Resource type identifier") + etag: str = Field(..., description="ETag for the collection") + items: list[ACLRule] = Field(default_factory=list, description="List of ACL rules") + nextPageToken: Optional[str] = Field(None, description="Token for next page") + nextSyncToken: Optional[str] = Field(None, description="Token for next sync operation") diff --git a/envs/calendar_env/server/schemas/calendar.py b/envs/calendar_env/server/schemas/calendar.py new file mode 100644 index 000000000..3c1a4bbd0 --- /dev/null +++ b/envs/calendar_env/server/schemas/calendar.py @@ -0,0 +1,133 @@ +""" +Calendar models for Calendar API following Google Calendar API v3 structure +""" + +from typing import Optional, List +from pydantic import BaseModel, Field, field_validator + +# Allowed conference solution types per Google Calendar API v3 spec +ALLOWED_CONFERENCE_SOLUTION_TYPES = { + "eventHangout", + "eventNamedHangout", + "hangoutsMeet", +} + + +class ConferenceProperties(BaseModel): + """Conference properties for calendar""" + + allowedConferenceSolutionTypes: Optional[List[str]] = Field( + default=None, + description=( + "Conference solution types. Allowed values: 'eventHangout', 'eventNamedHangout', 'hangoutsMeet'" + ), + ) + + @field_validator("allowedConferenceSolutionTypes") + @classmethod + def validate_allowed_conference_solution_types(cls, v: Optional[List[str]]) -> Optional[List[str]]: + """Ensure provided values are restricted to the API-supported set. + + The Google Calendar API v3 permits only the following values for + conferenceProperties.allowedConferenceSolutionTypes: + - "eventHangout" + - "eventNamedHangout" + - "hangoutsMeet" + """ + if v is None: + return v + # Allow empty list, but every provided value must be valid + invalid = [item for item in v if item not in ALLOWED_CONFERENCE_SOLUTION_TYPES and item not in [None, ""]] + if invalid: + allowed_sorted = sorted(ALLOWED_CONFERENCE_SOLUTION_TYPES) + raise ValueError( + "Invalid values for conferenceProperties.allowedConferenceSolutionTypes: " + f"{invalid}. Allowed values are: {allowed_sorted}" + ) + return v + + +class Calendar(BaseModel): + """Calendar model following Google Calendar API v3 structure""" + + kind: str = Field(default="calendar#calendar", description="Calendar resource type") + etag: Optional[str] = Field(None, description="ETag of the resource") + id: Optional[str] = Field(None, description="Unique calendar identifier") + summary: str = Field(..., min_length=1, max_length=255, description="Calendar title") + description: Optional[str] = Field(None, max_length=1000, description="Calendar description") + location: Optional[str] = Field(None, max_length=500, description="Calendar location") + timeZone: str = Field(default="UTC", description="Calendar timezone in IANA format") + conferenceProperties: Optional[ConferenceProperties] = Field(None, description="Conference properties") + + +class CalendarCreateRequest(BaseModel): + """Request model for creating a calendar (POST /calendars)""" + + summary: str = Field(..., min_length=1, max_length=255, description="Calendar title (required)") + description: Optional[str] = Field(None, max_length=1000, description="Calendar description") + location: Optional[str] = Field(None, max_length=500, description="Calendar location") + timeZone: Optional[str] = Field(None, description="Calendar timezone in IANA format") + conferenceProperties: Optional[ConferenceProperties] = Field(None, description="Conference properties") + + @field_validator("summary") + @classmethod + def validate_summary_not_blank(cls, v: str) -> str: + if v is None: + return v + if isinstance(v, str) and v.strip() == "": + raise ValueError("summary must not be blank") + return v + + @field_validator("timeZone") + @classmethod + def validate_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + from dateutil.tz import gettz + if gettz(v) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + # If dateutil is unavailable or another error occurs + raise ValueError("Invalid timeZone; validation failed") + return v + + +class CalendarUpdateRequest(BaseModel): + """Request model for updating a calendar (PATCH/PUT /calendars/{calendarId})""" + + summary: str = Field(None, min_length=1, max_length=255, description="Calendar title") + description: Optional[str] = Field(None, max_length=1000, description="Calendar description") + location: Optional[str] = Field(None, max_length=500, description="Calendar location") + timeZone: Optional[str] = Field(None, description="Calendar timezone in IANA format") + conferenceProperties: Optional[ConferenceProperties] = Field(None, description="Conference properties") + + @field_validator("summary") + @classmethod + def validate_update_summary_not_blank(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if isinstance(v, str) and v.strip() == "": + raise ValueError("summary must not be blank when provided") + return v + + @field_validator("timeZone") + @classmethod + def validate_update_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + from dateutil.tz import gettz + if gettz(v) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + raise ValueError("Invalid timeZone; validation failed") + return v + + +class CalendarListResponse(BaseModel): + """Response model for listing calendars""" + + kind: str = Field(default="calendar#calendarList", description="Calendar list resource type") + etag: Optional[str] = Field(None, description="ETag of the collection") + items: List[Calendar] = Field(default_factory=list, description="List of calendars") \ No newline at end of file diff --git a/envs/calendar_env/server/schemas/calendar_list.py b/envs/calendar_env/server/schemas/calendar_list.py new file mode 100644 index 000000000..e99d1cd92 --- /dev/null +++ b/envs/calendar_env/server/schemas/calendar_list.py @@ -0,0 +1,287 @@ +""" +CalendarList models following Google Calendar API v3 CalendarList structure +""" + +from typing import Optional, List, Union +from pydantic import BaseModel, Field, field_validator +from enum import Enum +from urllib.parse import urlparse +from datetime import datetime, timezone + + +class AccessRole(str, Enum): + """Access roles for calendar list entries""" + FREE_BUSY_READER = "freeBusyReader" + READER = "reader" + WRITER = "writer" + OWNER = "owner" + + +class ReminderMethod(str, Enum): + """Allowed reminder delivery methods per Google Calendar API v3""" + EMAIL = "email" + POPUP = "popup" + + +class NotificationMethod(str, Enum): + """Allowed notification delivery methods (CalendarList notifications)""" + EMAIL = "email" + + +class NotificationType(str, Enum): + """Allowed CalendarList notification types per Google Calendar API v3""" + EVENT_CREATION = "eventCreation" + EVENT_CHANGE = "eventChange" + EVENT_CANCELLATION = "eventCancellation" + EVENT_RESPONSE = "eventResponse" + AGENDA = "agenda" + + +class EventReminder(BaseModel): + """Event reminder settings""" + method: ReminderMethod = Field(..., description="Reminder delivery method (email, popup)") + minutes: int = Field(..., description="Minutes before event to trigger reminder (>= 0)") + + @field_validator("minutes") + @classmethod + def _validate_minutes_non_negative(cls, v: int) -> int: + if v is None: + raise ValueError("minutes is required for defaultReminders items") + if int(v) < 0: + raise ValueError("defaultReminders[].minutes must be >= 0") + return v + +class PatchCalendarListEventReminder(BaseModel): + """Event reminder settings""" + method: Union[ReminderMethod, str] = "" + minutes: int = Field(..., description="Minutes before event to trigger reminder (>= 0)") + + @field_validator("minutes") + @classmethod + def _validate_minutes_non_negative(cls, v: int) -> int: + if v is None: + raise ValueError("minutes is required for defaultReminders items") + if int(v) < 0 and int(v) >=40320: + raise ValueError("defaultReminders[].minutes must be between 0 and 40320") + return v + + @field_validator("method") + @classmethod + def _validate_method(cls, v): + if v == "": + return v + try: + return ReminderMethod(v) + except ValueError: + raise ValueError(f"Reminder method must be one of {[rm.value for rm in ReminderMethod]} or empty string") + + +class Notification(BaseModel): + """Notification setting item for CalendarList notificationSettings.notifications[]""" + method: NotificationMethod = Field(..., description="Notification delivery method (only 'email' supported)") + type: NotificationType = Field( + ..., + description=( + "Notification type (eventCreation, eventChange, eventCancellation, eventResponse, agenda)" + ), + ) + +class PatchCalendarListNotification(BaseModel): + """Notification setting item for CalendarList notificationSettings.notifications[]""" + method: Union[NotificationMethod, str] = "" + type: Union[NotificationType, str] = "" + + @field_validator("method") + @classmethod + def _validate_method(cls, v): + if v == "": + return v + try: + return NotificationMethod(v) + except ValueError: + raise ValueError(f"Notification method must be {[nm.value for nm in NotificationMethod]} or empty string") + + + @field_validator("type") + @classmethod + def _validate_type(cls, v): + if v == "": + return v + try: + return NotificationType(v) + except ValueError: + raise ValueError(f"Notification type must be one of the {[nt.value for nt in NotificationType]} or empty string") + + + +class NotificationSettings(BaseModel): + """Notification settings for calendar""" + notifications: Optional[List[Notification]] = Field( + default=None, + description="List of notification settings (each requires method and type)", + ) + +class PatchCalendarListNotificationSettings(BaseModel): + """Notification settings for calendar""" + notifications: Optional[List[PatchCalendarListNotification]] = Field( + default=None, + description="List of notification settings (each requires method and type)", + ) + + + +class ConferenceProperties(BaseModel): + """Conference properties for calendar""" + allowedConferenceSolutionTypes: Optional[List[str]] = Field( + default_factory=list, + description="List of supported conference solution types" + ) + + @field_validator("allowedConferenceSolutionTypes") + @classmethod + def _validate_conference_solution_types(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is None: + return v + + allowed_types = {"eventHangout", "eventNamedHangout", "hangoutsMeet"} + for solution_type in v: + if solution_type not in allowed_types: + raise ValueError(f"Invalid conference solution type '{solution_type}'. Must be one of: {', '.join(sorted(allowed_types))}") + + return v + + +class CalendarListEntry(BaseModel): + """CalendarListEntry following Google Calendar API v3 structure""" + + kind: str = Field(default="calendar#calendarListEntry", description="Resource type") + etag: Optional[str] = Field(None, description="ETag of the resource") + id: str = Field(..., description="Calendar identifier") + summary: str = Field(..., description="Calendar title") + description: Optional[str] = Field(None, description="Calendar description") + location: Optional[str] = Field(None, description="Calendar location") + timeZone: str = Field(..., description="Calendar timezone") + summaryOverride: Optional[str] = Field(None, description="Custom calendar title override") + colorId: Optional[str] = Field(None, description="Calendar color ID") + backgroundColor: Optional[str] = Field(None, description="Calendar background color (hex)") + foregroundColor: Optional[str] = Field(None, description="Calendar foreground color (hex)") + hidden: Optional[bool] = Field(False, description="Whether calendar is hidden from list") + selected: Optional[bool] = Field(True, description="Whether calendar is selected in UI") + accessRole: AccessRole = Field(..., description="User's access level to this calendar") + defaultReminders: Optional[List[EventReminder]] = Field(None, description="Default reminders for events") + notificationSettings: Optional[NotificationSettings] = Field(None, description="Notification settings") + primary: Optional[bool] = Field(False, description="Whether this is the user's primary calendar") + deleted: Optional[bool] = Field(False, description="Whether calendar is deleted") + conferenceProperties: Optional[ConferenceProperties] = Field(None, description="Conference properties") + + +class CalendarListInsertRequest(BaseModel): + """Request model for inserting calendar into user's list""" + + id: str = Field(..., description="Calendar ID to add to user's list") + summaryOverride: Optional[str] = Field(None, description="Custom calendar title override") + colorId: Optional[str] = Field(None, description="Calendar color ID") + backgroundColor: Optional[str] = Field(None, description="Calendar background color (hex)") + foregroundColor: Optional[str] = Field(None, description="Calendar foreground color (hex)") + hidden: Optional[bool] = Field(False, description="Whether calendar is hidden from list") + selected: Optional[bool] = Field(True, description="Whether calendar is selected in UI") + defaultReminders: Optional[List[EventReminder]] = Field(None, description="Default reminders for events") + notificationSettings: Optional[NotificationSettings] = Field(None, description="Notification settings") + + +class CalendarListUpdateRequest(BaseModel): + """Request model for updating calendar list entry""" + + summaryOverride: Optional[str] = Field(None, description="Custom calendar title override") + colorId: Optional[str] = Field(None, description="Calendar color ID") + backgroundColor: Optional[str] = Field(None, description="Calendar background color (hex)") + foregroundColor: Optional[str] = Field(None, description="Calendar foreground color (hex)") + hidden: Optional[bool] = Field(None, description="Whether calendar is hidden from list") + selected: Optional[bool] = Field(None, description="Whether calendar is selected in UI") + defaultReminders: Optional[List[EventReminder]] = Field(None, description="Default reminders for events") + notificationSettings: Optional[NotificationSettings] = Field(None, description="Notification settings") + +class CalendarListPatchRequest(BaseModel): + """Request model for updating calendar list entry""" + + summaryOverride: Optional[str] = Field(None, description="Custom calendar title override") + colorId: Optional[str] = Field(None, description="Calendar color ID") + backgroundColor: Optional[str] = Field(None, description="Calendar background color (hex)") + foregroundColor: Optional[str] = Field(None, description="Calendar foreground color (hex)") + hidden: Optional[bool] = Field(None, description="Whether calendar is hidden from list") + selected: Optional[bool] = Field(None, description="Whether calendar is selected in UI") + defaultReminders: Optional[List[PatchCalendarListEventReminder]] = Field(None, description="Default reminders for events") + notificationSettings: Optional[PatchCalendarListNotificationSettings] = Field(None, description="Notification settings") + conferenceProperties: Optional[ConferenceProperties] = Field(None, description="Conference properties") + + +class CalendarListResponse(BaseModel): + """Response model for calendar list collection""" + + kind: str = Field(default="calendar#calendarList", description="Collection resource type") + etag: Optional[str] = Field(None, description="ETag of the collection") + nextPageToken: Optional[str] = Field(None, description="Token for next page of results") + nextSyncToken: Optional[str] = Field(None, description="Token for incremental sync") + items: List[CalendarListEntry] = Field(default_factory=list, description="List of calendar entries") + + +class Channel(BaseModel): + """Channel resource for watch notifications""" + + kind: str = Field(default="api#channel", description="Resource type identifier") + id: str = Field(..., description="Channel identifier") + resourceId: Optional[str] = Field(None, description="Resource being watched") + resourceUri: Optional[str] = Field(None, description="Resource URI") + token: Optional[str] = Field(None, description="Verification token") + expiration: Optional[int] = Field(None, description="Channel expiration time") + type: str = Field(default="web_hook", description="Channel type") + address: str = Field(..., description="Notification delivery address") + + +class WatchParams(BaseModel): + """Watch parameters""" + ttl: Optional[str] = Field(None, description="Time to live (seconds)") + + @field_validator("ttl") + @classmethod + def _validate_ttl(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + s = str(v).strip() + if not s.isdigit(): + raise ValueError("params.ttl must be an integer string representing seconds") + if int(s) <= 0: + raise ValueError("params.ttl must be greater than 0") + return s + + +class WatchRequest(BaseModel): + """Request model for setting up watch notifications""" + + id: str = Field(..., min_length=1, description="Channel identifier (non-empty)") + type: str = Field(..., description="Channel type: must be 'web_hook' (alias 'webhook' accepted)") + address: str = Field(..., min_length=1, description="Notification delivery address (HTTPS URL)") + token: Optional[str] = Field(None, description="Verification token") + params: Optional[WatchParams] = Field(None, description="Optional parameters object; supports 'ttl' as string seconds per Google spec") + + @field_validator("type") + @classmethod + def _validate_type(cls, v: str) -> str: + if v is None: + raise ValueError("type is required") + s = str(v).strip().lower() + if s not in ("web_hook", "webhook"): + raise ValueError("Only channel type 'web_hook' is supported") + # Normalize to canonical 'web_hook' + return "web_hook" + + @field_validator("address") + @classmethod + def _validate_address(cls, v: str) -> str: + if v is None: + raise ValueError("address is required") + s = str(v).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'address': must be an https URL") + return s \ No newline at end of file diff --git a/envs/calendar_env/server/schemas/common.py b/envs/calendar_env/server/schemas/common.py new file mode 100644 index 000000000..12dd6e026 --- /dev/null +++ b/envs/calendar_env/server/schemas/common.py @@ -0,0 +1,74 @@ +""" +Common models and response schemas for Calendar API +""" + +from typing import Dict, Optional, List, Any +from pydantic import BaseModel, Field +from datetime import datetime + + +class BaseResponse(BaseModel): + """Base response model""" + + success: bool + message: Optional[str] = None + timestamp: Optional[str] = None + + +class ErrorResponse(BaseResponse): + """Error response model""" + + success: bool = False + error: str + error_code: Optional[str] = None + + +class SuccessResponse(BaseResponse): + """Success response model""" + + success: bool = True + data: Optional[Any] = None + + +class PaginationModel(BaseModel): + """Pagination model""" + + limit: int = Field(default=100, ge=1, le=1000, description="Number of records to return") + offset: int = Field(default=0, ge=0, description="Number of records to skip") + + +class DatabaseStateResponse(BaseModel): + """Database state response model""" + + success: bool + service: str + database_info: Dict[str, Any] + table_counts: Dict[str, int] + table_data: Dict[str, List[Dict[str, Any]]] + timestamp: str + + +# Enums for calendar status and visibility +class EventStatus: + CONFIRMED = "confirmed" + TENTATIVE = "tentative" + CANCELLED = "cancelled" + + +class EventVisibility: + DEFAULT = "default" + PUBLIC = "public" + PRIVATE = "private" + CONFIDENTIAL = "confidential" + + +class CalendarAccessRole: + OWNER = "owner" + READER = "reader" + WRITER = "writer" + FREE_BUSY_READER = "freeBusyReader" + + +class EventTransparency: + OPAQUE = "opaque" + TRANSPARENT = "transparent" \ No newline at end of file diff --git a/envs/calendar_env/server/schemas/event.py b/envs/calendar_env/server/schemas/event.py new file mode 100644 index 000000000..a476ae8c1 --- /dev/null +++ b/envs/calendar_env/server/schemas/event.py @@ -0,0 +1,634 @@ +""" +Event models following Google Calendar API v3 structure +""" + +from typing import Optional, List, Dict, Any, Literal +from pydantic import BaseModel, Field, field_validator, Extra, model_validator +from enum import Enum +from urllib.parse import urlparse +import re +import json + + +class EventStatus(str, Enum): + """Event status enum""" + CONFIRMED = "confirmed" + TENTATIVE = "tentative" + CANCELLED = "cancelled" + + +class EventVisibility(str, Enum): + """Event visibility enum""" + DEFAULT = "default" + PUBLIC = "public" + PRIVATE = "private" + CONFIDENTIAL = "confidential" + + +class Transparency(str, Enum): + """Event transparency enum""" + OPAQUE = "opaque" + TRANSPARENT = "transparent" + + +class OrderByEnum(str, Enum): + """Event list ordering enum""" + START_TIME = "startTime" + UPDATED = "updated" + + +class EventTypesEnum(str, Enum): + """Event types enum for filtering""" + DEFAULT = "default" + BIRTHDAY = "birthday" + FOCUS_TIME = "focusTime" + FROM_GMAIL = "fromGmail" + OUT_OF_OFFICE = "outOfOffice" + WORKING_LOCATION = "workingLocation" + +class ResponseStatusEnum(str, Enum): + needsAction = "needsAction" + declined = "declined" + tentative = "tentative" + accepted = "accepted" + +class ReminderMethodEnum(str, Enum): + email = "email" + popup = "popup" + +class EntryPointType(str, Enum): + """Conference entry point types""" + VIDEO = "video" + PHONE = "phone" + SIP = "sip" + MORE = "more" + + +class DateTime(BaseModel): + """DateTime object for event start/end times""" + dateTime: Optional[str] = Field(None, description="RFC3339 timestamp") + date: Optional[str] = Field(None, description="Date in YYYY-MM-DD format") + timeZone: Optional[str] = Field(None, description="IANA timezone") + + @model_validator(mode='after') + def validate_datetime_or_date(self) -> 'DateTime': + """Ensure either dateTime or date is provided, but not both""" + has_datetime = self.dateTime is not None + has_date = self.date is not None + + if has_datetime and has_date: + raise ValueError("Cannot specify both dateTime and date - use either dateTime for timed events or date for all-day events") + + if not has_datetime and not has_date: + raise ValueError("Must specify either dateTime or date - dateTime for timed events, date for all-day events") + + return self + + @field_validator('timeZone') + @classmethod + def validate_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + from dateutil.tz import gettz + if gettz(v) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + # If dateutil is unavailable or another error occurs + raise ValueError("Invalid timeZone; validation failed") + return v + +class Person(BaseModel): + """Person object for organizer and attendees""" + id: Optional[str] = Field(None, description="Person identifier") + email: Optional[str] = Field(None, description="Email address") + displayName: Optional[str] = Field(None, description="Display name") + self: Optional[bool] = Field(None, description="Whether this person is you") + + +class Attendee(Person): + """Event attendee""" + organizer: Optional[bool] = Field(False, description="Whether attendee is the event organizer") + responseStatus: Optional[ResponseStatusEnum] = Field("needsAction", description="Response status: needsAction, declined, tentative, accepted") + comment: Optional[str] = Field(None, description="Attendee comment") + additionalGuests: Optional[int] = Field(0, description="Number of additional guests") + optional: Optional[bool] = Field(False, description="Whether attendance is optional") + resource: Optional[bool] = Field(False, description="Whether this is a resource") + + +class EventReminder(BaseModel): + """Event reminder settings""" + method: ReminderMethodEnum = Field(..., description="Reminder method: email, popup") + minutes: int = Field(..., gt=0, lt=40320, description="Minutes before event") + + +class ReminderOverrides(BaseModel): + """Event reminder overrides""" + overrides: Optional[List[EventReminder]] = Field(None, description="List of reminders") + useDefault: bool = Field(True, description="Use default reminders") + + +class ConferenceSolutionKey(BaseModel): + """Conference solution key""" + type: Optional[str] = Field(None, description="Conference solution type") + + +class ConferenceSolution(BaseModel): + """Conference solution details""" + iconUri: Optional[str] = Field(None, description="Icon URI for the conference solution") + key: Optional[ConferenceSolutionKey] = Field(None, description="Conference solution key") + name: Optional[str] = Field(None, description="Name of the conference solution") + + + +class CreateRequestStatus(BaseModel): + """Create request status""" + statusCode: Optional[str] = Field(None, description="Status code of the create request") + + +class CreateRequest(BaseModel): + """Conference create request details""" + conferenceSolutionKey: Optional[ConferenceSolutionKey] = Field(None, description="Conference solution for the create request") + requestId: Optional[str] = Field(None, description="Request ID for creating the conference") + status: Optional[CreateRequestStatus] = Field(None, description="Status of the create request") + + +class EntryPoint(BaseModel): + """Conference entry point details""" + accessCode: Optional[str] = Field(None, max_length=128, description="Access code for the conference") + entryPointType: Optional[EntryPointType] = Field(None, description="Type of entry point") + label: Optional[str] = Field(None, max_length=512, description="Label for the URI, visible to end users") + meetingCode: Optional[str] = Field(None, max_length=128, description="Meeting code for the conference") + passcode: Optional[str] = Field(None, max_length=128, description="Passcode for the conference") + password: Optional[str] = Field(None, max_length=128, description="Password to access the conference") + pin: Optional[str] = Field(None, max_length=128, description="PIN for the conference") + uri: Optional[str] = Field(None, max_length=1300, description="URI for the conference entry point") + + @field_validator('uri') + @classmethod + def validate_uri(cls, v: str, info) -> str: + """Validate URI format based on entry point type""" + if not v: + raise ValueError("URI is required") + + # Get the entry point type from the model context + entry_point_type = info.data.get('entryPointType') + + parsed = urlparse(v) + + if entry_point_type == EntryPointType.VIDEO: + if parsed.scheme not in ['http', 'https']: + raise ValueError("Video entry point URI must use http or https schema") + elif entry_point_type == EntryPointType.PHONE: + if parsed.scheme != 'tel': + raise ValueError("Phone entry point URI must use tel schema") + elif entry_point_type == EntryPointType.SIP: + if parsed.scheme != 'sip': + raise ValueError("SIP entry point URI must use sip schema") + elif entry_point_type == EntryPointType.MORE: + if parsed.scheme not in ['http', 'https']: + raise ValueError("More entry point URI must use http or https schema") + + return v + +class ConferenceData(BaseModel): + """Conference/meeting data""" + + conferenceId: Optional[str] = Field(None, description="Conference ID") + conferenceSolution: Optional[ConferenceSolution] = Field(None, description="Conference solution details") + createRequest: Optional[CreateRequest] = Field(None, description="Conference create request details") + entryPoints: Optional[List[EntryPoint]] = Field(None, description="Conference entry points") + notes: Optional[str] = Field(None, max_length=2048, description="Conference notes") + signature: Optional[str] = Field(None, description="Conference signature") + + @model_validator(mode='after') + def validate_conference_data(self) -> 'ConferenceData': + """Validate that either (conferenceSolution + entryPoints) or createRequest is provided""" + has_solution = self.conferenceSolution is not None + has_entry_points = self.entryPoints is not None and len(self.entryPoints) > 0 + has_create_request = self.createRequest is not None + + if has_create_request: + # If createRequest is provided, conferenceSolution and entryPoints should not be set + if has_solution or has_entry_points: + raise ValueError("Cannot specify both createRequest and conferenceSolution/entryPoints") + else: + # If no createRequest, must have conferenceSolution and at least one entryPoint + if not (has_solution and has_entry_points): + raise ValueError("Must specify either createRequest OR both conferenceSolution and at least one entryPoint") + + return self + + @field_validator('entryPoints') + @classmethod + def validate_entry_points(cls, v: Optional[List[EntryPoint]]) -> Optional[List[EntryPoint]]: + """Validate entry point constraints""" + if not v: + return v + + # Count entry points by type + type_counts = {} + for entry_point in v: + entry_type = entry_point.entryPointType + type_counts[entry_type] = type_counts.get(entry_type, 0) + 1 + + # Validate type constraints + if type_counts.get(EntryPointType.VIDEO, 0) > 1: + raise ValueError("Conference can have at most one video entry point") + if type_counts.get(EntryPointType.SIP, 0) > 1: + raise ValueError("Conference can have at most one SIP entry point") + if type_counts.get(EntryPointType.MORE, 0) > 1: + raise ValueError("Conference can have at most one more entry point") + + # A conference with only a 'more' entry point is not valid + if len(v) == 1 and v[0].entryPointType == EntryPointType.MORE: + raise ValueError("Conference with only a 'more' entry point is not valid") + + return v + +class ConferenceDataOutput(BaseModel): + conferenceSolution: Optional[ConferenceSolution] = Field(None, description="Conference solution details") + createRequest: Optional[CreateRequest] = Field(None, description="Conference create request details") + entryPoints: Optional[List[EntryPoint]] = Field(None, description="Conference entry points") + notes: Optional[str] = Field(None, description="Conference notes") + signature: Optional[str] = Field(None, description="Conference signature") + +class FocusTimeProperties(BaseModel): + """Focus time properties for focus time events""" + autoDeclineMode: Optional[Literal["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"]] = Field( + "", + description="Whether to decline meeting invitations which overlap Focus Time events. Valid values are declineNone, declineAllConflictingInvitations, and declineOnlyNewConflictingInvitations" + ) + chatStatus: Optional[Literal["available", "doNotDisturb"]] = Field( + "", + description="The status to mark the user in Chat and related products. This can be available or doNotDisturb" + ) + declineMessage: Optional[str] = Field( + "", + description="Response message to set if an existing event or new invitation is automatically declined by Calendar" + ) + +class OutOfOfficeProperties(BaseModel): + """Out of office properties for outOfOfficeProperties events""" + autoDeclineMode: Optional[Literal["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"]] = Field( + "", + description="Whether to decline meeting invitations which overlap Focus Time events. Valid values are declineNone, declineAllConflictingInvitations, and declineOnlyNewConflictingInvitations" + ) + declineMessage: Optional[str] = Field( + "", + description="Response message to set if an existing event or new invitation is automatically declined by Calendar" + ) + + +class CustomLocation(BaseModel): + """Custom location details for working location events""" + label: Optional[str] = Field(None, description="An optional extra label for additional information") + + +class OfficeLocation(BaseModel): + """Office location details for working location events""" + buildingId: Optional[str] = Field(None, description="An optional building identifier. This should reference a building ID in the organization's Resources database") + deskId: Optional[str] = Field(None, description="An optional desk identifier") + floorId: Optional[str] = Field(None, description="An optional floor identifier") + floorSectionId: Optional[str] = Field(None, description="An optional floor section identifier") + label: Optional[str] = Field(None, description="The office name that's displayed in Calendar Web and Mobile clients. We recommend you reference a building name in the organization's Resources database") + + + +class WorkingLocationProperties(BaseModel): + """Working location properties for working location events""" + type: Literal["homeOffice", "officeLocation", "customLocation"] = Field( + ..., + description="Type of the working location. Required when adding working location properties" + ) + customLocation: Optional[CustomLocation] = Field(None, description="If present, specifies that the user is working from a custom location") + homeOffice: Optional[Any] = Field(None, description="If present, specifies that the user is working at home") + officeLocation: Optional[OfficeLocation] = Field(None, description="If present, specifies that the user is working from an office") + + @field_validator("homeOffice") + @classmethod + def _validate_homeOffice(cls, v): + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + return v + return v + +class BirthdayProperties(BaseModel): + """Birthday properties for birthday events""" + type: Literal["birthday"] = Field("birthday", description="Type of birthday event, must be 'birthday'. Cannot be changed after event creation.") + +class ExtendedProperties(BaseModel): + """Extended properties for events""" + + private: Optional[Dict[str, str]] = Field(None, description="Private extended properties") + shared: Optional[Dict[str, str]] = Field(None, description="Shared extended properties") + +class EventSource(BaseModel): + """Event source information""" + + url: str = Field(..., description="Source URL") + title: str = Field(..., description="Source title") + +class Event(BaseModel): + """Event model following Google Calendar API v3 structure""" + + kind: str = Field(default="calendar#event", description="Resource type") + etag: Optional[str] = Field(None, description="ETag of the resource") + id: Optional[str] = Field(None, description="Event identifier") + status: EventStatus = Field(default=EventStatus.CONFIRMED, description="Event status") + htmlLink: Optional[str] = Field(None, description="Absolute link to event in Google Calendar") + created: Optional[str] = Field(None, description="Creation time (RFC3339)") + updated: Optional[str] = Field(None, description="Last modification time (RFC3339)") + + summary: Optional[str] = Field(None, description="Event title") + description: Optional[str] = Field(None, description="Event description") + location: Optional[str] = Field(None, description="Geographic location") + colorId: Optional[str] = Field(None, description="Color ID") + + creator: Optional[Person] = Field(None, description="Event creator") + organizer: Optional[Person] = Field(None, description="Event organizer") + + start: DateTime = Field(..., description="Event start time") + end: DateTime = Field(..., description="Event end time") + endTimeUnspecified: Optional[bool] = Field(False, description="Whether end time is actually unspecified") + + recurrence: Optional[List[str]] = Field(None, description="List of RRULE, EXRULE, RDATE and EXDATE") + recurringEventId: Optional[str] = Field(None, description="Recurring event ID for instances of recurring events") + originalStartTime: Optional[DateTime] = Field(None, description="Original start time for recurring event instances") + + transparency: Optional[Transparency] = Field(None, description="Whether event blocks time") + visibility: Optional[EventVisibility] = Field(EventVisibility.DEFAULT, description="Visibility of event") + iCalUID: Optional[str] = Field(None, description="iCalendar UID") + sequence: Optional[int] = Field(0, description="Sequence number") + + attendees: Optional[List[Attendee]] = Field(None, description="List of attendees") + attendeesOmitted: Optional[bool] = Field(None, description="Whether attendees are omitted") + + extendedProperties: Optional[Dict[str, Any]] = Field(None, description="Extended properties") + hangoutLink: Optional[str] = Field(None, description="Hangout link") + conferenceData: Optional[ConferenceDataOutput] = Field(None, description="Conference data") + + guestsCanInviteOthers: Optional[bool] = Field(True, description="Whether guests can invite others") + guestsCanModify: Optional[bool] = Field(False, description="Whether guests can modify event") + guestsCanSeeOtherGuests: Optional[bool] = Field(True, description="Whether guests can see other guests") + privateCopy: Optional[bool] = Field(None, description="Whether this is a private copy") + locked: Optional[bool] = Field(False, description="Whether event is locked") + + reminders: Optional[ReminderOverrides] = Field(None, description="Reminder settings") + source: Optional[EventSource] = Field(None, description="Source from which event was created") + attachments: Optional[List[Dict[str, Any]]] = Field(None, description="File attachments") + eventType: Optional[str] = Field("default", description="Event type: default, outOfOffice, focusTime, workingLocation") + + # Type-specific properties + birthdayProperties: Optional[BirthdayProperties] = Field(None, description="Birthday properties for birthday events") + focusTimeProperties: Optional[FocusTimeProperties] = Field(None, description="Focus time properties for focusTime events") + outOfOfficeProperties: Optional[OutOfOfficeProperties] = Field(None, description="Out of office properties for outOfOffice events") + workingLocationProperties: Optional[WorkingLocationProperties] = Field(None, description="Working location properties for workingLocation events") + + +class EventCreateRequest(BaseModel): + """Request model for creating an event - matches Google Calendar API v3 Events.insert""" + + # Required properties + end: DateTime = Field(..., description="Event end time") + start: DateTime = Field(..., description="Event start time") + + # Optional properties (alphabetically ordered as per Google API docs) + attachments: Optional[List[Dict[str, Any]]] = Field(None, description="File attachments for the event") + attendees: Optional[List[Attendee]] = Field(None, description="List of attendees") + birthdayProperties: Optional[BirthdayProperties] = Field(None, description="Birthday properties for birthday events") + colorId: Optional[str] = Field(None, description="Color ID of the event") + conferenceData: Optional[ConferenceData] = Field(None, description="Conference data") + description: Optional[str] = Field(None, description="Event description") + eventType: Optional[EventTypesEnum] = Field("default", description="Event type: default, birthday, outOfOffice, focusTime, workingLocation") + extendedProperties: Optional[Dict[str, Any]] = Field(None, description="Extended properties") + focusTimeProperties: Optional[FocusTimeProperties] = Field(None, description="Focus time properties for focusTime events") + guestsCanInviteOthers: Optional[bool] = Field(True, description="Whether guests can invite others") + guestsCanModify: Optional[bool] = Field(False, description="Whether guests can modify event") + guestsCanSeeOtherGuests: Optional[bool] = Field(True, description="Whether guests can see other guests") + hangoutLink: Optional[str] = Field(None, description="Hangout link") + iCalUID: Optional[str] = Field(None, description="iCalendar UID") + location: Optional[str] = Field(None, description="Geographic location") + originalStartTime: Optional[DateTime] = Field(None, description="Original start time for recurring event instances") + outOfOfficeProperties: Optional[OutOfOfficeProperties] = Field(None, description="Out of office properties for outOfOffice events") + recurrence: Optional[List[str]] = Field(None, description="List of RRULE, EXRULE, RDATE and EXDATE") + reminders: Optional[ReminderOverrides] = Field(None, description="Reminder settings") + sequence: Optional[int] = Field(0, description="Sequence number") + source: Optional[Dict[str, Any]] = Field(None, description="Source from which event was created") + status: Optional[EventStatus] = Field(EventStatus.CONFIRMED, description="Event status") + summary: Optional[str] = Field(None, description="Event title") + transparency: Optional[Transparency] = Field(Transparency.OPAQUE, description="Whether event blocks time") + visibility: Optional[EventVisibility] = Field(EventVisibility.DEFAULT, description="Visibility of event") + workingLocationProperties: Optional[WorkingLocationProperties] = Field(None, description="Working location properties for workingLocation events") + + @field_validator("iCalUID") + @classmethod + def _validate_iCalUID(cls, v: Optional[str]) -> Optional[str]: + pattern = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(pattern, v): + raise ValueError("Invalid iCalUID format. Expected something like 'abcd123@google.com'.") + return v + + @field_validator("source") + @classmethod + def _validate_source(cls, v: str) -> str: + if v is None: + return None + s = str(v.get('url')).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'url' in source: must be an https or http URL") + return v + +class EventUpdateRequest(BaseModel): + """Request model for updating an event - matches Google Calendar API v3 Events.patch""" + + # Basic event properties + summary: Optional[str] = Field(None, description="Event title") + description: Optional[str] = Field(None, description="Event description") + location: Optional[str] = Field(None, description="Geographic location") + colorId: Optional[str] = Field(None, description="Color ID") + + # Date/time properties + start: Optional[DateTime] = Field(None, description="Event start time") + end: Optional[DateTime] = Field(None, description="Event end time") + endTimeUnspecified: Optional[bool] = Field(None, description="Whether end time is actually unspecified") + + eventType: Optional[EventTypesEnum] = Field(None, description="Event type: default, birthday, outOfOffice, focusTime, workingLocation") + + # Recurrence properties + recurrence: Optional[List[str]] = Field(None, description="List of RRULE, EXRULE, RDATE and EXDATE") + + # Status and visibility + status: Optional[EventStatus] = Field(None, description="Event status") + visibility: Optional[EventVisibility] = Field(None, description="Visibility of event") + transparency: Optional[Transparency] = Field(None, description="Whether event blocks time") + + # Attendee management + attendees: Optional[List[Attendee]] = Field(None, description="List of attendees") + guestsCanInviteOthers: Optional[bool] = Field(None, description="Whether guests can invite others") + guestsCanModify: Optional[bool] = Field(None, description="Whether guests can modify event") + guestsCanSeeOtherGuests: Optional[bool] = Field(None, description="Whether guests can see other guests") + + # Advanced properties requiring query_params + attachments: Optional[List[Dict[str, Any]]] = Field(None, description="File attachments (requires supportsAttachments=true)") + conferenceData: Optional[ConferenceData] = Field(None, description="Conference data (requires conferenceDataVersion)") + + # Type-specific properties (note: birthdayProperties.type cannot be changed after creation) + birthdayProperties: Optional[BirthdayProperties] = Field(None, description="Birthday properties (type cannot be changed)") + focusTimeProperties: Optional[FocusTimeProperties] = Field(None, description="Focus time properties for focusTime events") + outOfOfficeProperties: Optional[Dict[str, Any]] = Field(None, description="Out of office properties for outOfOffice events") + workingLocationProperties: Optional[WorkingLocationProperties] = Field(None, description="Working location properties for workingLocation events") + + # Other modifiable properties + extendedProperties: Optional[Dict[str, Any]] = Field(None, description="Extended properties") + hangoutLink: Optional[str] = Field(None, description="Hangout link") + iCalUID: Optional[str] = Field(None, description="iCalendar UID") + privateCopy: Optional[bool] = Field(None, description="Whether this is a private copy") + locked: Optional[bool] = Field(None, description="Whether event is locked") + reminders: Optional[ReminderOverrides] = Field(None, description="Reminder settings") + sequence: Optional[int] = Field(None, description="Sequence number") + source: Optional[Dict[str, Any]] = Field(None, description="Source from which event was created") + + @field_validator("iCalUID") + @classmethod + def _validate_iCalUID(cls, v: Optional[str]) -> Optional[str]: + pattern = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(pattern, v): + raise ValueError("Invalid iCalUID format. Expected something like 'abcd123@google.com'.") + return v + + @field_validator("source") + @classmethod + def _validate_source(cls, v: str) -> str: + if v is None: + return None + s = str(v.get('url')).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'url' in source: must be an https or http URL") + return v + + +class EventListResponse(BaseModel): + """Response model for events list""" + + kind: str = Field(default="calendar#events", description="Resource type") + etag: Optional[str] = Field(None, description="ETag of the collection") + summary: Optional[str] = Field(None, description="Calendar title") + description: Optional[str] = Field(None, description="Calendar description") + updated: Optional[str] = Field(None, description="Last modification time (RFC3339)") + timeZone: Optional[str] = Field(None, description="Calendar timezone") + accessRole: Optional[str] = Field(None, description="User's access role") + defaultReminders: Optional[List[EventReminder]] = Field(None, description="Default reminders") + nextPageToken: Optional[str] = Field(None, description="Token for next page") + nextSyncToken: Optional[str] = Field(None, description="Token for incremental sync") + items: List[Event] = Field(..., description="List of events") + + +class EventMoveRequest(BaseModel): + """Request model for moving an event to another calendar""" + + destination: str = Field(..., description="ID of destination calendar") + sendUpdates: Optional[str] = Field(None, description="Guests who should receive notifications") + + +class EventQuickAddRequest(BaseModel): + """Request model for quick adding an event""" + + text: str = Field(..., min_length=1, max_length=1000, description="Quick add text") + sendUpdates: Optional[str] = Field(None, description="Guests who should receive notifications") + + +class EventImportRequest(BaseModel): + """Request model for importing an event""" + + summary: str = Field(..., min_length=1, max_length=255, description="Event title") + description: Optional[str] = Field(None, max_length=2000, description="Event description") + location: Optional[str] = Field(None, max_length=500, description="Event location") + start_datetime: str = Field(..., description="Event start datetime in ISO format") + end_datetime: str = Field(..., description="Event end datetime in ISO format") + start_timezone: Optional[str] = Field(None, description="Start datetime timezone") + end_timezone: Optional[str] = Field(None, description="End datetime timezone") + recurrence: Optional[str] = Field(None, description="Recurrence rules in RRULE format") + status: str = Field(default="confirmed", description="Event status") + visibility: str = Field(default="default", description="Event visibility") + supportsImport: Optional[bool] = Field(None, description="Whether import is supported") + + +class EventInstancesResponse(BaseModel): + """Response model for recurring event instances""" + + kind: str = Field(default="calendar#events", description="Resource type") + etag: Optional[str] = Field(None, description="ETag of the collection") + summary: Optional[str] = Field(None, description="Calendar title") + description: Optional[str] = Field(None, description="Calendar description") + updated: Optional[str] = Field(None, description="Last modification time (RFC3339)") + timeZone: Optional[str] = Field(None, description="Calendar timezone") + accessRole: Optional[str] = Field(None, description="User's access role") + defaultReminders: Optional[List[EventReminder]] = Field(None, description="Default reminders") + nextPageToken: Optional[str] = Field(None, description="Token for next page") + items: List[Event] = Field(..., description="List of event instances") + + +class Channel(BaseModel): + """Channel model for watch notifications""" + + kind: str = Field(default="api#channel", description="Resource type identifier") + id: str = Field(..., description="Channel identifier") + resourceId: Optional[str] = Field(None, description="Resource ID") + resourceUri: Optional[str] = Field(None, description="Resource URI") + token: Optional[str] = Field(None, description="Channel token") + expiration: Optional[str] = Field(None, description="Expiration time") + +class WatchParams(BaseModel): + """Watch parameters""" + ttl: Optional[str] = Field(None, description="Time to live (seconds)") + + class Config: + extra = Extra.forbid + + @field_validator("ttl") + @classmethod + def _validate_ttl(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + s = str(v).strip() + if not s.isdigit(): + raise ValueError("params.ttl must be an integer string representing seconds") + if int(s) <= 0: + raise ValueError("params.ttl must be greater than 0") + return s + +class EventWatchRequest(BaseModel): + """Request model for watching events""" + + id: str = Field(..., description="Channel identifier") + type: str = Field(description="Channel type") + address: str = Field(..., description="Webhook address") + token: Optional[str] = Field(None, description="Channel token") + params: Optional[WatchParams] = Field(None, description="Optional parameters object; supports 'ttl' as string seconds per Google spec") + + @field_validator("type") + @classmethod + def _validate_type(cls, v: str) -> str: + if v is None: + raise ValueError("type is required") + s = str(v).strip().lower() + if s not in ("web_hook", "webhook"): + raise ValueError("Only channel type 'web_hook' or 'webhook' is supported") + # Normalize to canonical 'web_hook' + return "web_hook" + + @field_validator("address") + @classmethod + def _validate_address(cls, v: str) -> str: + if v is None: + raise ValueError("address is required") + s = str(v).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'address': must be an https URL") + return s \ No newline at end of file diff --git a/envs/calendar_env/server/schemas/freebusy.py b/envs/calendar_env/server/schemas/freebusy.py new file mode 100644 index 000000000..0e6ce8c8f --- /dev/null +++ b/envs/calendar_env/server/schemas/freebusy.py @@ -0,0 +1,163 @@ +""" +FreeBusy models following Google Calendar API v3 structure +""" + +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, field_validator +from datetime import datetime + + +class FreeBusyError(BaseModel): + """Error model for FreeBusy API responses""" + + domain: str = Field(..., description="Error domain") + reason: str = Field(..., description="Error reason") + + +class TimePeriod(BaseModel): + """Time period model for busy times""" + + start: str = Field(..., description="Start time in RFC3339 format") + end: str = Field(..., description="End time in RFC3339 format") + + +class CalendarItem(BaseModel): + """Calendar item for FreeBusy query request""" + + id: str = Field(..., description="Calendar identifier") + + +class FreeBusyQueryRequest(BaseModel): + """Request model for FreeBusy query""" + + timeMin: str = Field(..., description="Lower bound for the query (RFC3339)") + timeMax: str = Field(..., description="Upper bound for the query (RFC3339)") + timeZone: Optional[str] = Field("UTC", description="Time zone for the query") + groupExpansionMax: Optional[int] = Field( + None, + ge=1, + description="Maximum number of calendars to expand for groups" + ) + calendarExpansionMax: Optional[int] = Field( + None, + ge=1, + description="Maximum number of events to expand for calendars" + ) + items: List[CalendarItem] = Field(..., description="List of calendars to query") + + @field_validator("timeZone") + @classmethod + def validate_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + from dateutil.tz import gettz + if gettz(v) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + # If dateutil is unavailable or another error occurs + raise ValueError("Invalid timeZone; validation failed") + return v + + @field_validator('items') + @classmethod + def validate_items_not_empty(cls, v): + """Validate that items list is not empty.""" + if not v: + raise ValueError("At least one calendar item is required") + return v + + +class FreeBusyCalendarResult(BaseModel): + """FreeBusy result for a single calendar""" + + errors: Optional[List[FreeBusyError]] = Field(None, description="List of errors") + busy: Optional[List[TimePeriod]] = Field(None, description="List of busy time periods") + + +class FreeBusyGroupResult(BaseModel): + """FreeBusy result for a group""" + + calendars: Optional[List[str]] = Field(None, description="List of calendar IDs in the group") + errors: Optional[List[FreeBusyError]] = Field(None, description="List of errors") + + +class FreeBusyQueryResponse(BaseModel): + """Response model for FreeBusy query""" + + kind: str = Field(default="calendar#freeBusy", description="Resource type") + timeMin: str = Field(..., description="Lower bound for the query (RFC3339)") + timeMax: str = Field(..., description="Upper bound for the query (RFC3339)") + calendars: Dict[str, FreeBusyCalendarResult] = Field( + default_factory=dict, + description="Calendar-specific results" + ) + groups: Optional[Dict[str, FreeBusyGroupResult]] = Field( + None, + description="Group-specific results" + ) + + + +# Additional helper models for internal use + +class FreeBusyCalendarInput(BaseModel): + """Internal model for calendar input validation""" + + calendar_id: str = Field(..., description="Calendar identifier") + user_id: str = Field(..., description="User identifier") + + +class FreeBusyTimeRange(BaseModel): + """Internal model for time range validation""" + + start: datetime = Field(..., description="Start datetime") + end: datetime = Field(..., description="End datetime") + timezone: str = Field(default="UTC", description="Timezone") + + def validate_range(self) -> bool: + """Validate that end time is after start time""" + return self.end > self.start + + +class FreeBusyEventOverlap(BaseModel): + """Internal model for event overlap calculation""" + + event_id: str = Field(..., description="Event identifier") + start: datetime = Field(..., description="Event start time") + end: datetime = Field(..., description="Event end time") + transparency: Optional[str] = Field("opaque", description="Event transparency") + + def is_busy(self) -> bool: + """Check if event blocks time (is not transparent)""" + return self.transparency != "transparent" + + +class FreeBusyQueryValidation(BaseModel): + """Internal model for query validation""" + + time_min: datetime = Field(..., description="Query start time") + time_max: datetime = Field(..., description="Query end time") + calendar_ids: List[str] = Field(..., description="Calendar IDs to query") + user_id: str = Field(..., description="User making the query") + + def validate_time_range(self) -> bool: + """Validate time range is reasonable""" + max_duration_days = 366 # Maximum 1 year + 1 day + # Calculate total duration in seconds to handle same-day queries + duration_timedelta = self.time_max - self.time_min + duration_seconds = duration_timedelta.total_seconds() + + # Ensure the time range is valid (end after start) + if duration_seconds <= 0: + return False + + # Convert to days for comparison with maximum limit + duration_days = duration_seconds / (24 * 60 * 60) # seconds per day + + return duration_days <= max_duration_days + + def validate_calendar_count(self) -> bool: + """Validate number of calendars is reasonable""" + max_calendars = 50 # Maximum calendars per query + return 0 < len(self.calendar_ids) <= max_calendars diff --git a/envs/calendar_env/server/schemas/import_event.py b/envs/calendar_env/server/schemas/import_event.py new file mode 100644 index 000000000..2cb78788c --- /dev/null +++ b/envs/calendar_env/server/schemas/import_event.py @@ -0,0 +1,543 @@ +""" +Event Import schemas following Google Calendar API v3 structure +""" + +from typing import Optional, List, Dict, Any, Union, Literal +from pydantic import BaseModel, Field, validator, field_validator, model_validator +from datetime import datetime +from enum import Enum +from urllib.parse import urlparse +import re + + +class EventDateTime(BaseModel): + """DateTime model for event start/end times""" + + dateTime: Optional[str] = Field(None, description="RFC3339 timestamp with timezone") + date: Optional[str] = Field(None, description="Date in YYYY-MM-DD format for all-day events") + timeZone: Optional[str] = Field(None, description="IANA timezone identifier") + + @model_validator(mode='after') + def validate_datetime_or_date(self) -> 'EventDateTime': + """Ensure either dateTime or date is provided, but not both""" + has_datetime = self.dateTime is not None + has_date = self.date is not None + + if has_datetime and has_date: + raise ValueError("Cannot specify both dateTime and date - use either dateTime for timed events or date for all-day events") + + if not has_datetime and not has_date: + raise ValueError("Must specify either dateTime or date - dateTime for timed events, date for all-day events") + + return self + + @field_validator('timeZone') + @classmethod + def validate_timezone(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + try: + from dateutil.tz import gettz + if gettz(v) is None: + raise ValueError("Invalid timeZone; must be a valid IANA timezone name") + except Exception: + # If dateutil is unavailable or another error occurs + raise ValueError("Invalid timeZone; validation failed") + return v + + + +# Conference Data Enums +class ConferenceSolutionType(str, Enum): + """Conference solution types according to Google Calendar API""" + HANGOUTS_MEET = "hangoutsMeet" + ADD_ON = "addOn" + + +class EntryPointType(str, Enum): + """Conference entry point types""" + VIDEO = "video" + PHONE = "phone" + SIP = "sip" + MORE = "more" + + +class CreateRequestStatusCode(str, Enum): + """Conference create request status codes""" + PENDING = "pending" + SUCCESS = "success" + FAILURE = "failure" + +class ResponseStatusEnum(str, Enum): + needsAction = "needsAction" + declined = "declined" + tentative = "tentative" + accepted = "accepted" + + +# Conference Data Models +class ConferenceSolutionKey(BaseModel): + """Conference solution key with type validation""" + + type: Optional[ConferenceSolutionType] = Field(None, description="Conference solution type") + + +class ConferenceSolution(BaseModel): + """Conference solution details""" + + iconUri: Optional[str] = Field(None, description="User-visible icon for this solution") + key: Optional[ConferenceSolutionKey] = Field(None, description="Key which uniquely identifies the conference solution") + name: Optional[str] = Field(None, description="User-visible name of this solution (not localized)") + + +class CreateRequestStatus(BaseModel): + """Status of conference create request""" + + statusCode: CreateRequestStatusCode = Field(..., description="Current status of the conference create request") + + +class CreateRequest(BaseModel): + """Request to generate a new conference and attach it to the event""" + + conferenceSolutionKey: Optional[ConferenceSolutionKey] = Field(None, description="Conference solution such as Hangouts or Google Meet") + requestId: Optional[str] = Field(None, description="Client-generated unique ID for this request") + status: Optional[CreateRequestStatus] = Field(None, description="Status of the conference create request") + + +class EntryPoint(BaseModel): + """Individual conference entry point (URLs or phone numbers)""" + + entryPointType: EntryPointType = Field(..., description="Type of the conference entry point") + uri: str = Field(..., max_length=1300, description="URI of the entry point") + + # Optional access credentials (only populate subset based on provider terminology) + accessCode: Optional[str] = Field(None, max_length=128, description="Access code to access the conference") + meetingCode: Optional[str] = Field(None, max_length=128, description="Meeting code to access the conference") + passcode: Optional[str] = Field(None, max_length=128, description="Passcode to access the conference") + password: Optional[str] = Field(None, max_length=128, description="Password to access the conference") + pin: Optional[str] = Field(None, max_length=128, description="PIN to access the conference") + + # Optional display properties + label: Optional[str] = Field(None, max_length=512, description="Label for the URI, visible to end users") + + @field_validator('uri') + @classmethod + def validate_uri(cls, v: str, info) -> str: + """Validate URI format based on entry point type""" + if not v: + raise ValueError("URI is required") + + # Get the entry point type from the model context + entry_point_type = info.data.get('entryPointType') + + parsed = urlparse(v) + + if entry_point_type == EntryPointType.VIDEO: + if parsed.scheme not in ['http', 'https']: + raise ValueError("Video entry point URI must use http or https schema") + elif entry_point_type == EntryPointType.PHONE: + if parsed.scheme != 'tel': + raise ValueError("Phone entry point URI must use tel schema") + elif entry_point_type == EntryPointType.SIP: + if parsed.scheme != 'sip': + raise ValueError("SIP entry point URI must use sip schema") + elif entry_point_type == EntryPointType.MORE: + if parsed.scheme not in ['http', 'https']: + raise ValueError("More entry point URI must use http or https schema") + + return v + + +class ConferenceData(BaseModel): + """Conference-related information such as Google Meet details""" + + # Basic properties + conferenceId: Optional[str] = Field(None, description="ID of the conference") + signature: Optional[str] = Field(None, description="Signature of the conference data") + notes: Optional[str] = Field(None, max_length=2048, description="Additional notes to display to user") + + # Either conferenceSolution + entryPoints OR createRequest is required + conferenceSolution: Optional[ConferenceSolution] = Field(None, description="Conference solution such as Google Meet") + entryPoints: Optional[List[EntryPoint]] = Field(None, description="Conference entry points such as URLs or phone numbers") + createRequest: Optional[CreateRequest] = Field(None, description="Request to generate a new conference") + + @model_validator(mode='after') + def validate_conference_data(self) -> 'ConferenceData': + """Validate that either (conferenceSolution + entryPoints) or createRequest is provided""" + has_solution = self.conferenceSolution is not None + has_entry_points = self.entryPoints is not None and len(self.entryPoints) > 0 + has_create_request = self.createRequest is not None + + if has_create_request: + # If createRequest is provided, conferenceSolution and entryPoints should not be set + if has_solution or has_entry_points: + raise ValueError("Cannot specify both createRequest and conferenceSolution/entryPoints") + else: + # If no createRequest, must have conferenceSolution and at least one entryPoint + if not (has_solution and has_entry_points): + raise ValueError("Must specify either createRequest OR both conferenceSolution and at least one entryPoint") + + return self + + @field_validator('entryPoints') + @classmethod + def validate_entry_points(cls, v: Optional[List[EntryPoint]]) -> Optional[List[EntryPoint]]: + """Validate entry point constraints""" + if not v: + return v + + # Count entry points by type + type_counts = {} + for entry_point in v: + entry_type = entry_point.entryPointType + type_counts[entry_type] = type_counts.get(entry_type, 0) + 1 + + # Validate type constraints + if type_counts.get(EntryPointType.VIDEO, 0) > 1: + raise ValueError("Conference can have at most one video entry point") + if type_counts.get(EntryPointType.SIP, 0) > 1: + raise ValueError("Conference can have at most one SIP entry point") + if type_counts.get(EntryPointType.MORE, 0) > 1: + raise ValueError("Conference can have at most one more entry point") + + # A conference with only a 'more' entry point is not valid + if len(v) == 1 and v[0].entryPointType == EntryPointType.MORE: + raise ValueError("Conference with only a 'more' entry point is not valid") + + return v + + +class AttendeeRequest(BaseModel): + """Attendee model for import requests""" + + email: str = Field(..., description="Attendee email address") + displayName: Optional[str] = Field(None, description="Attendee display name") + optional: Optional[bool] = Field(False, description="Whether attendee is optional") + resource: Optional[bool] = Field(False, description="Whether attendee is a resource") + responseStatus: ResponseStatusEnum = Field("needsAction", description="Response status") + comment: Optional[str] = Field(None, description="Attendee comment") + additionalGuests: Optional[int] = Field(0, description="Number of additional guests") + + +class EventAttachment(BaseModel): + """Event attachment model""" + + fileUrl: str = Field(..., description="URL of the attached file") + title: Optional[str] = Field(None, description="Attachment title") + mimeType: Optional[str] = Field(None, description="MIME type of the attachment") + iconLink: Optional[str] = Field(None, description="URL to attachment icon") + fileId: Optional[str] = Field(None, description="ID of the attached file") + + @field_validator('fileUrl') + @classmethod + def _validate_fileUrl(cls, v: str) -> str: + if v is None: + return None + s = str(v).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'url' in source: must be an https or http URL") + return v + + +class EventReminder(BaseModel): + """Event reminder model""" + + method: str = Field(..., description="Reminder method: email or popup") + minutes: int = Field(..., gt=0, lt=40320, description="Minutes before event to remind") + + @validator('method') + def validate_method(cls, v): + if v not in ['email', 'popup']: + raise ValueError("Method must be either 'email' or 'popup'") + return v + + @validator('minutes') + def validate_minutes(cls, v): + if v < 0: + raise ValueError("Please enter the non-negative value for minutes") + return v + +class EventReminders(BaseModel): + """Event reminders configuration""" + + useDefault: bool = Field(True, description="Whether to use default reminders") + overrides: Optional[List[EventReminder]] = Field(None, description="Custom reminder overrides") + + +class EventSource(BaseModel): + """Event source information""" + + url: str = Field(..., description="Source URL") + title: str = Field(..., description="Source title") + + +class ExtendedProperties(BaseModel): + """Extended properties for events""" + + private: Optional[Dict[str, str]] = Field(None, description="Private extended properties") + shared: Optional[Dict[str, str]] = Field(None, description="Shared extended properties") + + +class WorkingLocationProperties(BaseModel): + """Working location properties for events""" + + type: str = Field(..., description="Working location type") + homeOffice: Optional[bool] = Field(None, description="Whether working from home") + customLocation: Optional[Dict[str, Any]] = Field(None, description="Custom location details") + officeLocation: Optional[Dict[str, Any]] = Field(None, description="Office location details") + + @validator('type') + def validate_type(cls, v): + valid_types = ['homeOffice', 'officeLocation', 'customLocation'] + if v not in valid_types: + raise ValueError(f"Type must be one of: {valid_types}") + return v + +class FocusTimeProperties(BaseModel): + """Focus time properties for focus time events""" + autoDeclineMode: Optional[Literal["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"]] = Field( + "", + description="Whether to decline meeting invitations which overlap Focus Time events. Valid values are declineNone, declineAllConflictingInvitations, and declineOnlyNewConflictingInvitations" + ) + chatStatus: Optional[Literal["available", "doNotDisturb"]] = Field( + "", + description="The status to mark the user in Chat and related products. This can be available or doNotDisturb" + ) + declineMessage: Optional[str] = Field( + "", + description="Response message to set if an existing event or new invitation is automatically declined by Calendar" + ) + + +class EventOrganizerImport(BaseModel): + """Event organizer model for import requests""" + + email: str = Field(..., description="The organizer's email address. Must be a valid email address as per RFC5322.") + displayName: Optional[str] = Field(None, description="The organizer's name, if available.") + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email format according to RFC5322""" + if not v: + raise ValueError("Organizer email is required") + + # Basic RFC5322 email validation pattern + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(pattern, v): + raise ValueError("Invalid email format. Must be a valid email address as per RFC5322.") + + return v + +class OutOfOfficeProperties(BaseModel): + """Out of office properties for outOfOfficeProperties events""" + autoDeclineMode: Optional[Literal["declineNone", "declineAllConflictingInvitations", "declineOnlyNewConflictingInvitations"]] = Field( + "", + description="Whether to decline meeting invitations which overlap Focus Time events. Valid values are declineNone, declineAllConflictingInvitations, and declineOnlyNewConflictingInvitations" + ) + declineMessage: Optional[str] = Field( + "", + description="Response message to set if an existing event or new invitation is automatically declined by Calendar" + ) + + +class EventImportRequest(BaseModel): + """Request model for importing an event""" + + # Required fields + start: EventDateTime = Field(..., description="Event start time") + end: EventDateTime = Field(..., description="Event end time") + iCalUID: str = Field(None, description="iCalendar UID") + + # Optional fields + summary: Optional[str] = Field(None, description="Event title/summary") + description: Optional[str] = Field(None, description="Event description") + location: Optional[str] = Field(None, description="Event location") + colorId: Optional[str] = Field(None, description="Event color ID") + status: Optional[str] = Field("confirmed", description="Event status") + transparency: Optional[str] = Field("opaque", description="Event transparency") + visibility: Optional[str] = Field("default", description="Event visibility") + + # Organizer (writable only when importing) + organizer: Optional[EventOrganizerImport] = Field(None, description="The organizer of the event. If the organizer is also an attendee, this is indicated with a separate entry in attendees with the organizer field set to True. To change the organizer, use the move operation. Read-only, except when importing an event.") + + + # Attendees and guests + attendees: Optional[List[AttendeeRequest]] = Field(None, description="Event attendees") + attendeesOmitted: Optional[bool] = Field(False, description="Whether attendees may have been omitted from the event's representation") + guestsCanInviteOthers: Optional[bool] = Field(True, description="Can guests invite others") + guestsCanModify: Optional[bool] = Field(False, description="Can guests modify event") + guestsCanSeeOtherGuests: Optional[bool] = Field(True, description="Can guests see other guests") + + # Gadget + gadget: Optional[Dict[str, Any]] = Field(None, description="Gadget extension") + + # Recurring events + recurrence: Optional[List[str]] = Field(None, description="List of RRULE, EXRULE, RDATE and EXDATE") + recurringEventId: Optional[str] = Field(None, description="Recurring event ID") + originalStartTime: Optional[EventDateTime] = Field(None, description="Original start time for recurring events") + + # Additional properties + reminders: Optional[EventReminders] = Field(None, description="Event reminders") + attachments: Optional[List[EventAttachment]] = Field(None, description="Event attachments") + conferenceData: Optional[ConferenceData] = Field(None, description="Conference data") + source: EventSource = Field(None, description="Event source information") + extendedProperties: Optional[ExtendedProperties] = Field(None, description="Extended properties") + + # Focus time properties (for focusTime event type) + focusTimeProperties: Optional[FocusTimeProperties] = Field( + None, description="Focus time properties" + ) + + # Out of office properties (for outOfOffice event type) + outOfOfficeProperties: Optional[OutOfOfficeProperties] = Field( + None, description="Out of office properties" + ) + + sequence: int = Field(None, description="iCalendar sequence number") + + @field_validator("iCalUID") + @classmethod + def _validate_iCalUID(cls, v: Optional[str]) -> Optional[str]: + pattern = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(pattern, v): + raise ValueError("Invalid iCalUID format. Expected something like 'abcd123@google.com'.") + return v + + @validator('status') + def validate_status(cls, v): + valid_statuses = ['confirmed', 'tentative', 'cancelled'] + if v not in valid_statuses: + raise ValueError(f"Status must be one of: {valid_statuses}") + return v + + @validator('transparency') + def validate_transparency(cls, v): + if v not in ['opaque', 'transparent']: + raise ValueError("Transparency must be either 'opaque' or 'transparent'") + return v + + @validator('visibility') + def validate_visibility(cls, v): + valid_visibilities = ['default', 'public', 'private', 'confidential'] + if v not in valid_visibilities: + raise ValueError(f"Visibility must be one of: {valid_visibilities}") + return v + + @field_validator("source") + @classmethod + def _validate_source(cls, v: str) -> str: + if v is None: + return None + s = str(v.url).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'url' in source: must be an https or http URL") + return v + + + +class EventImportQueryParams(BaseModel): + """Query parameters for event import API""" + + conferenceDataVersion: Optional[int] = Field( + None, + ge=0, + le=1, + description="Version number of conference data supported by API client" + ) + supportsAttachments: Optional[bool] = Field( + None, + description="Whether API client supports event attachments" + ) + + +class EventImportResponse(BaseModel): + """Response model for successful event import""" + + kind: str = Field(default="calendar#event", description="Resource type") + id: str = Field(..., description="Event identifier") + etag: str = Field(..., description="ETag for the event") + status: str = Field(..., description="Event status") + htmlLink: str = Field(..., description="Absolute link to event in Google Calendar") + created: str = Field(..., description="Creation time (RFC3339)") + updated: str = Field(..., description="Last modification time (RFC3339)") + summary: str = Field(..., description="Event title") + creator: Dict[str, Any] = Field(..., description="Event creator") + organizer: Dict[str, Any] = Field(..., description="Event organizer") + start: EventDateTime = Field(..., description="Event start time") + end: EventDateTime = Field(..., description="Event end time") + + # Optional response fields + description: Optional[str] = Field(None, description="Event description") + location: Optional[str] = Field(None, description="Event location") + colorId: Optional[str] = Field(None, description="Event color ID") + transparency: Optional[str] = Field(None, description="Event transparency") + visibility: Optional[str] = Field(None, description="Event visibility") + eventType: Optional[str] = Field(None, description="Event type") + attendees: Optional[List[Dict[str, Any]]] = Field(None, description="Event attendees") + recurrence: Optional[List[str]] = Field(None, description="Recurrence rules") + reminders: Optional[Dict[str, Any]] = Field(None, description="Event reminders") + attachments: Optional[List[Dict[str, Any]]] = Field(None, description="Event attachments") + conferenceData: Optional[Dict[str, Any]] = Field(None, description="Conference data") + source: Optional[Dict[str, Any]] = Field(None, description="Event source") + extendedProperties: Optional[Dict[str, Any]] = Field(None, description="Extended properties") + workingLocationProperties: Optional[Dict[str, Any]] = Field(None, description="Working location") + focusTimeProperties: Optional[Dict[str, Any]] = Field(None, description="Focus time properties") + outOfOfficeProperties: Optional[Dict[str, Any]] = Field(None, description="Out of office properties") + + # Guest permissions + guestsCanInviteOthers: Optional[bool] = Field(None, description="Whether guests can invite others") + guestsCanModify: Optional[bool] = Field(None, description="Whether guests can modify event") + guestsCanSeeOtherGuests: Optional[bool] = Field(None, description="Whether guests can see other guests") + + # iCalendar properties + iCalUID: Optional[str] = Field(None, description="iCalendar UID") + sequence: Optional[int] = Field(None, description="iCalendar sequence number") + + +class EventImportError(BaseModel): + """Error model for event import failures""" + + domain: str = Field(..., description="Error domain") + reason: str = Field(..., description="Error reason") + message: str = Field(..., description="Error message") + locationType: Optional[str] = Field(None, description="Location type causing error") + location: Optional[str] = Field(None, description="Location causing error") + + +class EventImportValidation(BaseModel): + """Internal model for event import validation""" + + calendar_id: str = Field(..., description="Target calendar ID") + user_id: str = Field(..., description="User importing the event") + event_data: EventImportRequest = Field(..., description="Event data to import") + preserve_event_id: bool = Field(False, description="Whether to preserve original event ID") + generate_new_id: bool = Field(True, description="Whether to generate new event ID") + + def validate_calendar_access(self) -> bool: + """Validate user has access to target calendar""" + # This would be implemented with actual calendar access checks + return True + + def validate_event_conflicts(self) -> bool: + """Validate no conflicts with existing events""" + # This would check for time conflicts, duplicate IDs, etc. + return True + + def validate_import_permissions(self) -> bool: + """Validate user has import permissions""" + return True + + +class EventImportResult(BaseModel): + """Internal model for event import operation result""" + + success: bool = Field(..., description="Whether import was successful") + event_id: Optional[str] = Field(None, description="ID of imported event") + original_event_type: Optional[str] = Field(None, description="Original event type before conversion") + converted_to_default: bool = Field(False, description="Whether event was converted to default type") + properties_dropped: Optional[List[str]] = Field(None, description="Properties dropped during conversion") + warnings: Optional[List[str]] = Field(None, description="Import warnings") + error: Optional[EventImportError] = Field(None, description="Import error if failed") \ No newline at end of file diff --git a/envs/calendar_env/server/schemas/settings.py b/envs/calendar_env/server/schemas/settings.py new file mode 100644 index 000000000..5a5954d17 --- /dev/null +++ b/envs/calendar_env/server/schemas/settings.py @@ -0,0 +1,89 @@ +""" +Settings models for Calender Settings API following Google Calendar API v3 structure +""" + +from typing import Optional, List, Dict +from pydantic import BaseModel, Field, field_validator, Extra +from urllib.parse import urlparse + +class SettingItem(BaseModel): + """Single settings resource (Google Calendar API v3 style)""" + model_config = {"from_attributes": True} + + etag: Optional[str] = Field(None, description="ETag of the settings") + id: str = Field(..., description="ID of the settings (e.g., timezone)") + value: str = Field(..., description="Value of the settings") + user_id: str = Field(..., description="User ID to whom this setting belongs") + + +class SettingsListResponse(BaseModel): + """Paginated list of settings (Google Calendar API v3 style)""" + + etag: Optional[str] = Field(None, description="ETag of the collection") + items: List[SettingItem] = Field(default_factory=list, description="List of settings resources") + nextPageToken: Optional[str] = Field(None, description="Token for next page if pagination is supported") + + +class Channel(BaseModel): + """Channel model for watch notifications""" + + kind: str = Field(default="api#channel", description="Resource type identifier") + id: str = Field(..., description="Channel identifier") + resourceId: Optional[str] = Field(None, description="Resource ID") + resourceUri: Optional[str] = Field(None, description="Resource URI") + token: Optional[str] = Field(None, description="Channel token") + expiration: Optional[str] = Field(None, description="Expiration time") + +class WatchParams(BaseModel): + """Watch parameters""" + ttl: Optional[str] = Field(None, description="Time to live (seconds)") + + class Config: + extra = Extra.forbid + + @field_validator("ttl") + @classmethod + def _validate_ttl(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + s = str(v).strip() + if not s.isdigit(): + raise ValueError("params.ttl must be an integer string representing seconds") + if int(s) <= 0: + raise ValueError("params.ttl must be greater than 0") + return s + + +class SettingsWatchRequest(BaseModel): + """Request model for watching settings changes""" + + id: str = Field(..., description="Channel identifier") + type: str = Field(description="Channel type") + address: str = Field(..., description="Webhook address") + token: Optional[str] = Field(None, description="Channel token") + params: Optional[WatchParams] = Field(None, description="Optional parameters object; supports 'ttl' as string seconds per Google spec") + + @field_validator("type") + @classmethod + def _validate_type(cls, v: str) -> str: + if v is None: + raise ValueError("type is required") + s = str(v).strip().lower() + if s not in ("web_hook", "webhook"): + raise ValueError("Only channel type 'web_hook' or 'webhook' is supported") + # Normalize to canonical 'web_hook' + return "web_hook" + + @field_validator("address") + @classmethod + def _validate_address(cls, v: str) -> str: + if v is None: + raise ValueError("address is required") + s = str(v).strip() + parsed = urlparse(s) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("Invalid 'address': must be an https URL") + return s + + class Config: + extra = "ignore" diff --git a/envs/calendar_env/server/services/__init__.py b/envs/calendar_env/server/services/__init__.py new file mode 100644 index 000000000..989d37d7b --- /dev/null +++ b/envs/calendar_env/server/services/__init__.py @@ -0,0 +1,10 @@ +""" +Services package for calendar application +""" + +from .notification_service import NotificationService, get_notification_service + +__all__ = [ + "NotificationService", + "get_notification_service" +] \ No newline at end of file diff --git a/envs/calendar_env/server/services/notification_service.py b/envs/calendar_env/server/services/notification_service.py new file mode 100644 index 000000000..90211d677 --- /dev/null +++ b/envs/calendar_env/server/services/notification_service.py @@ -0,0 +1,272 @@ +""" +Webhook notification service for calendar watch channels +""" + +import json +import logging +import requests +from datetime import datetime +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from database.models.watch_channel import WatchChannel + +logger = logging.getLogger(__name__) + + +class NotificationService: + """Service for sending webhook notifications to watch channels""" + + def __init__(self, db: Session): + self.db = db + + def send_notification( + self, + channel_id: str, + event_type: str, + resource_data: Dict[str, Any], + resource_state: str = "sync" + ) -> bool: + """ + Send a notification to a specific watch channel + + Args: + channel_id: The watch channel ID + event_type: Type of event (e.g., "sync", "exists", "not_exists") + resource_data: The actual resource data that changed + resource_state: State of the resource + + Returns: + True if notification was sent successfully, False otherwise + """ + try: + # Get the watch channel from database + channel = self.db.query(WatchChannel).filter( + WatchChannel.id == channel_id, + WatchChannel.is_active == "true" + ).first() + + if not channel: + logger.warning(f"Watch channel {channel_id} not found or inactive") + return False + + # Check if channel has expired + if channel.is_expired(): + logger.info(f"Watch channel {channel_id} has expired") + self._deactivate_channel(channel) + return False + + # Prepare notification payload + notification_payload = { + "kind": "api#channel", + "id": channel.id, + "resourceId": channel.resource_id, + "resourceUri": channel.resource_uri, + "eventType": event_type, + "resourceState": resource_state, + "timestamp": datetime.utcnow().isoformat() + "Z", + "data": resource_data + } + + # Prepare headers + headers = { + "Content-Type": "application/json", + "User-Agent": "Calendar-Notifications/1.0" + } + + # Add token to headers if present + if channel.webhook_token: + headers["X-Goog-Channel-Token"] = channel.webhook_token + + # Send the webhook notification + response = requests.post( + channel.webhook_address, + json=notification_payload, + headers=headers, + timeout=30 + ) + + # Check if notification was successful + if response.status_code in [200, 201, 202, 204]: + # Update channel statistics + self._update_channel_stats(channel) + logger.info(f"Notification sent successfully to {channel.webhook_address}") + return True + else: + logger.error(f"Webhook failed with status {response.status_code}: {response.text}") + return False + + except requests.exceptions.Timeout: + logger.error(f"Webhook timeout for channel {channel_id}") + return False + except requests.exceptions.RequestException as e: + logger.error(f"Webhook request failed for channel {channel_id}: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error sending notification: {e}") + return False + + def notify_acl_change( + self, + calendar_id: str, + change_type: str, + acl_rule_data: Dict[str, Any] + ) -> int: + """ + Send ACL change notifications to all active watch channels for a calendar + + Args: + calendar_id: The calendar ID + change_type: Type of change ("insert", "update", "delete") + acl_rule_data: The ACL rule data + + Returns: + Number of successful notifications sent + """ + try: + # Find all active watch channels for this calendar's ACL + channels = self.db.query(WatchChannel).filter( + WatchChannel.calendar_id == calendar_id, + WatchChannel.resource_type == "acl", + WatchChannel.is_active == "true" + ).all() + if not channels: + logger.debug(f"No active watch channels found for calendar {calendar_id}") + return 0 + + successful_notifications = 0 + + for channel in channels: + # Check if channel has expired + if channel.is_expired(): + self._deactivate_channel(channel) + continue + + # Send notification + success = self.send_notification( + channel.id, + change_type, + acl_rule_data, + "sync" + ) + + if success: + successful_notifications += 1 + + logger.info(f"Sent {successful_notifications} ACL change notifications for calendar {calendar_id}") + return successful_notifications + + except Exception as e: + logger.error(f"Error sending ACL change notifications: {e}") + return 0 + + def notify_settings_change( + self, + user_id: str, + change_type: str, + setting_data: Dict[str, Any] + ) -> int: + """ + Send settings change notifications to all active watch channels for a user + + Args: + user_id: The user ID whose settings changed + change_type: Type of change ("insert", "update", "delete") + setting_data: The setting data + + Returns: + Number of successful notifications sent + """ + try: + # Find all active watch channels for this user's settings + channels = self.db.query(WatchChannel).filter( + WatchChannel.user_id == user_id, + WatchChannel.resource_type == "settings", + WatchChannel.is_active == "true" + ).all() + + if not channels: + logger.debug(f"No active settings watch channels found for user {user_id}") + return 0 + + successful_notifications = 0 + + for channel in channels: + # Check if channel has expired + if channel.is_expired(): + self._deactivate_channel(channel) + continue + + # Send notification + success = self.send_notification( + channel.id, + change_type, + setting_data, + "sync" + ) + + if success: + successful_notifications += 1 + + logger.info(f"Sent {successful_notifications} settings change notifications for user {user_id}") + return successful_notifications + + except Exception as e: + logger.error(f"Error sending settings change notifications: {e}") + return 0 + + def cleanup_expired_channels(self) -> int: + """ + Clean up expired watch channels + + Returns: + Number of channels cleaned up + """ + try: + current_time = datetime.utcnow() + + # Find expired channels + expired_channels = self.db.query(WatchChannel).filter( + WatchChannel.expires_at < current_time, + WatchChannel.is_active == "true" + ).all() + + cleanup_count = 0 + for channel in expired_channels: + self._deactivate_channel(channel) + cleanup_count += 1 + + if cleanup_count > 0: + self.db.commit() + logger.info(f"Cleaned up {cleanup_count} expired watch channels") + + return cleanup_count + + except Exception as e: + logger.error(f"Error cleaning up expired channels: {e}") + self.db.rollback() + return 0 + + def _update_channel_stats(self, channel: WatchChannel): + """Update channel statistics after successful notification""" + try: + channel.last_notification_at = datetime.utcnow() + channel.notification_count += 1 + self.db.commit() + except Exception as e: + logger.error(f"Error updating channel stats: {e}") + self.db.rollback() + + def _deactivate_channel(self, channel: WatchChannel): + """Deactivate a watch channel""" + try: + channel.is_active = "false" + self.db.commit() + logger.info(f"Deactivated watch channel {channel.id}") + except Exception as e: + logger.error(f"Error deactivating channel {channel.id}: {e}") + self.db.rollback() + + +def get_notification_service(db: Session) -> NotificationService: + """Factory function to get notification service instance""" + return NotificationService(db) \ No newline at end of file diff --git a/envs/calendar_env/server/tests/conftest.py b/envs/calendar_env/server/tests/conftest.py new file mode 100644 index 000000000..d2c763ee0 --- /dev/null +++ b/envs/calendar_env/server/tests/conftest.py @@ -0,0 +1,135 @@ +""" +Pytest configuration and shared fixtures for Calendar API tests +""" + +import sys +import uuid +from pathlib import Path +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from apis.mcp.router import router as mcp_router +from apis.calendars.router import router as calendars_router +from apis.calendarList.router import router as calendar_list_router +from data.multi_user_sample import get_multi_user_sql + +# Add parent directory to path to allow imports +parent_dir = Path(__file__).parent.parent +sys.path.insert(0, str(parent_dir)) + + +@pytest.fixture +def app(): + """Create a FastAPI app with the MCP router for testing""" + app = FastAPI() + app.include_router(mcp_router) + return app + + +@pytest.fixture +def client(app): + """Create a test client""" + return TestClient(app) + + +@pytest.fixture +def calendar_app(): + """Create a FastAPI app with the Calendar router for testing""" + app = FastAPI() + app.include_router(calendars_router) + return app + + +@pytest.fixture +def calendar_client(calendar_app): + """Create a test client for calendar endpoints""" + return TestClient(calendar_app) + + +@pytest.fixture +def calendar_list_app(): + """Create a FastAPI app with the CalendarList router for testing""" + app = FastAPI() + app.include_router(calendar_list_router) + return app + + +@pytest.fixture +def calendar_list_client(calendar_list_app): + """Create a test client for calendarList endpoints""" + return TestClient(calendar_list_app) + + +@pytest.fixture +def full_app(): + """Create a complete FastAPI app with all routers (like main.py)""" + from main import app + return app + + +@pytest.fixture +def full_client(full_app): + """Create a test client for the complete application""" + return TestClient(full_app) + + +@pytest.fixture(scope="function") +def test_database_id(): + """Generate unique test database ID for each test""" + return f"test_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture(scope="function") +def seeded_database(full_client, test_database_id): + """Setup a seeded test database""" + # Get sample SQL data + sql_content = get_multi_user_sql() + + # Seed the database + seed_response = full_client.post( + "/api/seed-database", + json={ + "database_id": test_database_id, + "sql_content": sql_content + } + ) + + assert seed_response.status_code == 200, f"Failed to seed database: {seed_response.text}" + + return { + "client": full_client, + "database_id": test_database_id, + "users": { + "alice": "alice_manager", + "bob": "bob_developer", + "carol": "carol_designer", + "dave": "dave_sales" + } + } + + +@pytest.fixture +def mcp_request_helper(): + """Helper function to create MCP requests""" + def create_mcp_request(method_name, arguments, request_id=1): + return { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": method_name, + "arguments": arguments + } + } + return create_mcp_request + + +@pytest.fixture +def api_headers(): + """Helper function to create API headers""" + def create_headers(database_id, user_id): + return { + "x-database-id": database_id, + "x-user-id": user_id + } + return create_headers diff --git a/envs/calendar_env/server/tests/test_calenderList_router.py b/envs/calendar_env/server/tests/test_calenderList_router.py new file mode 100644 index 000000000..3add3593d --- /dev/null +++ b/envs/calendar_env/server/tests/test_calenderList_router.py @@ -0,0 +1,287 @@ +#Integration tests using seed-database API + +import pytest +import json +from unittest.mock import patch + +class TestDatabaseSeeding: + """Verify database seeding works correctly""" + + def test_seed_database_success(self, seeded_database): + """Test that database seeding creates all required tables""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + + response = client.get( + "/api/database-state", + headers={"x-database-id": database_id} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify expected tables with data + expected_tables = ["users", "calendars", "events", "acls", "scopes"] + for table in expected_tables: + assert table in data["table_counts"] + assert data["table_counts"][table] > 0 + + +class TestMCPFlow: + """Test MCP protocol flow: Swagger → MCP → Calendar APIs → Database""" + + def test_mcp_vs_direct_api_returns_same_data(self, seeded_database, mcp_request_helper, api_headers): + """Verify MCP flow returns (same results as direct API call)""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # Direct API call (what Swagger does) + direct_response = client.get( + "/users/me/calendarList?maxResults=100&showDeleted=false&showHidden=false", + headers=api_headers(database_id, user_id) + ) + assert direct_response.status_code == 200 + direct_data = direct_response.json() + + # trying to make actual HTTP requests + # verify the direct API works correctly + assert len(direct_data["items"]) > 0 + assert "kind" in direct_data + assert direct_data["kind"] == "calendar#calendarList" + + def test_mcp_get_calendar(self, seeded_database, mcp_request_helper, api_headers): + """Test MCP flow for getting specific calendar""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # Get a calendar ID first + list_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, user_id) + ) + calendar_id = list_response.json()["items"][0]["id"] + + # Get the calendar details directly + direct_calendar_response = client.get( + f"/calendars/{calendar_id}", + headers=api_headers(database_id, user_id) + ) + calendar_data_direct = direct_calendar_response.json() + + # Trying to make actual HTTP requests + # Verify the direct API works correctly + assert calendar_data_direct["id"] == calendar_id + assert "summary" in calendar_data_direct + + +class TestCalendarAPIs: + """Test Calendar CRUD operations with real database""" + + def test_calendar_crud_operations(self, seeded_database, api_headers): + """Test create, read, update, delete calendar""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # CREATE + create_response = client.post( + "/calendars", + json={"summary": "Test Calendar", "timeZone": "UTC"}, + headers=api_headers(database_id, user_id) + ) + assert create_response.status_code == 201 + calendar_id = create_response.json()["id"] + + # READ + get_response = client.get( + f"/calendars/{calendar_id}", + headers=api_headers(database_id, user_id) + ) + assert get_response.status_code == 200 + assert get_response.json()["summary"] == "Test Calendar" + + # UPDATE + update_response = client.patch( + f"/calendars/{calendar_id}", + json={"summary": "Updated Calendar"}, + headers=api_headers(database_id, user_id) + ) + assert update_response.status_code == 200 + assert update_response.json()["summary"] == "Updated Calendar" + + # DELETE + delete_response = client.delete( + f"/calendars/{calendar_id}", + headers=api_headers(database_id, user_id) + ) + assert delete_response.status_code == 200 + + +class TestCalendarListAPIs: + """Test CalendarList operations with real database""" + + def test_get_calendar_list_success(self, seeded_database, api_headers): + """Test getting calendar list with real seeded data""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, user_id) + ) + + assert response.status_code == 200 + data = response.json() + assert data["kind"] == "calendar#calendarList" + assert len(data["items"]) > 0 + + # Verify Alice's calendars are present + summaries = [item["summary"] for item in data["items"]] + assert any("Alice" in summary for summary in summaries) + + def test_get_primary_calendar(self, seeded_database, api_headers): + """Test getting primary calendar using 'primary' keyword""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + response = client.get( + "/users/me/calendarList/primary", + headers=api_headers(database_id, user_id) + ) + + assert response.status_code == 200 + assert response.json()["primary"] is True + + def test_update_calendar_in_list(self, seeded_database, api_headers): + """Test updating calendar list entry""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # Get writable calendar + list_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, user_id) + ) + calendars = list_response.json()["items"] + writable_cal = next(c for c in calendars if c["accessRole"] in ["writer", "owner"]) + + # Update settings + response = client.patch( + f"/users/me/calendarList/{writable_cal['id']}", + json={"hidden": True, "selected": False}, + headers=api_headers(database_id, user_id) + ) + + assert response.status_code == 200 + assert response.json()["hidden"] is True + + +class TestEventAPIs: + """Test Event operations with real database""" + + def test_create_and_get_event(self, seeded_database, api_headers): + """Test creating and retrieving event""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # Get calendar + list_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, user_id) + ) + calendar_id = list_response.json()["items"][0]["id"] + + # Create event + event_data = { + "summary": "Test Event", + "description": "Event created during integration test", + "start": { + "dateTime": "2024-12-01T10:00:00Z", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2024-12-01T11:00:00Z", + "timeZone": "UTC" + } + } + + create_response = client.post( + f"/calendars/{calendar_id}/events", + json=event_data, + headers=api_headers(database_id, user_id) + ) + + assert create_response.status_code == 201 + event_id = create_response.json()["id"] + + # Get event + get_response = client.get( + f"/calendars/{calendar_id}/events/{event_id}", + headers=api_headers(database_id, user_id) + ) + + assert get_response.status_code == 200 + assert get_response.json()["summary"] == "Test Event" + + +class TestACLPermissions: + """Test ACL permissions with real database""" + + def test_owner_has_acl_permissions(self, seeded_database, api_headers): + """Test that calendar owner has proper ACL entries""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + user_id = seeded_database["users"]["alice"] + + # Get owner calendar + list_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, user_id) + ) + calendars = list_response.json()["items"] + owner_cal = next(c for c in calendars if c.get("accessRole") == "owner") + + # Get ACL list + acl_response = client.get( + f"/calendars/{owner_cal['id']}/acl", + headers=api_headers(database_id, user_id) + ) + + assert acl_response.status_code == 200 + acls = acl_response.json() + # ACL response should be a dict with 'items' containing the list of rules + assert isinstance(acls, dict) + assert 'items' in acls + assert isinstance(acls['items'], list) + assert any(acl.get("role") == "owner" for acl in acls["items"]) + + def test_cross_user_isolation(self, seeded_database, api_headers): + """Test that users can only see their own calendars""" + client = seeded_database["client"] + database_id = seeded_database["database_id"] + + # Get Alice's calendars + alice_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, seeded_database["users"]["alice"]) + ) + + # Get Bob's calendars + bob_response = client.get( + "/users/me/calendarList", + headers=api_headers(database_id, seeded_database["users"]["bob"]) + ) + + assert alice_response.status_code == 200 + assert bob_response.status_code == 200 + + # Each user should have their own calendars + assert len(alice_response.json()["items"]) > 0 + assert len(bob_response.json()["items"]) > 0 diff --git a/envs/calendar_env/server/tests_tools/__init__.py b/envs/calendar_env/server/tests_tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/envs/calendar_env/server/tests_tools/conftest.py b/envs/calendar_env/server/tests_tools/conftest.py new file mode 100644 index 000000000..9aceeee97 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/conftest.py @@ -0,0 +1,122 @@ +# calendar/tests_tools/conftest.py +import importlib +import importlib.util +import os +import sqlite3 +import types +from pathlib import Path +import pytest + +# ---- How we load the tools registry ---- +TOOLS_MODULE_ENV = "CAL_TOOLS_MODULE" # optional +TOOLS_FILE_ENV = "CAL_TOOLS_PATH" # preferred for our adapter + +def _import_module_from_path(path: str, name: str = "calendar_tools_adapter") -> types.ModuleType: + p = Path(path).expanduser().resolve() + if not p.exists(): + raise RuntimeError(f"{TOOLS_FILE_ENV} points to missing file: {p}") + spec = importlib.util.spec_from_file_location(name, str(p)) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(mod) + return mod + +@pytest.fixture(scope="session") +def tools_root_module() -> types.ModuleType: + """ + Prefer an explicit adapter module if provided via env vars. + Falls back to the legacy package only if neither env var is set. + """ + file_path = os.getenv(TOOLS_FILE_ENV) + if file_path: + return _import_module_from_path(file_path) + + mod_name = os.getenv(TOOLS_MODULE_ENV) + if mod_name: + return importlib.import_module(mod_name) + + # legacy fallback (not recommended because of stdlib 'calendar' collision) + return importlib.import_module("calendar_mcp.tools") + +@pytest.fixture(scope="session") +def tool_registry(tools_root_module): + """ + Expect the adapter to expose a TOOLS dict {name: handler}. + """ + reg = getattr(tools_root_module, "TOOLS", None) + assert isinstance(reg, dict) and reg, "Adapter did not expose any tools via TOOLS" + print(f"\n[conftest] tool registry size: {len(reg)}") + return reg + +# ---- Convenience fixtures the tests expect ---- +@pytest.fixture(scope="session") +def all_tool_names(tool_registry): + return sorted(tool_registry.keys()) + +@pytest.fixture() +def tool_call(tool_registry): + def _invoke(name: str, params: dict): + handler = tool_registry[name] # let KeyError surface if test asked for wrong name + try: + res = handler(params or {}) + if isinstance(res, tuple) and len(res) == 2 and isinstance(res[0], bool): + return res[0], res[1] + return True, res + except Exception as e: + return False, {"error": str(e)} + return _invoke + +@pytest.fixture() +def pick_tool(all_tool_names): + lowered = [n.lower() for n in all_tool_names] + def _pick(*hints: str) -> str: + for h in hints: + if h in all_tool_names: + return h + for h in hints: + hl = h.lower() + if hl in lowered: + return all_tool_names[lowered.index(hl)] + for h in hints: + hl = h.lower() + for i, n in enumerate(lowered): + if hl in n: + return all_tool_names[i] + raise KeyError(f"No tool matches {hints}. Sample: {all_tool_names[:10]}") + return _pick + +# ---- Minimal DB seed so tools can run ---- +@pytest.fixture(scope="session") +def sample_sql() -> str: + return """ + PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY, email TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS calendars (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, summary TEXT); + CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, calendar_id TEXT NOT NULL, summary TEXT, start TEXT, end TEXT); + INSERT OR IGNORE INTO users(user_id, email) VALUES ('alice_manager','alice.manager@techcorp.com'); + INSERT OR IGNORE INTO calendars(id, user_id, summary) VALUES ('alice-primary','alice_manager','Alice Primary'), + ('alice-projects','alice_manager','Alice Projects'); + INSERT OR IGNORE INTO events(id, calendar_id, summary, start, end) + VALUES ('ev1','alice-projects','Kickoff','2025-10-07T10:00:00Z','2025-10-07T11:00:00Z'); + """ + +@pytest.fixture() +def seeded_db(tmp_path, sample_sql): + db_dir = tmp_path / "mcp_databases" + db_dir.mkdir(exist_ok=True) + dbid = "pytestdb" + db_path = db_dir / f"{dbid}.sqlite" + con = sqlite3.connect(db_path) + try: + con.executescript(sample_sql) + con.commit() + finally: + con.close() + return { + "database_id": dbid, + "user_id": "alice_manager", + "email": "alice.manager@techcorp.com", + "primary_calendar_id": "alice-primary", + "some_calendar_id": "alice-projects", + "db_path": str(db_path), + } diff --git a/envs/calendar_env/server/tests_tools/debug_mcp_request.py b/envs/calendar_env/server/tests_tools/debug_mcp_request.py new file mode 100644 index 000000000..7fbd03d23 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/debug_mcp_request.py @@ -0,0 +1,40 @@ +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler + + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + data = await request.json() + print("\n=== RAW JSON BODY RECEIVED BY FASTAPI ===") + print(data) + result = await mcp_handler.handle_mcp_request(request) + print("\n=== RESULT FROM HANDLER ===") + print(result) + return result + + +if __name__ == "__main__": + client = TestClient(app) + + # Try variant 1: database_id at the same level as "name" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "create_calendar", + "database_id": "test-db-001", + "user_id": "test-user-001", + "arguments": {"summary": "Team Coordination"} + }, + "id": 1 + } + + r = client.post("/mcp", json=payload) + print("\n=== HTTP STATUS:", r.status_code, "===") + print(r.json()) diff --git a/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py b/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py new file mode 100644 index 000000000..abecc9f1c --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py @@ -0,0 +1,406 @@ +""" +End-to-end MCP tests for all Calendar tools via /mcp endpoint. +All tool executions and user lookups are mocked so tests run offline. +Covers: create, get, patch, update, delete, clear. +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app mounting the /mcp route +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Route that forwards requests to the real MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixture: fake UserManager to bypass DB lookups +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + """Bypass real DB access inside UserManager for tests.""" + class DummyUserManager: + def __init__(self, dbid): + self.dbid = dbid + def get_first_user_from_db(self): + return {"id": "test-user-001"} + def get_user_by_access_token(self, token): + return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Fixture: fake execute_tool_generic to simulate tool responses +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Patch execute_tool_generic so each tool returns predictable mock data.""" + # Mock the MCP_TOOLS list to include calendar list tools + from calendar_mcp.tools.calendar_list import CALENDAR_LIST_TOOLS + monkeypatch.setattr(tool_handlers, "MCP_TOOLS", CALENDAR_LIST_TOOLS) + + async def _fake(tool_name, tool_input, database_id, user_id): + import json as json_module + + if tool_name == "create_calendar": + data = {"id": "alice-team", "summary": tool_input.get("summary")} + return {"text": json_module.dumps(data), "isError": False} + if tool_name == "get_calendar": + data = {"id": tool_input.get("calendarId"), "summary": "Alice Johnson"} + return {"text": json_module.dumps(data), "isError": False} + if tool_name == "patch_calendar": + data = {"id": tool_input.get("calendarId"), "summary": tool_input.get("summary", "patched")} + return {"text": json_module.dumps(data), "isError": False} + if tool_name == "update_calendar": + data = {"id": tool_input.get("calendarId"), "summary": "Updated Full Calendar"} + return {"text": json_module.dumps(data), "isError": False} + if tool_name == "replace_calendar_in_list": + calendar_id = tool_input.get("calendarId") + if not calendar_id: + return {"isError": True, "text": "Calendar ID is required"} + + data = { + "kind": "calendar#calendarListEntry", + "etag": "etag-replaced", + "id": calendar_id, + "summary": tool_input.get("summaryOverride", "Replaced Calendar"), + "summaryOverride": tool_input.get("summaryOverride"), + "colorId": tool_input.get("colorId"), + "backgroundColor": tool_input.get("backgroundColor"), + "foregroundColor": tool_input.get("foregroundColor"), + "hidden": tool_input.get("hidden", False), + "selected": tool_input.get("selected", True), + "accessRole": "owner", + "defaultReminders": tool_input.get("defaultReminders", []) + } + return {"text": json_module.dumps(data), "isError": False} + if tool_name in ("delete_calendar", "clear_calendar"): + return {"text": "{}", "isError": False} + if tool_name == "watch_calendar_list": + channel_id = tool_input.get("id") + address = tool_input.get("address") + + if not address: + return {"isError": True, "text": "Webhook address is required"} + + if not channel_id: + return {"isError": True, "text": "Channel ID is required"} + + data = { + "kind": "api#channel", + "id": channel_id, + "resourceId": f"calendar-list-{user_id}", + "resourceUri": "/users/me/calendarList", + "token": tool_input.get("token", ""), + "expiration": "1735689600000", + "type": "web_hook", + "address": address + } + return {"text": json_module.dumps(data), "isError": False} + return {"isError": True, "text": f"Unhandled tool {tool_name}"} + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + + # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic + tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in CALENDAR_LIST_TOOLS} + + # Also patch TOOL_HANDLERS in mcp_handler module since it imports it + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "TOOL_HANDLERS", tool_handlers.TOOL_HANDLERS) + + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Helper to send JSON-RPC calls to /mcp with required headers +# ------------------------------------------------------------------------------ + +def rpc_call(tool_name, arguments, rpc_id=1): + """Send a JSON-RPC call including headers required by MCP handler.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": rpc_id + } + headers = { + "x-database-id": "test-db-001", + "x-access-token": "dummy-static-token" + } + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# Tests for each calendar tool +# ------------------------------------------------------------------------------ + +def test_mcp_create_calendar(): + """ create_calendar → should succeed and return mocked response""" + resp = rpc_call("create_calendar", {"summary": "Team Coordination"}) + assert resp.status_code == 200 + result = resp.json()["result"] + # Validate basic MCP structure + assert "content" in result + text = result["content"][0]["text"] + assert "" in text or "completed" in text + + +def test_mcp_get_calendar(): + """ get_calendar → should succeed""" + resp = rpc_call("get_calendar", {"calendarId": "alice-primary"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_patch_calendar(): + """ patch_calendar → should succeed""" + resp = rpc_call("patch_calendar", {"calendarId": "alice-primary", "summary": "Updated Title"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_update_calendar(): + """ update_calendar → should succeed""" + resp = rpc_call("update_calendar", {"calendarId": "bob-development"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_delete_calendar(): + """ delete_calendar → should succeed""" + resp = rpc_call("delete_calendar", {"calendarId": "carol-design"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_clear_calendar(): + """ clear_calendar → should succeed""" + resp = rpc_call("clear_calendar", {"calendarId": "dave-primary"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_watch_calendar_list_success(): + """watch_calendar_list → should successfully create watch channel (positive)""" + resp = rpc_call("watch_calendar_list", { + "id": "test-watch-channel-001", + "type": "web_hook", + "address": "https://example.com/webhook", + "token": "verification-token-123" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "test-watch-channel-001" in text or "" in text + + +def test_mcp_watch_calendar_list_missing_address(): + """watch_calendar_list → should fail when address is missing (negative)""" + resp = rpc_call("watch_calendar_list", { + "id": "test-watch-channel-002", + "type": "web_hook" + # Missing address + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "address" in text.lower() or "required" in text.lower() + + +def test_mcp_watch_calendar_list_missing_channel_id(): + """watch_calendar_list → should fail when channel ID is missing (negative)""" + resp = rpc_call("watch_calendar_list", { + "type": "web_hook", + "address": "https://example.com/webhook" + # Missing id + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "channel" in text.lower() or "id" in text.lower() or "required" in text.lower() + + +def test_mcp_watch_calendar_list_with_params(): + """watch_calendar_list → should succeed with additional params (positive)""" + resp = rpc_call("watch_calendar_list", { + "id": "test-watch-channel-003", + "type": "web_hook", + "address": "https://example.com/webhook", + "token": "verification-token-456", + "params": { + "ttl": "3600" + } + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "test-watch-channel-003" in text or "" in text + + +def test_mcp_watch_calendar_list_minimal(): + """watch_calendar_list → should succeed with minimal required params (positive)""" + resp = rpc_call("watch_calendar_list", { + "id": "minimal-watch-channel", + "type": "web_hook", + "address": "https://minimal.example.com/hook" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "minimal-watch-channel" in text or "" in text + + +def test_mcp_replace_calendar_in_list_full_update(): + """replace_calendar_in_list → should perform full update with all fields (positive)""" + resp = rpc_call("replace_calendar_in_list", { + "calendarId": "test-calendar-001", + "summaryOverride": "Fully Replaced Calendar", + "colorId": "5", + "hidden": False, + "selected": True, + "defaultReminders": [ + {"method": "email", "minutes": 30}, + {"method": "popup", "minutes": 10} + ] + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains updated data + assert "calendar#calendarListEntry" in text or "Fully Replaced Calendar" in text or "" in text + + +def test_mcp_replace_calendar_in_list_minimal(): + """replace_calendar_in_list → should succeed with minimal required params (positive)""" + resp = rpc_call("replace_calendar_in_list", { + "calendarId": "test-calendar-002" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains calendar data + assert "test-calendar-002" in text or "calendar#calendarListEntry" in text or "" in text + + +def test_mcp_replace_calendar_in_list_with_colors(): + """replace_calendar_in_list → should update with RGB colors (positive)""" + resp = rpc_call("replace_calendar_in_list", { + "calendarId": "test-calendar-003", + "colorRgbFormat": True, + "backgroundColor": "#FF5733", + "foregroundColor": "#FFFFFF", + "summaryOverride": "Custom Colored Calendar" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains color data + assert "#FF5733" in text or "#FFFFFF" in text or "Custom Colored Calendar" in text or "" in text + + +def test_mcp_replace_calendar_in_list_hidden(): + """replace_calendar_in_list → should update hidden status (positive)""" + resp = rpc_call("replace_calendar_in_list", { + "calendarId": "test-calendar-004", + "hidden": True, + "selected": False + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains updated data + assert "test-calendar-004" in text or "" in text + + +def test_mcp_replace_calendar_in_list_missing_calendar_id(): + """replace_calendar_in_list → should fail when calendarId is missing (negative)""" + resp = rpc_call("replace_calendar_in_list", { + "summaryOverride": "No Calendar ID" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "calendar" in text.lower() or "id" in text.lower() or "required" in text.lower() + + +def test_mcp_replace_calendar_in_list_with_reminders(): + """replace_calendar_in_list → should update with default reminders (positive)""" + resp = rpc_call("replace_calendar_in_list", { + "calendarId": "test-calendar-005", + "summaryOverride": "Calendar with Reminders", + "defaultReminders": [ + {"method": "email", "minutes": 60}, + {"method": "popup", "minutes": 15} + ] + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains reminder data + assert "email" in text or "popup" in text or "Calendar with Reminders" in text or "" in text + diff --git a/envs/calendar_env/server/tests_tools/test_mcp_colors.py b/envs/calendar_env/server/tests_tools/test_mcp_colors.py new file mode 100644 index 000000000..444e2a973 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_colors.py @@ -0,0 +1,359 @@ +""" +End-to-end MCP tests for Colors tool via /mcp endpoint. +All tool executions and user lookups are mocked so tests run offline. +Covers: get_colors (the only colors endpoint). +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app mounting the /mcp route +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Route that forwards requests to the real MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixture: fake UserManager to bypass DB lookups +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + """Bypass real DB access inside UserManager for tests.""" + class DummyUserManager: + def __init__(self, dbid): + self.dbid = dbid + def get_first_user_from_db(self): + return {"id": "test-user-001"} + def get_user_by_access_token(self, token): + return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Fixture: fake execute_tool_generic to simulate tool responses +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Patch execute_tool_generic so each tool returns predictable mock data.""" + # Mock the MCP_TOOLS list to include colors tools + from calendar_mcp.tools.colors import COLORS_TOOLS + monkeypatch.setattr(tool_handlers, "MCP_TOOLS", COLORS_TOOLS) + + async def _fake(tool_name, tool_input, database_id, user_id): + import json as json_module + + if tool_name == "get_colors": + # Return comprehensive color data matching Google Calendar API v3 format + data = { + "kind": "calendar#colors", + "updated": "2024-01-15T12:00:00.000Z", + "calendar": { + "1": { + "background": "#ac725e", + "foreground": "#1d1d1d" + }, + "2": { + "background": "#d06b64", + "foreground": "#1d1d1d" + }, + "3": { + "background": "#f83a22", + "foreground": "#1d1d1d" + }, + "4": { + "background": "#fa573c", + "foreground": "#1d1d1d" + }, + "5": { + "background": "#ff6b6b", + "foreground": "#1d1d1d" + }, + "6": { + "background": "#ffad46", + "foreground": "#1d1d1d" + }, + "7": { + "background": "#42d692", + "foreground": "#1d1d1d" + }, + "8": { + "background": "#16a765", + "foreground": "#1d1d1d" + }, + "9": { + "background": "#7bd148", + "foreground": "#1d1d1d" + }, + "10": { + "background": "#b3dc6c", + "foreground": "#1d1d1d" + }, + "11": { + "background": "#fbe983", + "foreground": "#1d1d1d" + }, + "12": { + "background": "#fad165", + "foreground": "#1d1d1d" + }, + "13": { + "background": "#92e1c0", + "foreground": "#1d1d1d" + }, + "14": { + "background": "#9fe1e7", + "foreground": "#1d1d1d" + }, + "15": { + "background": "#9fc6e7", + "foreground": "#1d1d1d" + }, + "16": { + "background": "#4986e7", + "foreground": "#1d1d1d" + }, + "17": { + "background": "#9a9cff", + "foreground": "#1d1d1d" + }, + "18": { + "background": "#b99aff", + "foreground": "#1d1d1d" + }, + "19": { + "background": "#c2c2c2", + "foreground": "#1d1d1d" + }, + "20": { + "background": "#cabdbf", + "foreground": "#1d1d1d" + }, + "21": { + "background": "#cca6ac", + "foreground": "#1d1d1d" + }, + "22": { + "background": "#f691b2", + "foreground": "#1d1d1d" + }, + "23": { + "background": "#cd74e6", + "foreground": "#1d1d1d" + }, + "24": { + "background": "#a47ae2", + "foreground": "#1d1d1d" + } + }, + "event": { + "1": { + "background": "#a4bdfc", + "foreground": "#1d1d1d" + }, + "2": { + "background": "#7ae7bf", + "foreground": "#1d1d1d" + }, + "3": { + "background": "#dbadff", + "foreground": "#1d1d1d" + }, + "4": { + "background": "#ff887c", + "foreground": "#1d1d1d" + }, + "5": { + "background": "#fbd75b", + "foreground": "#1d1d1d" + }, + "6": { + "background": "#ffb878", + "foreground": "#1d1d1d" + }, + "7": { + "background": "#46d6db", + "foreground": "#1d1d1d" + }, + "8": { + "background": "#e1e1e1", + "foreground": "#1d1d1d" + }, + "9": { + "background": "#5484ed", + "foreground": "#1d1d1d" + }, + "10": { + "background": "#51b749", + "foreground": "#1d1d1d" + }, + "11": { + "background": "#dc2127", + "foreground": "#1d1d1d" + } + } + } + return {"text": json_module.dumps(data), "isError": False} + + return {"isError": True, "text": f"Unhandled tool {tool_name}"} + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + + # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic + tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in COLORS_TOOLS} + + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Helper to send JSON-RPC calls to /mcp with required headers +# ------------------------------------------------------------------------------ + +def rpc_call(tool_name, arguments, rpc_id=1): + """Send a JSON-RPC call including headers required by MCP handler.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": rpc_id + } + headers = { + "x-database-id": "test-db-001", + "x-access-token": "dummy-static-token" + } + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# Tests for colors tool +# ------------------------------------------------------------------------------ + +def test_mcp_get_colors_success(): + """get_colors → should successfully retrieve all color definitions (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + # Validate basic MCP structure + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains color data + assert "calendar#colors" in text or "calendar" in text or "" in text + + +def test_mcp_get_colors_has_updated_timestamp(): + """get_colors → should include updated timestamp (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify updated timestamp is present + assert "updated" in text or "2024" in text or "" in text + + +def test_mcp_get_colors_multiple_calls(): + """get_colors → should handle multiple consecutive calls (positive)""" + resp1 = rpc_call("get_colors", {}, rpc_id=1) + resp2 = rpc_call("get_colors", {}, rpc_id=2) + resp3 = rpc_call("get_colors", {}, rpc_id=3) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + assert resp3.status_code == 200 + + # All should return valid responses + result1 = resp1.json()["result"] + result2 = resp2.json()["result"] + result3 = resp3.json()["result"] + + assert "content" in result1 + assert "content" in result2 + assert "content" in result3 + + +def test_mcp_get_colors_no_parameters_required(): + """get_colors → should work without any parameters (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Should still return valid color data + assert "calendar#colors" in text or "calendar" in text or "" in text + + +def test_mcp_get_colors_with_empty_arguments(): + """get_colors → should handle empty arguments object (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + + +def test_mcp_get_colors_idempotent(): + """get_colors → should return same data on repeated calls (positive)""" + resp1 = rpc_call("get_colors", {}) + resp2 = rpc_call("get_colors", {}) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + + result1 = resp1.json()["result"] + result2 = resp2.json()["result"] + + # Both responses should have the same structure + assert "content" in result1 + assert "content" in result2 + + +def test_mcp_get_colors_calendar_color_count(): + """get_colors → should contain 24 calendar colors (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify we have calendar colors (checking for some IDs) + assert '"1"' in text or '"24"' in text or "" in text + + +def test_mcp_get_colors_event_color_count(): + """get_colors → should contain 11 event colors (positive)""" + resp = rpc_call("get_colors", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify we have event colors (checking for some IDs) + assert '"1"' in text or '"11"' in text or "" in text \ No newline at end of file diff --git a/envs/calendar_env/server/tests_tools/test_mcp_core_all.py b/envs/calendar_env/server/tests_tools/test_mcp_core_all.py new file mode 100644 index 000000000..125841d6e --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_core_all.py @@ -0,0 +1,261 @@ +""" +End-to-end MCP tests for core Calendar tools via /mcp endpoint. +Mocks out database, user lookups, and tool execution. +Covers: freebusy.query, colors.get, settings.list, acl.list, acl.get, acl.insert, acl.update, acl.patch, acl.delete, acl.watch +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app for /mcp +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Forward JSON-RPC requests to the real MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixture: fake UserManager (skip DB access) +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + """Bypass DB in UserManager for tests.""" + class DummyUserManager: + def __init__(self, dbid): self.dbid = dbid + def get_first_user_from_db(self): return {"id": "test-user-001"} + def get_user_by_access_token(self, token): return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Fixture: fake execute_tool_generic for core tool behaviors +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Simulate behavior of core Calendar tools.""" + async def _fake(tool_name, tool_input, database_id, user_id): + # Simulate error conditions + if tool_name in ["get_acl_rule", "update_acl_rule", "patch_acl_rule"]: + if not tool_input.get("calendarId"): + return {"isError": True, "text": "Missing calendarId"} + if not tool_input.get("ruleId"): + return {"isError": True, "text": "Missing ruleId"} + + if tool_name == "update_acl_rule": + if not tool_input.get("scope"): + return {"isError": True, "text": "Missing scope"} + + if tool_name == "watch_acl": + if not tool_input.get("calendarId"): + return {"isError": True, "text": "Missing calendarId"} + if not tool_input.get("id"): + return {"isError": True, "text": "Missing id"} + if not tool_input.get("type"): + return {"isError": True, "text": "Missing type"} + if not tool_input.get("address"): + return {"isError": True, "text": "Missing address"} + + if tool_name == "freebusy_query": + return {"calendars": {"primary": {"busy": [{"start": "2025-10-10T09:00:00Z", "end": "2025-10-10T10:00:00Z"}]}}} + if tool_name == "colors_get": + return {"calendar": {"1": {"background": "#ff0000"}}, "event": {"2": {"background": "#00ff00"}}} + if tool_name == "settings_list": + return {"items": [{"id": "timezone", "value": "America/New_York"}]} + if tool_name == "acl_list": + return {"items": [{"role": "owner", "scope": {"type": "user", "value": "owner@example.com"}}]} + if tool_name == "acl_insert": + return {"id": "rule-123", "role": "reader"} + if tool_name == "acl_delete": + return {} + if tool_name == "get_acl_rule": + return {"id": tool_input.get("ruleId"), "role": "reader", "scope": {"type": "user", "value": "user@example.com"}} + if tool_name == "update_acl_rule": + return {"id": tool_input.get("ruleId"), "role": tool_input.get("role", "reader"), "scope": tool_input.get("scope")} + if tool_name == "patch_acl_rule": + return {"id": tool_input.get("ruleId"), "role": tool_input.get("role", "reader"), "scope": {"type": "user", "value": "updated@example.com"}} + if tool_name == "watch_acl": + return {"kind": "api#channel", "id": tool_input.get("id"), "resourceId": "acl-watch"} + return {"isError": True, "text": f"Unhandled tool {tool_name}"} + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Helper to make JSON-RPC calls to /mcp +# ------------------------------------------------------------------------------ + +def rpc_call(tool_name, arguments, rpc_id=1): + """Send JSON-RPC call to /mcp with required headers.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + "id": rpc_id, + } + headers = {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# Tests: FreeBusy +# ------------------------------------------------------------------------------ + +def test_mcp_freebusy_query(): + """ freebusy.query → should return mock busy slots.""" + resp = rpc_call("freebusy_query", {"timeMin": "2025-10-10T00:00:00Z", "timeMax": "2025-10-10T23:59:59Z"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +# ------------------------------------------------------------------------------ +# Tests: Colors +# ------------------------------------------------------------------------------ + +def test_mcp_colors_get(): + """ colors.get → should return calendar and event colors.""" + resp = rpc_call("colors_get", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +# ------------------------------------------------------------------------------ +# Tests: Settings +# ------------------------------------------------------------------------------ + +def test_mcp_settings_list(): + """ settings.list → should return user settings.""" + resp = rpc_call("settings_list", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +# ------------------------------------------------------------------------------ +# Tests: ACL (Access Control List) +# ------------------------------------------------------------------------------ + +def test_mcp_acl_list(): + """ acl.list → should return mock ACL entries.""" + resp = rpc_call("acl_list", {"calendarId": "primary"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_acl_insert(): + """ acl.insert → should insert ACL rule.""" + resp = rpc_call("acl_insert", {"calendarId": "primary", "role": "reader"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_acl_delete(): + """ acl.delete → should delete ACL rule.""" + resp = rpc_call("acl_delete", {"calendarId": "primary", "ruleId": "rule-123"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +# ------------------------------------------------------------------------------ +# Tests: ACL Rule Management +# ------------------------------------------------------------------------------ + +def test_mcp_get_acl_rule_success(): + """GOOD: get_acl_rule with valid parameters""" + resp = rpc_call("get_acl_rule", {"calendarId": "primary", "ruleId": "rule-456"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_get_acl_rule_missing_rule_id(): + """BAD: get_acl_rule without ruleId""" + resp = rpc_call("get_acl_rule", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_mcp_update_acl_rule_success(): + """GOOD: update_acl_rule with complete parameters""" + resp = rpc_call("update_acl_rule", { + "calendarId": "primary", + "ruleId": "rule-789", + "scope": {"type": "user", "value": "newuser@example.com"}, + "role": "writer", + "sendNotifications": False + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_update_acl_rule_missing_scope(): + """BAD: update_acl_rule without scope""" + resp = rpc_call("update_acl_rule", {"calendarId": "primary", "ruleId": "rule-789"}) + assert resp.status_code == 200 + + +def test_mcp_patch_acl_rule_success(): + """GOOD: patch_acl_rule with role update""" + resp = rpc_call("patch_acl_rule", { + "calendarId": "primary", + "ruleId": "rule-101", + "role": "owner" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_patch_acl_rule_missing_calendar_id(): + """BAD: patch_acl_rule without calendarId""" + resp = rpc_call("patch_acl_rule", {"ruleId": "rule-101", "role": "owner"}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# Tests: ACL Watch +# ------------------------------------------------------------------------------ + +def test_mcp_watch_acl_success(): + """GOOD: watch_acl with all required parameters""" + resp = rpc_call("watch_acl", { + "calendarId": "primary", + "id": "acl-watch-001", + "type": "web_hook", + "address": "https://example.com/acl-webhook" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_mcp_watch_acl_missing_address(): + """BAD: watch_acl without address""" + resp = rpc_call("watch_acl", { + "calendarId": "primary", + "id": "acl-watch-002", + "type": "web_hook" + }) + assert resp.status_code == 200 diff --git a/envs/calendar_env/server/tests_tools/test_mcp_errors.py b/envs/calendar_env/server/tests_tools/test_mcp_errors.py new file mode 100644 index 000000000..efba3cb2f --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_errors.py @@ -0,0 +1,128 @@ +""" +MCP Error-Handling Smoke Tests +------------------------------ +Verifies that /mcp endpoint correctly handles invalid JSON, +unknown tools, missing fields, and bad database headers. +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app using the real MCP handler +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# 1. Invalid JSON in request body +# ------------------------------------------------------------------------------ + +def test_mcp_invalid_json(monkeypatch): + """Should return JSON-RPC error for malformed JSON.""" + # Mock the handler to catch the validation error and return proper response + async def mock_handle_mcp_request(request): + try: + # Try to parse JSON to trigger the error + import json + await request.json() + except json.JSONDecodeError: + # Return raw dict to avoid Pydantic validation issues + return { + "jsonrpc": "2.0", + "id": None, + "result": {"error": "Invalid JSON"} + } + # If no error, call original handler + return await mcp_handler.handle_mcp_request(request) + + monkeypatch.setattr("handlers.mcp_handler.handle_mcp_request", mock_handle_mcp_request) + + # Simulate raw invalid body by bypassing FastAPI json parser + response = client.post("/mcp", data="not-json") + body = response.json() + assert response.status_code == 200 + assert "error" in body["result"]["error"].lower() or "invalid" in str(body).lower() + + +# ------------------------------------------------------------------------------ +# 2. Missing required fields +# ------------------------------------------------------------------------------ + +def test_mcp_missing_method(): + """ Should fail gracefully when method is missing.""" + payload = {"jsonrpc": "2.0", "params": {"name": "get_calendar"}, "id": 1} + response = client.post("/mcp", json=payload, headers={"x-database-id": "test-db-001"}) + assert response.status_code == 200 + body = response.json() + assert "error" in str(body["result"]).lower() + + +# ------------------------------------------------------------------------------ +# 3. Unknown tool name +# ------------------------------------------------------------------------------ + +def test_mcp_unknown_tool(): + """ Should report unknown tool error.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": "non_existent_tool", "arguments": {}}, + "id": 99, + } + headers = {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} + response = client.post("/mcp", json=payload, headers=headers) + assert response.status_code == 200 + body = response.json() + result = body["result"] + assert "Unknown tool" in result["content"][0]["text"] or "" in result["content"][0]["text"] + + +# ------------------------------------------------------------------------------ +# 4. Missing database_id header +# ------------------------------------------------------------------------------ + +def test_mcp_missing_database_header(): + """ Should raise 'database_id is required' if header is absent.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": "create_calendar", "arguments": {"summary": "Team"}}, + "id": 5, + } + # No x-database-id header intentionally + response = client.post("/mcp", json=payload) + assert response.status_code == 200 + body = response.json() + msg = body["result"]["content"][0]["text"] + assert "database_id is required" in msg + + +# ------------------------------------------------------------------------------ +# 5. Valid JSON-RPC but unsupported method +# ------------------------------------------------------------------------------ + +def test_mcp_unknown_method(): + """ Should return Method not found error.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/invalid_method", + "params": {}, + "id": 7, + } + headers = {"x-database-id": "test-db-001"} + response = client.post("/mcp", json=payload, headers=headers) + body = response.json() + assert "Method not found" in str(body) diff --git a/envs/calendar_env/server/tests_tools/test_mcp_events_all.py b/envs/calendar_env/server/tests_tools/test_mcp_events_all.py new file mode 100644 index 000000000..1683b1627 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_events_all.py @@ -0,0 +1,640 @@ +""" +End-to-end MCP tests for all Event tools via /mcp endpoint. +All tool executions and user lookups are mocked so tests run offline. +Covers: list_events, get_event, insert_event, patch_event, update_event, delete_event, move_event, quick_add_event, import_event, get_event_instances, watch_events +Each function has 2 good scenarios and 3 bad scenarios. +They are classified in sub clasess +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app mounting the /mcp route +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Route that forwards requests to the real MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixture: fake UserManager to bypass DB lookups +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + """Bypass real DB access inside UserManager for tests.""" + class DummyUserManager: + def __init__(self, dbid): + self.dbid = dbid + def get_first_user_from_db(self): + return {"id": "test-user-001"} + def get_user_by_access_token(self, token): + return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Fixture: fake execute_tool_generic to simulate event tool responses +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Patch execute_tool_generic so each event tool returns predictable mock data.""" + async def _fake(tool_name, tool_input, database_id, user_id): + # Simulate error conditions + if not tool_input.get("calendarId"): + return {"isError": True, "text": "Missing calendarId"} + + if tool_name in ["get_event", "patch_event", "update_event", "delete_event", "move_event", "get_event_instances"]: + if not tool_input.get("eventId"): + return {"isError": True, "text": "Missing eventId"} + + if tool_name == "move_event": + if not tool_input.get("destination"): + return {"isError": True, "text": "Missing destination"} + + if tool_name == "quick_add_event": + if not tool_input.get("text"): + return {"isError": True, "text": "Missing text"} + + if tool_name == "import_event": + if not tool_input.get("start"): + return {"isError": True, "text": "Missing start"} + if not tool_input.get("end"): + return {"isError": True, "text": "Missing end"} + if not tool_input.get("iCalUID"): + return {"isError": True, "text": "Missing iCalUID"} + + if tool_name == "watch_events": + if not tool_input.get("id"): + return {"isError": True, "text": "Missing id"} + if not tool_input.get("type"): + return {"isError": True, "text": "Missing type"} + if not tool_input.get("address"): + return {"isError": True, "text": "Missing address"} + + if tool_name == "list_events": + return { + "items": [ + {"id": "evt-1", "summary": "Morning Meeting"}, + {"id": "evt-2", "summary": "Lunch Break"}, + ] + } + if tool_name == "get_event": + return {"id": tool_input.get("eventId"), "summary": "Project Sync"} + if tool_name == "insert_event": + return {"id": "evt-new", "summary": tool_input.get("summary", "Untitled Event")} + if tool_name == "patch_event": + return {"id": tool_input.get("eventId"), "summary": tool_input.get("summary", "Patched")} + if tool_name == "update_event": + return {"id": tool_input.get("eventId"), "summary": "Updated Event"} + if tool_name == "delete_event": + return {} + if tool_name == "move_event": + return {"id": tool_input.get("eventId"), "summary": "Moved Event"} + if tool_name == "quick_add_event": + return {"id": "evt-quick", "summary": "Quick Added Event"} + if tool_name == "import_event": + return {"id": "evt-import", "summary": "Imported Event"} + if tool_name == "get_event_instances": + return { + "items": [ + {"id": "evt-inst-1", "summary": "Recurring Meeting Instance 1"}, + {"id": "evt-inst-2", "summary": "Recurring Meeting Instance 2"}, + ] + } + if tool_name == "watch_events": + return {"kind": "api#channel", "id": tool_input.get("id"), "resourceId": "calendar-watch"} + return {"isError": True, "text": f"Unhandled tool {tool_name}"} + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Helper to send JSON-RPC calls to /mcp with required headers +# ------------------------------------------------------------------------------ + +def rpc_call(tool_name, arguments, rpc_id=1, db_id="test-db-001", token="dummy-static-token"): + """Send a JSON-RPC call including headers required by MCP handler.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": rpc_id + } + headers = { + "x-database-id": db_id, + "x-access-token": token + } + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# list_events: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_list_events_primary_calendar_success(): + """GOOD: list_events with primary calendar""" + resp = rpc_call("list_events", {"calendarId": "primary"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_list_events_secondary_calendar_success(): + """GOOD: list_events with secondary calendar""" + resp = rpc_call("list_events", {"calendarId": "work@company.com"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_list_events_missing_calendar_id(): + """BAD: list_events without calendarId""" + resp = rpc_call("list_events", {}) + assert resp.status_code == 200 + # Check for error in response content if applicable + + +def test_list_events_invalid_calendar_format(): + """BAD: list_events with malformed calendar ID""" + resp = rpc_call("list_events", {"calendarId": "not@valid@format@email"}) + assert resp.status_code == 200 + + +def test_list_events_empty_calendar_id(): + """BAD: list_events with empty string calendarId""" + resp = rpc_call("list_events", {"calendarId": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# get_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_get_event_valid_primary_calendar(): + """GOOD: get_event with valid eventId on primary calendar""" + resp = rpc_call("get_event", {"calendarId": "primary", "eventId": "evt-123"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_get_event_valid_secondary_calendar(): + """GOOD: get_event with valid eventId on secondary calendar""" + resp = rpc_call("get_event", {"calendarId": "team@company.com", "eventId": "evt-456"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_get_event_missing_event_id(): + """BAD: get_event without eventId""" + resp = rpc_call("get_event", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_get_event_missing_calendar_id(): + """BAD: get_event without calendarId""" + resp = rpc_call("get_event", {"eventId": "evt-123"}) + assert resp.status_code == 200 + + +def test_get_event_empty_event_id(): + """BAD: get_event with empty eventId""" + resp = rpc_call("get_event", {"calendarId": "primary", "eventId": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# insert_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_insert_event_with_summary(): + """GOOD: insert_event with summary""" + resp = rpc_call("insert_event", {"calendarId": "primary", "summary": "Team Standup"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_insert_event_with_unicode_summary(): + """GOOD: insert_event with unicode characters""" + resp = rpc_call("insert_event", {"calendarId": "primary", "summary": "会議"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_insert_event_missing_calendar_id(): + """BAD: insert_event without calendarId""" + resp = rpc_call("insert_event", {"summary": "Meeting"}) + assert resp.status_code == 200 + + +def test_insert_event_empty_calendar_id(): + """BAD: insert_event with empty calendarId""" + resp = rpc_call("insert_event", {"calendarId": "", "summary": "Meeting"}) + assert resp.status_code == 200 + + +def test_insert_event_null_summary(): + """BAD: insert_event with null summary""" + resp = rpc_call("insert_event", {"calendarId": "primary", "summary": None}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# patch_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_patch_event_update_summary(): + """GOOD: patch_event to update summary""" + resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "evt-555", "summary": "Updated Title"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_patch_event_partial_update(): + """GOOD: patch_event with minimal changes""" + resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "evt-666", "summary": "Quick Fix"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_patch_event_missing_event_id(): + """BAD: patch_event without eventId""" + resp = rpc_call("patch_event", {"calendarId": "primary", "summary": "New Title"}) + assert resp.status_code == 200 + + +def test_patch_event_missing_calendar_id(): + """BAD: patch_event without calendarId""" + resp = rpc_call("patch_event", {"eventId": "evt-555", "summary": "New Title"}) + assert resp.status_code == 200 + + +def test_patch_event_empty_event_id(): + """BAD: patch_event with empty eventId""" + resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "", "summary": "Title"}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# update_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_update_event_full_replace(): + """GOOD: update_event with full replacement""" + resp = rpc_call("update_event", {"calendarId": "primary", "eventId": "evt-888"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_update_event_with_all_fields(): + """GOOD: update_event with complete data""" + resp = rpc_call("update_event", { + "calendarId": "primary", + "eventId": "evt-999", + "summary": "Complete Update" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_update_event_missing_event_id(): + """BAD: update_event without eventId""" + resp = rpc_call("update_event", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_update_event_missing_calendar_id(): + """BAD: update_event without calendarId""" + resp = rpc_call("update_event", {"eventId": "evt-888"}) + assert resp.status_code == 200 + + +def test_update_event_empty_event_id(): + """BAD: update_event with empty eventId""" + resp = rpc_call("update_event", {"calendarId": "primary", "eventId": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# delete_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_delete_event_from_primary(): + """GOOD: delete_event from primary calendar""" + resp = rpc_call("delete_event", {"calendarId": "primary", "eventId": "evt-999"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_delete_event_from_secondary(): + """GOOD: delete_event from secondary calendar""" + resp = rpc_call("delete_event", {"calendarId": "work@example.com", "eventId": "evt-777"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_delete_event_missing_event_id(): + """BAD: delete_event without eventId""" + resp = rpc_call("delete_event", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_delete_event_missing_calendar_id(): + """BAD: delete_event without calendarId""" + resp = rpc_call("delete_event", {"eventId": "evt-999"}) + assert resp.status_code == 200 + + +def test_delete_event_empty_event_id(): + """BAD: delete_event with empty eventId""" + resp = rpc_call("delete_event", {"calendarId": "primary", "eventId": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# move_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_move_event_to_primary_calendar(): + """GOOD: move_event from secondary to primary calendar""" + resp = rpc_call("move_event", { + "calendarId": "work@company.com", + "eventId": "evt-123", + "destination": "primary" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_move_event_with_notifications(): + """GOOD: move_event with notification settings""" + resp = rpc_call("move_event", { + "calendarId": "primary", + "eventId": "evt-456", + "destination": "team@company.com", + "sendNotifications": True, + "sendUpdates": "all" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_move_event_missing_destination(): + """BAD: move_event without destination""" + resp = rpc_call("move_event", {"calendarId": "primary", "eventId": "evt-123"}) + assert resp.status_code == 200 + + +def test_move_event_missing_event_id(): + """BAD: move_event without eventId""" + resp = rpc_call("move_event", {"calendarId": "primary", "destination": "work@company.com"}) + assert resp.status_code == 200 + + +def test_move_event_missing_calendar_id(): + """BAD: move_event without calendarId""" + resp = rpc_call("move_event", {"eventId": "evt-123", "destination": "work@company.com"}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# quick_add_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_quick_add_event_simple_text(): + """GOOD: quick_add_event with simple text""" + resp = rpc_call("quick_add_event", {"calendarId": "primary", "text": "Meeting tomorrow at 2pm"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_quick_add_event_complex_text(): + """GOOD: quick_add_event with complex text and notifications""" + resp = rpc_call("quick_add_event", { + "calendarId": "primary", + "text": "Team lunch next Friday at 12:30pm at Downtown Cafe", + "sendNotifications": True + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_quick_add_event_missing_text(): + """BAD: quick_add_event without text""" + resp = rpc_call("quick_add_event", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_quick_add_event_missing_calendar_id(): + """BAD: quick_add_event without calendarId""" + resp = rpc_call("quick_add_event", {"text": "Meeting tomorrow"}) + assert resp.status_code == 200 + + +def test_quick_add_event_empty_text(): + """BAD: quick_add_event with empty text""" + resp = rpc_call("quick_add_event", {"calendarId": "primary", "text": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# import_event: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_import_event_basic(): + """GOOD: import_event with required fields""" + resp = rpc_call("import_event", { + "calendarId": "primary", + "iCalUID": "import-test-001", + "start": {"dateTime": "2023-12-01T10:00:00Z"}, + "end": {"dateTime": "2023-12-01T11:00:00Z"} + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_import_event_with_details(): + """GOOD: import_event with complete details""" + resp = rpc_call("import_event", { + "calendarId": "primary", + "iCalUID": "import-test-002", + "summary": "Imported Meeting", + "description": "Meeting imported from external calendar", + "start": {"dateTime": "2023-12-01T14:00:00Z"}, + "end": {"dateTime": "2023-12-01T15:00:00Z"}, + "supportsAttachments": True + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_import_event_missing_start(): + """BAD: import_event without start""" + resp = rpc_call("import_event", { + "calendarId": "primary", + "iCalUID": "import-test-003", + "end": {"dateTime": "2023-12-01T11:00:00Z"} + }) + assert resp.status_code == 200 + + +def test_import_event_missing_end(): + """BAD: import_event without end""" + resp = rpc_call("import_event", { + "calendarId": "primary", + "iCalUID": "import-test-004", + "start": {"dateTime": "2023-12-01T10:00:00Z"} + }) + assert resp.status_code == 200 + + +def test_import_event_missing_ical_uid(): + """BAD: import_event without iCalUID""" + resp = rpc_call("import_event", { + "calendarId": "primary", + "start": {"dateTime": "2023-12-01T10:00:00Z"}, + "end": {"dateTime": "2023-12-01T11:00:00Z"} + }) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# get_event_instances: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_get_event_instances_basic(): + """GOOD: get_event_instances with basic parameters""" + resp = rpc_call("get_event_instances", {"calendarId": "primary", "eventId": "recurring-evt-123"}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_get_event_instances_with_time_range(): + """GOOD: get_event_instances with time range filter""" + resp = rpc_call("get_event_instances", { + "calendarId": "primary", + "eventId": "recurring-evt-456", + "timeMin": "2023-12-01T00:00:00Z", + "timeMax": "2023-12-31T23:59:59Z", + "maxResults": 10 + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_get_event_instances_missing_event_id(): + """BAD: get_event_instances without eventId""" + resp = rpc_call("get_event_instances", {"calendarId": "primary"}) + assert resp.status_code == 200 + + +def test_get_event_instances_missing_calendar_id(): + """BAD: get_event_instances without calendarId""" + resp = rpc_call("get_event_instances", {"eventId": "recurring-evt-123"}) + assert resp.status_code == 200 + + +def test_get_event_instances_empty_event_id(): + """BAD: get_event_instances with empty eventId""" + resp = rpc_call("get_event_instances", {"calendarId": "primary", "eventId": ""}) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------------------ +# watch_events: 2 good + 3 bad scenarios +# ------------------------------------------------------------------------------ + +def test_watch_events_basic(): + """GOOD: watch_events with required parameters""" + resp = rpc_call("watch_events", { + "calendarId": "primary", + "id": "watch-channel-001", + "type": "web_hook", + "address": "https://example.com/webhook" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_watch_events_with_token(): + """GOOD: watch_events with authentication token""" + resp = rpc_call("watch_events", { + "calendarId": "primary", + "id": "watch-channel-002", + "type": "web_hook", + "address": "https://secure.example.com/webhook", + "token": "secure-token-123", + "eventTypes": "default" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_watch_events_missing_id(): + """BAD: watch_events without id""" + resp = rpc_call("watch_events", { + "calendarId": "primary", + "type": "web_hook", + "address": "https://example.com/webhook" + }) + assert resp.status_code == 200 + + +def test_watch_events_missing_type(): + """BAD: watch_events without type""" + resp = rpc_call("watch_events", { + "calendarId": "primary", + "id": "watch-channel-003", + "address": "https://example.com/webhook" + }) + assert resp.status_code == 200 + + +def test_watch_events_missing_address(): + """BAD: watch_events without address""" + resp = rpc_call("watch_events", { + "calendarId": "primary", + "id": "watch-channel-004", + "type": "web_hook" + }) + assert resp.status_code == 200 \ No newline at end of file diff --git a/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py b/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py new file mode 100644 index 000000000..ac527ac6b --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py @@ -0,0 +1,157 @@ +""" +MCP Functional Scenarios +------------------------ +Ten comprehensive integration tests combining multiple tools and edge cases. +These simulate real-world sequences like creating, patching, and deleting +calendars/events using the /mcp endpoint. +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app mounting /mcp +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixtures: fake DB, user manager, and tool executor +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + class DummyUserManager: + def __init__(self, dbid): self.dbid = dbid + def get_first_user_from_db(self): return {"id": "test-user-001"} + def get_user_by_access_token(self, token): return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Generic fake executor for all scenarios.""" + async def _fake(tool_name, tool_input, dbid, userid): + # Simulate basic behavior + responses = { + "create_calendar": {"id": "cal-new", "summary": tool_input.get("summary")}, + "patch_calendar": {"id": "cal-new", "summary": tool_input.get("summary", "patched")}, + "get_calendar": {"id": tool_input.get("calendarId"), "summary": "Sample Calendar"}, + "delete_calendar": {}, + "list_events": {"items": [{"id": "evt-1", "summary": "Meeting"}]}, + "insert_event": {"id": "evt-new", "summary": tool_input.get("summary")}, + "patch_event": {"id": tool_input.get("eventId"), "summary": "Patched Event"}, + "freebusy_query": {"calendars": {"primary": {"busy": []}}}, + "acl_list": {"items": [{"role": "owner"}]}, + "colors_get": {"calendar": {"1": {"background": "#ff0000"}}}, + } + return responses.get(tool_name, {"isError": True, "text": f"Unhandled {tool_name}"}) + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + return monkeypatch + + +@pytest.fixture +def headers(): + return {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} + + +def rpc_call(tool_name, arguments, headers): + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + "id": 1 + } + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# 10 Comprehensive MCP Scenarios +# ------------------------------------------------------------------------------ + +def test_01_create_calendar_success(headers): + """ Create a calendar and verify response.""" + resp = rpc_call("create_calendar", {"summary": "Engineering Team"}, headers) + assert resp.status_code == 200 + assert "" in str(resp.json()) or "completed" in str(resp.json()) + + +def test_02_patch_calendar_title(headers): + """ Patch calendar title.""" + resp = rpc_call("patch_calendar", {"calendarId": "cal-new", "summary": "Updated Calendar"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_03_insert_event_into_calendar(headers): + """ Insert event inside a calendar.""" + resp = rpc_call("insert_event", {"calendarId": "cal-new", "summary": "Team Sync"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_04_list_events_from_calendar(headers): + """ List events after insertion.""" + resp = rpc_call("list_events", {"calendarId": "cal-new"}, headers) + assert resp.status_code == 200 + result = resp.json()["result"] + assert "content" in result + + +def test_05_update_event_summary(headers): + """ Patch existing event summary.""" + resp = rpc_call("patch_event", {"calendarId": "cal-new", "eventId": "evt-1", "summary": "Rescheduled Sync"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_06_query_freebusy_availability(headers): + """ FreeBusy query should return empty slots.""" + resp = rpc_call("freebusy_query", {"timeMin": "2025-10-10T00:00:00Z", "timeMax": "2025-10-10T23:59:59Z"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_07_retrieve_calendar_colors(headers): + """ Get available calendar colors.""" + resp = rpc_call("colors_get", {}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_08_acl_list_access_rights(headers): + """ List ACL entries.""" + resp = rpc_call("acl_list", {"calendarId": "primary"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_09_delete_calendar(headers): + """ Delete an existing calendar.""" + resp = rpc_call("delete_calendar", {"calendarId": "cal-new"}, headers) + result = resp.json()["result"] + assert "content" in result + + +def test_10_invalid_tool_request(headers): + """ Invalid tool should produce Unknown tool error.""" + resp = rpc_call("unknown_tool", {}, headers) + result = resp.json()["result"] + text = result["content"][0]["text"] + assert "Unknown tool" in text or "" in text diff --git a/envs/calendar_env/server/tests_tools/test_mcp_settings.py b/envs/calendar_env/server/tests_tools/test_mcp_settings.py new file mode 100644 index 000000000..53fed18e0 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_settings.py @@ -0,0 +1,358 @@ +""" +End-to-end MCP tests for all Settings tools via /mcp endpoint. +All tool executions and user lookups are mocked so tests run offline. +Covers: list_settings, get_settings, watch_settings. +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler, tool_handlers + + +# ------------------------------------------------------------------------------ +# Minimal FastAPI app mounting the /mcp route +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Route that forwards requests to the real MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Fixture: fake UserManager to bypass DB lookups +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_user_manager(monkeypatch): + """Bypass real DB access inside UserManager for tests.""" + class DummyUserManager: + def __init__(self, dbid): + self.dbid = dbid + def get_first_user_from_db(self): + return {"id": "test-user-001"} + def get_user_by_access_token(self, token): + return {"id": "test-user-001"} + + import handlers.mcp_handler as mcp + monkeypatch.setattr(mcp, "UserManager", DummyUserManager) + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Fixture: fake execute_tool_generic to simulate tool responses +# ------------------------------------------------------------------------------ + +@pytest.fixture(autouse=True) +def fake_exec(monkeypatch): + """Patch execute_tool_generic so each tool returns predictable mock data.""" + # Mock the MCP_TOOLS list to include settings tools + from calendar_mcp.tools.settings import SETTINGS_TOOLS + monkeypatch.setattr(tool_handlers, "MCP_TOOLS", SETTINGS_TOOLS) + + async def _fake(tool_name, tool_input, database_id, user_id): + import json as json_module + + if tool_name == "list_settings": + data = { + "kind": "calendar#settings", + "etag": "settings-collection-etag", + "items": [ + { + "kind": "calendar#setting", + "etag": "setting-etag-1", + "id": "timezone", + "value": "America/Los_Angeles" + }, + { + "kind": "calendar#setting", + "etag": "setting-etag-2", + "id": "dateFieldOrder", + "value": "MDY" + }, + { + "kind": "calendar#setting", + "etag": "setting-etag-3", + "id": "timeFormat", + "value": "12" + } + ] + } + return {"text": json_module.dumps(data), "isError": False} + + if tool_name == "get_settings": + setting_id = tool_input.get("settingId") + if setting_id == "timezone": + data = { + "kind": "calendar#setting", + "etag": "setting-etag-timezone", + "id": "timezone", + "value": "America/Los_Angeles" + } + return {"text": json_module.dumps(data), "isError": False} + elif setting_id == "dateFieldOrder": + data = { + "kind": "calendar#setting", + "etag": "setting-etag-date", + "id": "dateFieldOrder", + "value": "MDY" + } + return {"text": json_module.dumps(data), "isError": False} + elif setting_id == "timeFormat": + data = { + "kind": "calendar#setting", + "etag": "setting-etag-time", + "id": "timeFormat", + "value": "12" + } + return {"text": json_module.dumps(data), "isError": False} + else: + return {"isError": True, "text": f"Setting {setting_id} not found"} + + if tool_name == "watch_settings": + channel_id = tool_input.get("id") + address = tool_input.get("address") + + if not address: + return {"isError": True, "text": "Webhook address is required"} + + if not channel_id: + return {"isError": True, "text": "Channel ID is required"} + + data = { + "kind": "api#channel", + "id": channel_id, + "resourceId": "settings-test-user-001", + "resourceUri": "/settings", + "token": tool_input.get('token', ''), + "expiration": "1735689600000" + } + return {"text": json_module.dumps(data), "isError": False} + + return {"isError": True, "text": f"Unhandled tool {tool_name}"} + + monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) + + # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic + tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in SETTINGS_TOOLS} + + return monkeypatch + + +# ------------------------------------------------------------------------------ +# Helper to send JSON-RPC calls to /mcp with required headers +# ------------------------------------------------------------------------------ + +def rpc_call(tool_name, arguments, rpc_id=1): + """Send a JSON-RPC call including headers required by MCP handler.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + }, + "id": rpc_id + } + headers = { + "x-database-id": "test-db-001", + "x-access-token": "dummy-static-token" + } + return client.post("/mcp", json=payload, headers=headers) + + +# ------------------------------------------------------------------------------ +# Tests for each settings tool +# ------------------------------------------------------------------------------ + +def test_mcp_list_settings(): + """list_settings → should succeed and return all settings""" + resp = rpc_call("list_settings", {}) + assert resp.status_code == 200 + result = resp.json()["result"] + + # Validate basic MCP structure + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains settings data + assert "calendar#settings" in text or "timezone" in text or "" in text + + +def test_mcp_get_settings_timezone(): + """get_settings → should retrieve timezone setting (positive)""" + resp = rpc_call("get_settings", {"settingId": "timezone"}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains timezone setting + assert "timezone" in text or "America/Los_Angeles" in text or "" in text + + +def test_mcp_get_settings_date_field_order(): + """get_settings → should retrieve dateFieldOrder setting (positive)""" + resp = rpc_call("get_settings", {"settingId": "dateFieldOrder"}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains date field order setting + assert "dateFieldOrder" in text or "MDY" in text or "" in text + + +def test_mcp_get_settings_time_format(): + """get_settings → should retrieve timeFormat setting (positive)""" + resp = rpc_call("get_settings", {"settingId": "timeFormat"}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains time format setting + assert "timeFormat" in text or "12" in text or "" in text + + +def test_mcp_get_settings_not_found(): + """get_settings → should return error for non-existent setting (negative)""" + resp = rpc_call("get_settings", {"settingId": "nonexistent_setting"}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "not found" in text.lower() or "error" in text.lower() + + +def test_mcp_watch_settings_success(): + """watch_settings → should successfully create watch channel (positive)""" + resp = rpc_call("watch_settings", { + "id": "test-channel-001", + "type": "web_hook", + "address": "https://example.com/webhook", + "token": "secret-token-123" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "test-channel-001" in text or "" in text + + +def test_mcp_watch_settings_missing_address(): + """watch_settings → should fail when address is missing (negative)""" + resp = rpc_call("watch_settings", { + "id": "test-channel-002", + "type": "web_hook" + # Missing address + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "Request Error: All connection attempts failed" in text + + +def test_mcp_watch_settings_missing_channel_id(): + """watch_settings → should fail when channel ID is missing (negative)""" + resp = rpc_call("watch_settings", { + "type": "web_hook", + "address": "https://example.com/webhook" + # Missing id + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify error message + assert "request error" in text.lower() + + +def test_mcp_watch_settings_with_params(): + """watch_settings → should succeed with additional params (positive)""" + resp = rpc_call("watch_settings", { + "id": "test-channel-003", + "type": "web_hook", + "address": "https://example.com/webhook", + "token": "secret-token-456", + "params": { + "ttl": "3600" + } + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "test-channel-003" in text or "" in text + + +def test_mcp_watch_settings_minimal(): + """watch_settings → should succeed with minimal required params (positive)""" + resp = rpc_call("watch_settings", { + "id": "minimal-channel", + "type": "web_hook", + "address": "https://minimal.example.com/hook" + }) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + text = result["content"][0]["text"] + + # Verify response contains channel data + assert "api#channel" in text or "minimal-channel" in text or "" in text + + +# ------------------------------------------------------------------------------ +# Edge case tests +# ------------------------------------------------------------------------------ + +def test_mcp_get_settings_empty_setting_id(): + """get_settings → should handle empty settingId gracefully""" + resp = rpc_call("get_settings", {"settingId": ""}) + assert resp.status_code == 200 + result = resp.json()["result"] + + assert "content" in result + # Should return some response (error or empty) + + +def test_mcp_list_settings_multiple_calls(): + """list_settings → should handle multiple consecutive calls""" + resp1 = rpc_call("list_settings", {}, rpc_id=1) + resp2 = rpc_call("list_settings", {}, rpc_id=2) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + + # Both should return valid responses + result1 = resp1.json()["result"] + result2 = resp2.json()["result"] + + assert "content" in result1 + assert "content" in result2 diff --git a/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py b/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py new file mode 100644 index 000000000..1f09fd60f --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py @@ -0,0 +1,71 @@ +""" +MCP integration test: Verify that /mcp tools/list returns all registered tools. +Checks registry consistency between MCP_TOOLS_LIST and TOOL_HANDLERS. +""" + +import sys, os, pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from handlers import mcp_handler +from handlers.tool_handlers import MCP_TOOLS_LIST, TOOL_HANDLERS + + +# ------------------------------------------------------------------------------ +# FastAPI app mounting /mcp +# ------------------------------------------------------------------------------ + +app = FastAPI() + +@app.post("/mcp") +async def mcp_entry(request: Request): + """Forward JSON-RPC request to MCP handler.""" + return await mcp_handler.handle_mcp_request(request) + +client = TestClient(app) + + +# ------------------------------------------------------------------------------ +# Test 1: Direct call to /mcp tools/list +# ------------------------------------------------------------------------------ + +def test_mcp_tools_list_endpoint(): + """ Verify that /mcp tools/list returns JSON-RPC result with all tools.""" + payload = { + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": 1 + } + headers = {"x-database-id": "test-db-001"} # optional header + response = client.post("/mcp", json=payload, headers=headers) + + assert response.status_code == 200, "Expected HTTP 200 from /mcp" + body = response.json() + assert "result" in body, f"Missing JSON-RPC result: {body}" + + result = body["result"] + assert "tools" in result, f"Expected 'tools' key in result, got: {result}" + tool_count = len(result["tools"]) + print(f"\n📦 /mcp tools/list returned {tool_count} tools") + + assert tool_count == len(MCP_TOOLS_LIST), ( + f"Mismatch between registered tool list ({len(MCP_TOOLS_LIST)}) and returned count ({tool_count})" + ) + + +# ------------------------------------------------------------------------------ +# Test 2: Internal registry consistency +# ------------------------------------------------------------------------------ + +def test_tool_registry_consistency(): + """ Verify that every tool name in MCP_TOOLS_LIST has a handler.""" + tool_names = {t["name"] for t in MCP_TOOLS_LIST} + missing = [name for name in tool_names if name not in TOOL_HANDLERS] + + print(f"\n {len(tool_names)} tools in MCP_TOOLS_LIST") + print(f" {len(TOOL_HANDLERS)} handlers registered") + + assert not missing, f"Missing handlers for: {missing}" + assert len(tool_names) >= 10, "Expected at least 10 registered tools" diff --git a/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py b/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py new file mode 100644 index 000000000..af5993306 --- /dev/null +++ b/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py @@ -0,0 +1,227 @@ +""" +Tests for execute_tool_generic() from handlers.tool_handlers. +These mock httpx and router inspection to validate behavior without live APIs. +""" + +import pytest +import json as json_module +import httpx +from handlers import tool_handlers + + +# -------------------------------------------------------------------------- +# Common async helper +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def call_execute(tool_name="create_calendar", arguments=None, status=200, json_data=None, method="POST"): + """Helper to invoke execute_tool_generic() with mocked httpx.""" + arguments = arguments or {} + + # async-compatible request mock + async def mock_request(self, m, url, headers=None, json=None, params=None): + class MockResponse: + def __init__(self): + self.status_code = status + self._json = json_data + self.text = json_module.dumps(json_data) if json_data else "" + def json(self): return self._json + return MockResponse() + + # Patch network + endpoint discovery + tool_handlers.httpx.AsyncClient.request = mock_request + tool_handlers.get_api_endpoint_for_tool = lambda name: (method, f"/mock/{name}") + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic( + tool_name, arguments, database_id="test-db-001", user_id="test-user-001" + ) + return result + + +# -------------------------------------------------------------------------- +# 1. Successful POST +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_success_post(): + data = {"id": "123", "summary": "OK"} + result = await call_execute(json_data=data) + assert not result["isError"], f"Unexpected error: {result['text']}" + assert '"id": "123"' in result["text"] + + +# -------------------------------------------------------------------------- +# 2. Successful GET +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_success_get(): + async def mock_get(self, url, headers=None, params=None): + class R: + status_code = 200 + def json(self): return {"ping": "pong"} + text = json_module.dumps({"ping": "pong"}) + return R() + + # Mock tool configuration + original_tools = getattr(tool_handlers, 'MCP_TOOLS', []) + tool_handlers.MCP_TOOLS = [{"name": "get_calendar_list", "description": "test"}] + + try: + tool_handlers.httpx.AsyncClient.get = mock_get + tool_handlers.get_api_endpoint_for_tool = lambda name: ("GET", "/mock/path") + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("get_calendar_list", {}, "test-db-001", "u1") + assert not result["isError"], f"Unexpected error: {result['text']}" + assert "pong" in result["text"] + finally: + # Restore original tools + tool_handlers.MCP_TOOLS = original_tools + + +# -------------------------------------------------------------------------- +# 3. 204 (DELETE) +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_delete_204(): + async def mock_del(self, url, headers=None, params=None): + class R: status_code = 204; text = "" + return R() + tool_handlers.httpx.AsyncClient.delete = mock_del + tool_handlers.get_api_endpoint_for_tool = lambda name: ("DELETE", "/mock/path") + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("delete_calendar", {}, "test-db-001", "u1") + assert result["status_code"] == 204 + + +# -------------------------------------------------------------------------- +# 4. 404 friendly error +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_404_friendly_error(): + async def mock_get(self, url, headers=None, params=None): + class R: + status_code = 404 + def json(self): return {"detail": "Calendar not found"} + text = "" + return R() + + # Mock tool configuration + original_tools = getattr(tool_handlers, 'MCP_TOOLS', []) + tool_handlers.MCP_TOOLS = [{"name": "get_calendar", "description": "test"}] + + try: + tool_handlers.httpx.AsyncClient.get = mock_get + tool_handlers.get_api_endpoint_for_tool = lambda name: ("GET", "/calendars/{calendarId}") + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("get_calendar", {"calendarId": "bad"}, "test-db-001", "user1") + assert result["isError"] + assert "not found" in result["text"].lower() + finally: + # Restore original tools + tool_handlers.MCP_TOOLS = original_tools + + +# -------------------------------------------------------------------------- +# 5. Missing path parameter +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_missing_param(monkeypatch): + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("GET", "/calendars/{calendarId}")) + tool_handlers.log_tool_response = lambda *a, **kw: None + result = await tool_handlers.execute_tool_generic("get_calendar", {}, "test-db-001", "user1") + assert result["isError"] + assert "The parameter 'calendarId' is required" in result["text"] + + +# -------------------------------------------------------------------------- +# 6. Validation error (Pydantic detail list) +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_validation_error(monkeypatch): + async def mock_request(self, m, url, headers=None, json=None, params=None): + class R: + status_code = 422 + def json(self): + return {"detail": [{"loc": ["body", "summary"], "msg": "field required"}]} + text = "" + return R() + + monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", mock_request) + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") + assert result["isError"] + assert "Validation" in result["text"] + + +# -------------------------------------------------------------------------- +# 7. Non-JSON error body +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_non_json_error(monkeypatch): + async def mock_request(self, m, url, headers=None, json=None, params=None): + class R: + status_code = 500 + text = "Internal Error" + def json(self): raise ValueError("Not JSON") + return R() + monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", mock_request) + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") + assert result["isError"] + assert "HTTP 500" in result["text"] + + +# -------------------------------------------------------------------------- +# 8. Request Exception +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_request_exception(monkeypatch): + async def fail_request(*a, **kw): raise httpx.RequestError("Connection refused") + monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", fail_request) + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) + tool_handlers.log_tool_response = lambda *a, **kw: None + + result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") + assert result["isError"] + assert "Request Error" in result["text"] + + +# -------------------------------------------------------------------------- +# 9. Unexpected Exception +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_unexpected_exception(monkeypatch): + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: 1/0) + tool_handlers.log_tool_response = lambda *a, **kw: None + result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") + assert result["isError"] + assert "Unexpected" in result["text"] + + +# -------------------------------------------------------------------------- +# 10. API endpoint not found +# -------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_endpoint_not_found(monkeypatch): + monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: None) + tool_handlers.log_tool_response = lambda *a, **kw: None + result = await tool_handlers.execute_tool_generic("unknown_tool", {}, "test-db-001", "user1") + assert result["isError"] + assert "API endpoint not found" in result["text"] or "Tool configuration not found" in result["text"] diff --git a/envs/calendar_env/server/utils/__init__.py b/envs/calendar_env/server/utils/__init__.py new file mode 100644 index 000000000..d90f4b136 --- /dev/null +++ b/envs/calendar_env/server/utils/__init__.py @@ -0,0 +1 @@ +"""Utils package""" \ No newline at end of file diff --git a/envs/calendar_env/server/utils/recurrence_utils.py b/envs/calendar_env/server/utils/recurrence_utils.py new file mode 100644 index 000000000..f323c6bcc --- /dev/null +++ b/envs/calendar_env/server/utils/recurrence_utils.py @@ -0,0 +1,562 @@ +""" +Recurrence utilities for parsing RFC 5545 RRULE strings and generating event instances +""" + +import re +import json +from datetime import datetime, timedelta, timezone +from dateutil import rrule, parser as date_parser +from dateutil.rrule import rruleset, rrulestr +from dateutil.tz import gettz +from typing import List, Dict, Any, Optional, Tuple +# from database.models.recurring_event import RecurrenceFrequency +import logging + +logger = logging.getLogger(__name__) + + +class RecurrenceParseError(Exception): + """Custom exception for recurrence parsing errors""" + pass + + +class RecurrenceParser: + """ + Parser for RFC 5545 recurrence rules (RRULE, RDATE, EXDATE) + """ + + # Mapping from RFC 5545 frequency to dateutil.rrule frequency + FREQ_MAP = { + 'SECONDLY': rrule.SECONDLY, + 'MINUTELY': rrule.MINUTELY, + 'HOURLY': rrule.HOURLY, + 'DAILY': rrule.DAILY, + 'WEEKLY': rrule.WEEKLY, + 'MONTHLY': rrule.MONTHLY, + 'YEARLY': rrule.YEARLY, + } + + # Mapping from RFC 5545 weekdays to dateutil.rrule weekdays + WEEKDAY_MAP = { + 'MO': rrule.MO, + 'TU': rrule.TU, + 'WE': rrule.WE, + 'TH': rrule.TH, + 'FR': rrule.FR, + 'SA': rrule.SA, + 'SU': rrule.SU, + } + + @staticmethod + def parse_recurrence_list(recurrence_strings: List[str], event_start: datetime) -> Dict[str, Any]: + """ + Parse a list of recurrence strings (RRULE, RDATE, EXDATE) + + Args: + recurrence_strings: List of RFC 5545 recurrence strings + + Returns: + Dictionary with parsed recurrence components + """ + parsed_data = { + 'rrule': None, + 'rdate_list': [], + 'exdate_list': [] + } + rset = rruleset() + + for rec_string in recurrence_strings: + rec_string = rec_string.strip() + + if rec_string.startswith('RRULE:'): + if parsed_data['rrule'] is not None: + raise RecurrenceParseError("Multiple RRULE entries found - only one is allowed") + parsed_data['rrule'] = RecurrenceParser.parse_rrule(rec_string[6:]) # Remove 'RRULE:' prefix + + rule_str = rec_string.replace("RRULE:", "") + rule = rrulestr(rule_str, dtstart=event_start) + rset.rrule(rule) + + + elif rec_string.startswith('RDATE:'): + rdate_values = RecurrenceParser.parse_rdate_exdate(rec_string[6:]) # Remove 'RDATE:' prefix + parsed_data['rdate_list'].extend(rdate_values) + + rdates_str = rec_string.replace("RDATE:", "") + for d in rdates_str.split(","): + rdate_dt = datetime.strptime(d.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) + rset.rdate(rdate_dt) + + elif rec_string.startswith('EXDATE:'): + exdate_values = RecurrenceParser.parse_rdate_exdate(rec_string[7:]) # Remove 'EXDATE:' prefix + parsed_data['exdate_list'].extend(exdate_values) + + exdates_str = rec_string.replace("EXDATE:", "") + for d in exdates_str.split(","): + exdate_dt = datetime.strptime(d.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) + rset.exdate(exdate_dt) + + else: + logger.warning(f"Unknown recurrence string format: {rec_string}") + + return parsed_data, rset + + @staticmethod + def parse_rrule(rrule_string: str) -> Dict[str, Any]: + """ + Parse an RRULE string according to RFC 5545 + + Args: + rrule_string: RRULE parameters (without 'RRULE:' prefix) + + Returns: + Dictionary with parsed RRULE parameters + """ + rrule_data = {} + + # Split parameters by semicolon + parameters = rrule_string.split(';') + + for param in parameters: + if '=' not in param: + continue + + key, value = param.split('=', 1) + key = key.upper() + + if key == 'FREQ': + if value.upper() not in RecurrenceParser.FREQ_MAP: + raise RecurrenceParseError(f"Invalid FREQ value: {value}") + rrule_data['freq'] = value.upper() + + elif key == 'UNTIL': + try: + # Parse UNTIL datetime + until_dt = RecurrenceParser.parse_datetime(value) + rrule_data['until'] = until_dt + except Exception as e: + raise RecurrenceParseError(f"Invalid UNTIL value: {value}, error: {e}") + + elif key == 'COUNT': + try: + count = int(value) + if count <= 0: + raise RecurrenceParseError("COUNT must be positive") + rrule_data['count'] = count + except ValueError: + raise RecurrenceParseError(f"Invalid COUNT value: {value}") + + elif key == 'INTERVAL': + try: + interval = int(value) + if interval <= 0: + raise RecurrenceParseError("INTERVAL must be positive") + rrule_data['interval'] = interval + except ValueError: + raise RecurrenceParseError(f"Invalid INTERVAL value: {value}") + + elif key == 'BYSECOND': + rrule_data['by_second'] = RecurrenceParser.parse_int_list(value, 0, 60) + + elif key == 'BYMINUTE': + rrule_data['by_minute'] = RecurrenceParser.parse_int_list(value, 0, 59) + + elif key == 'BYHOUR': + rrule_data['by_hour'] = RecurrenceParser.parse_int_list(value, 0, 23) + + elif key == 'BYDAY': + rrule_data['by_day'] = RecurrenceParser.parse_by_day(value) + + elif key == 'BYMONTHDAY': + rrule_data['by_monthday'] = RecurrenceParser.parse_int_list(value, -31, 31, allow_zero=False) + + elif key == 'BYYEARDAY': + rrule_data['by_yearday'] = RecurrenceParser.parse_int_list(value, -366, 366, allow_zero=False) + + elif key == 'BYWEEKNO': + rrule_data['by_weekno'] = RecurrenceParser.parse_int_list(value, -53, 53, allow_zero=False) + + elif key == 'BYMONTH': + rrule_data['by_month'] = RecurrenceParser.parse_int_list(value, 1, 12) + + elif key == 'BYSETPOS': + rrule_data['by_setpos'] = RecurrenceParser.parse_int_list(value, -366, 366, allow_zero=False) + + elif key == 'WKST': + if value.upper() not in RecurrenceParser.WEEKDAY_MAP: + raise RecurrenceParseError(f"Invalid WKST value: {value}") + rrule_data['wkst'] = value.upper() + + else: + logger.warning(f"Unknown RRULE parameter: {key}") + + # Validate required FREQ parameter + if 'freq' not in rrule_data: + raise RecurrenceParseError("FREQ parameter is required in RRULE") + + # Validate COUNT and UNTIL are mutually exclusive + if 'count' in rrule_data and 'until' in rrule_data: + raise RecurrenceParseError("COUNT and UNTIL cannot both be specified in RRULE") + + return rrule_data + + @staticmethod + def parse_rdate_exdate(date_string: str) -> List[datetime]: + """ + Parse RDATE or EXDATE values + + Args: + date_string: Date list string (comma-separated dates) + + Returns: + List of datetime objects + """ + dates = [] + date_values = date_string.split(',') + + for date_value in date_values: + date_value = date_value.strip() + if date_value: + try: + dt = RecurrenceParser.parse_datetime(date_value) + dates.append(dt) + except Exception as e: + logger.warning(f"Failed to parse date value '{date_value}': {e}") + + return dates + + @staticmethod + def parse_datetime(datetime_string: str) -> datetime: + """ + Parse RFC 5545 datetime string + + Args: + datetime_string: RFC 5545 datetime string + + Returns: + datetime object + """ + # Handle UTC times ending with 'Z' + if datetime_string.endswith('Z'): + datetime_string = datetime_string[:-1] + '+00:00' + + # Use dateutil parser for flexibility + try: + dt = date_parser.isoparse(datetime_string) + # Ensure timezone-aware + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + # Fallback to manual parsing for RFC 5545 format + try: + if 'T' in datetime_string: + # YYYYMMDDTHHMMSS format + if '+' in datetime_string or '-' in datetime_string: + # Has timezone + dt_part = datetime_string.split('+')[0].split('-')[0] + dt = datetime.strptime(dt_part, "%Y%m%dT%H%M%S") + else: + dt = datetime.strptime(datetime_string, "%Y%m%dT%H%M%S") + else: + # YYYYMMDD format (date only) + dt = datetime.strptime(datetime_string, "%Y%m%d") + + # Always ensure timezone-aware + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception as e: + raise RecurrenceParseError(f"Cannot parse datetime: {datetime_string}, error: {e}") + + @staticmethod + def parse_int_list(value_string: str, min_val: int, max_val: int, allow_zero: bool = True) -> List[int]: + """ + Parse comma-separated list of integers with validation + + Args: + value_string: Comma-separated string of integers + min_val: Minimum allowed value + max_val: Maximum allowed value + allow_zero: Whether zero is allowed + + Returns: + List of validated integers + """ + values = [] + parts = value_string.split(',') + + for part in parts: + part = part.strip() + if part: + try: + val = int(part) + if not allow_zero and val == 0: + raise RecurrenceParseError(f"Zero not allowed in this context: {part}") + if val < min_val or val > max_val: + raise RecurrenceParseError(f"Value {val} out of range [{min_val}, {max_val}]") + values.append(val) + except ValueError: + raise RecurrenceParseError(f"Invalid integer value: {part}") + + return values + + @staticmethod + def parse_by_day(value_string: str) -> List[str]: + """ + Parse BYDAY parameter (e.g., "MO,TU,WE" or "1MO,-1FR") + + Args: + value_string: BYDAY parameter value + + Returns: + List of weekday specifications + """ + weekdays = [] + parts = value_string.split(',') + + weekday_pattern = re.compile(r'^([+-]?\d*)([A-Z]{2})$') + + for part in parts: + part = part.strip().upper() + if part: + match = weekday_pattern.match(part) + if not match: + raise RecurrenceParseError(f"Invalid BYDAY value: {part}") + + ordinal, weekday = match.groups() + + if weekday not in RecurrenceParser.WEEKDAY_MAP: + raise RecurrenceParseError(f"Invalid weekday: {weekday}") + + # Validate ordinal if present + if ordinal: + try: + ord_val = int(ordinal) + if ord_val == 0 or abs(ord_val) > 53: + raise RecurrenceParseError(f"Invalid ordinal in BYDAY: {ordinal}") + except ValueError: + raise RecurrenceParseError(f"Invalid ordinal in BYDAY: {ordinal}") + + weekdays.append(part) + + return weekdays + + +class EventInstanceGenerator: + """ + Generator for creating event instances from recurring event templates + """ + + @staticmethod + def generate_instances( + recurring_event, + start_date: datetime, + end_date: datetime, + max_instances: int = 1000 + ) -> List[Dict[str, Any]]: + """ + Generate event instances for a recurring event within a date range + + Args: + recurring_event: RecurringEvent model instance + start_date: Start of the generation window + end_date: End of the generation window + max_instances: Maximum number of instances to generate + + Returns: + List of event instance data dictionaries + """ + if not recurring_event.rrule_freq: + logger.warning(f"No RRULE frequency found for recurring event {recurring_event.recurring_event_id}") + return [] + + try: + # Build dateutil rrule from recurring event data + rrule_obj = EventInstanceGenerator._build_rrule(recurring_event, start_date, end_date) + + # Generate base occurrences from RRULE + base_occurrences = list(rrule_obj) + + # Add RDATE occurrences + rdate_occurrences = EventInstanceGenerator._get_rdate_occurrences( + recurring_event, start_date, end_date + ) + + # Combine and sort all occurrences + all_occurrences = sorted(set(base_occurrences + rdate_occurrences)) + + # Remove EXDATE occurrences + filtered_occurrences = EventInstanceGenerator._filter_exdate_occurrences( + all_occurrences, recurring_event + ) + + # Limit to max_instances + if len(filtered_occurrences) > max_instances: + filtered_occurrences = filtered_occurrences[:max_instances] + logger.warning(f"Limited instances to {max_instances} for recurring event {recurring_event.recurring_event_id}") + + # Generate event instance data + instances = [] + for occurrence_dt in filtered_occurrences: + instance_data = EventInstanceGenerator._create_instance_data( + recurring_event, occurrence_dt + ) + instances.append(instance_data) + + return instances + + except Exception as e: + logger.error(f"Error generating instances for recurring event {recurring_event.recurring_event_id}: {e}") + raise RecurrenceParseError(f"Failed to generate event instances: {e}") + + @staticmethod + def _build_rrule(recurring_event, start_date: datetime, end_date: datetime): + """Build dateutil rrule object from RecurringEvent data""" + + # Get frequency + freq = RecurrenceParser.FREQ_MAP[recurring_event.rrule_freq.value] + + # Start from DTSTART + dtstart = recurring_event.dtstart + + # Build rrule parameters + rrule_kwargs = { + 'freq': freq, + 'dtstart': dtstart, + 'interval': recurring_event.rrule_interval or 1, + } + + # Add UNTIL or COUNT + if recurring_event.rrule_until: + rrule_kwargs['until'] = min(recurring_event.rrule_until, end_date) + elif recurring_event.rrule_count: + rrule_kwargs['count'] = recurring_event.rrule_count + else: + # Limit to end_date if no UNTIL or COUNT specified + rrule_kwargs['until'] = end_date + + # Add BY* parameters + if recurring_event.rrule_by_second: + rrule_kwargs['bysecond'] = recurring_event.rrule_by_second + if recurring_event.rrule_by_minute: + rrule_kwargs['byminute'] = recurring_event.rrule_by_minute + if recurring_event.rrule_by_hour: + rrule_kwargs['byhour'] = recurring_event.rrule_by_hour + if recurring_event.rrule_by_day: + rrule_kwargs['byweekday'] = EventInstanceGenerator._parse_by_day_for_rrule( + recurring_event.rrule_by_day + ) + if recurring_event.rrule_by_monthday: + rrule_kwargs['bymonthday'] = recurring_event.rrule_by_monthday + if recurring_event.rrule_by_yearday: + rrule_kwargs['byyearday'] = recurring_event.rrule_by_yearday + if recurring_event.rrule_by_weekno: + rrule_kwargs['byweekno'] = recurring_event.rrule_by_weekno + if recurring_event.rrule_by_month: + rrule_kwargs['bymonth'] = recurring_event.rrule_by_month + if recurring_event.rrule_by_setpos: + rrule_kwargs['bysetpos'] = recurring_event.rrule_by_setpos + if recurring_event.rrule_wkst: + rrule_kwargs['wkst'] = RecurrenceParser.WEEKDAY_MAP[recurring_event.rrule_wkst] + + return rrule.rrule(**rrule_kwargs) + + @staticmethod + def _parse_by_day_for_rrule(by_day_list: List[str]) -> List: + """Convert BYDAY strings to dateutil weekday objects""" + weekdays = [] + weekday_pattern = re.compile(r'^([+-]?\d*)([A-Z]{2})$') + + for by_day in by_day_list: + match = weekday_pattern.match(by_day) + if match: + ordinal_str, weekday_str = match.groups() + weekday_obj = RecurrenceParser.WEEKDAY_MAP[weekday_str] + + if ordinal_str: + ordinal = int(ordinal_str) + weekdays.append(weekday_obj(ordinal)) + else: + weekdays.append(weekday_obj) + + return weekdays + + @staticmethod + def _get_rdate_occurrences(recurring_event, start_date: datetime, end_date: datetime) -> List[datetime]: + """Get RDATE occurrences within the date range""" + if not recurring_event.rdate_list: + return [] + + rdate_occurrences = [] + for rdate_str in recurring_event.rdate_list: + try: + rdate_dt = date_parser.isoparse(rdate_str) + if start_date <= rdate_dt <= end_date: + rdate_occurrences.append(rdate_dt) + except Exception as e: + logger.warning(f"Failed to parse RDATE '{rdate_str}': {e}") + + return rdate_occurrences + + @staticmethod + def _filter_exdate_occurrences(occurrences: List[datetime], recurring_event) -> List[datetime]: + """Remove EXDATE occurrences from the list""" + if not recurring_event.exdate_list: + return occurrences + + exdates = set() + for exdate_str in recurring_event.exdate_list: + try: + exdate_dt = date_parser.isoparse(exdate_str) + exdates.add(exdate_dt) + except Exception as e: + logger.warning(f"Failed to parse EXDATE '{exdate_str}': {e}") + + return [occ for occ in occurrences if occ not in exdates] + + @staticmethod + def _create_instance_data(recurring_event, occurrence_dt: datetime) -> Dict[str, Any]: + """Create event instance data from recurring event template and occurrence datetime""" + + # Calculate event duration from template + duration = recurring_event.template_end_datetime - recurring_event.template_start_datetime + + # Calculate instance start and end times + instance_start = occurrence_dt + instance_end = occurrence_dt + duration + + # Build event instance data + instance_data = { + 'recurring_event_id': recurring_event.recurring_event_id, + 'calendar_id': recurring_event.calendar_id, + 'user_id': recurring_event.user_id, + 'summary': recurring_event.summary, + 'description': recurring_event.description, + 'location': recurring_event.location, + 'start_datetime': instance_start, + 'end_datetime': instance_end, + 'start_timezone': recurring_event.template_start_timezone, + 'end_timezone': recurring_event.template_end_timezone, + 'status': recurring_event.status, + 'visibility': recurring_event.visibility, + 'color_id': recurring_event.color_id, + 'eventType': recurring_event.eventType, + 'guestsCanInviteOthers': recurring_event.guestsCanInviteOthers, + 'guestsCanModify': recurring_event.guestsCanModify, + 'guestsCanSeeOtherGuests': recurring_event.guestsCanSeeOtherGuests, + 'transparency': recurring_event.transparency, + 'privateCopy': recurring_event.privateCopy, + 'locked': recurring_event.locked, + 'sequence': recurring_event.sequence, + 'focusTimeProperties': recurring_event.focusTimeProperties, + 'outOfOfficeProperties': recurring_event.outOfOfficeProperties, + 'source': recurring_event.source, + # Set original start time for recurring event instances + 'originalStartTime_dateTime': instance_start, + 'originalStartTime_timeZone': recurring_event.template_start_timezone, + # Reference to parent recurring event + 'recurringEventId': recurring_event.recurring_event_id, + } + + return instance_data \ No newline at end of file diff --git a/envs/calendar_env/server/utils/validation.py b/envs/calendar_env/server/utils/validation.py new file mode 100644 index 000000000..8eba158cc --- /dev/null +++ b/envs/calendar_env/server/utils/validation.py @@ -0,0 +1,216 @@ +""" +Validation utilities for Calendar API +Provides common validation functions used across different API endpoints +""" + +from typing import Optional, Tuple +from database.managers.color_manager import ColorManager +from apis.colors.data import CALENDAR_COLORS, EVENT_COLORS + + +def validate_calendar_color_id(color_id: Optional[str], database_id: str) -> Optional[str]: + """ + Validate calendar colorId against database + + Args: + color_id: The color ID to validate (can be None) + database_id: Database ID for color manager + + Returns: + None if validation passes, error message if invalid + """ + if color_id is None: + return None # Optional field, None is valid + + if not isinstance(color_id, str): + return "colorId must be a string" + + try: + color_manager = ColorManager(database_id) + if not color_manager.validate_color_id("calendar", color_id): + return f"Invalid calendar colorId: '{color_id}'. Check available colorId with GET /colors" + except Exception: + # Fallback validation if database fails + return f"Could not validate colorId: '{color_id}'. Database may not be initialized" + + return None + + +def validate_event_color_id(color_id: Optional[str], database_id: str) -> Optional[str]: + """ + Validate event colorId against database + + Args: + color_id: The color ID to validate (can be None) + database_id: Database ID for color manager + + Returns: + None if validation passes, error message if invalid + """ + if color_id is None: + return None # Optional field, None is valid + + if not isinstance(color_id, str): + return "colorId must be a string" + + try: + color_manager = ColorManager(database_id) + if not color_manager.validate_color_id("event", color_id): + return f"Invalid event colorId: '{color_id}'. Check available colors with GET /colors" + except Exception: + # Fallback validation if database fails + return f"Could not validate colorId: '{color_id}'. Database may not be initialized" + + return None + + +def validate_request_colors(data: dict, color_type: str, database_id: str) -> Optional[str]: + """ + Validate colorId in request data against database + + Args: + data: Request data dictionary + color_type: Either 'calendar' or 'event' + database_id: Database ID for color manager + + Returns: + None if validation passes, error message if invalid + """ + if "colorId" not in data: + return None # No colorId in request, that's fine + + color_id = data.get("colorId") + + if color_type == "calendar": + return validate_calendar_color_id(color_id, database_id) + elif color_type == "event": + return validate_event_color_id(color_id, database_id) + else: + return f"Unknown color type: {color_type}" + + +def validate_color_combination(background_color: str, foreground_color: str, color_type: str) -> Optional[str]: + """ + Validate that backgroundColor and foregroundColor combination exists in color data + + Args: + background_color: Background color in hex format (e.g., "#ac725e") + foreground_color: Foreground color in hex format (e.g., "#1d1d1d") + color_type: Either 'calendar' or 'event' + + Returns: + None if combination is valid, error message if invalid + """ + if color_type == "calendar": + colors = CALENDAR_COLORS + elif color_type == "event": + colors = EVENT_COLORS + else: + return f"Unknown color type: {color_type}" + + # Check if the combination exists in the color data + for color_id, color_data in colors.items(): + if (color_data["background"].lower() == background_color.lower() and + color_data["foreground"].lower() == foreground_color.lower()): + return None # Valid combination found + + return f"Invalid color combination: backgroundColor='{background_color}' and foregroundColor='{foreground_color}' is not a valid {color_type} color combination" + + +def find_color_id_by_combination(background_color: str, foreground_color: str, color_type: str) -> Optional[str]: + """ + Find the colorId that matches the given backgroundColor and foregroundColor combination + + Args: + background_color: Background color in hex format (e.g., "#ac725e") + foreground_color: Foreground color in hex format (e.g., "#1d1d1d") + color_type: Either 'calendar' or 'event' + + Returns: + colorId if combination is found, None if not found + """ + if color_type == "calendar": + colors = CALENDAR_COLORS + elif color_type == "event": + colors = EVENT_COLORS + else: + return None + + # Find the colorId that matches the combination + for color_id, color_data in colors.items(): + if (color_data["background"].lower() == background_color.lower() and + color_data["foreground"].lower() == foreground_color.lower()): + return color_id + + return None + + +def set_colors_from_color_id(data: dict, color_type: str) -> Optional[str]: + """ + Set backgroundColor and foregroundColor from colorId if they are not provided + + Args: + data: Request data dictionary (will be modified to add colors if colorId is valid) + color_type: Either 'calendar' or 'event' + + Returns: + None if successful, error message if colorId is invalid + """ + color_id = data.get("colorId") + + # Only process if colorId is provided and RGB colors are not provided + if not color_id: + return None + + # Skip if RGB colors are already provided + if data.get("backgroundColor") or data.get("foregroundColor"): + return None + + if color_type == "calendar": + colors = CALENDAR_COLORS + elif color_type == "event": + colors = EVENT_COLORS + else: + return f"Unknown color type: {color_type}" + + # Validate colorId and get colors + if color_id not in colors: + return f"Invalid {color_type} colorId: '{color_id}'. Check available colorId with GET /colors" + + # Set the colors from the colorId + color_data = colors[color_id] + data["backgroundColor"] = color_data["background"] + data["foregroundColor"] = color_data["foreground"] + + return None + + +def validate_and_set_color_id(data: dict, color_type: str) -> Optional[str]: + """ + Validate RGB color combination and set appropriate colorId if valid combination exists + + Args: + data: Request data dictionary (will be modified to add colorId if found) + color_type: Either 'calendar' or 'event' + + Returns: + None if validation passes, error message if invalid combination + """ + background_color = data.get("backgroundColor") + foreground_color = data.get("foregroundColor") + + # Only validate if both colors are provided + if not (background_color and foreground_color): + return None + + # Validate the combination exists + validation_error = validate_color_combination(background_color, foreground_color, color_type) + if validation_error: + return validation_error + + # Find and set the matching colorId + color_id = find_color_id_by_combination(background_color, foreground_color, color_type) + if color_id: + data["colorId"] = color_id + + return None \ No newline at end of file From 1703dd76b3805387c42e7bbf9be3a01fa1f2256a Mon Sep 17 00:00:00 2001 From: Santosh Sah Date: Wed, 28 Jan 2026 23:11:55 +0530 Subject: [PATCH 2/5] Deleted some test files to comply with limit of 100 mac files --- .../server/tests_tools/__init__.py | 0 .../server/tests_tools/conftest.py | 122 ---- .../server/tests_tools/debug_mcp_request.py | 40 -- .../tests_tools/test_mcp_calendars_all.py | 406 ----------- .../server/tests_tools/test_mcp_colors.py | 359 ---------- .../server/tests_tools/test_mcp_core_all.py | 261 ------- .../server/tests_tools/test_mcp_errors.py | 128 ---- .../server/tests_tools/test_mcp_events_all.py | 640 ------------------ .../server/tests_tools/test_mcp_scenarios.py | 157 ----- .../server/tests_tools/test_mcp_settings.py | 358 ---------- .../server/tests_tools/test_mcp_tools_list.py | 71 -- .../tests_tools/test_tool_handlers_generic.py | 227 ------- 12 files changed, 2769 deletions(-) delete mode 100644 envs/calendar_env/server/tests_tools/__init__.py delete mode 100644 envs/calendar_env/server/tests_tools/conftest.py delete mode 100644 envs/calendar_env/server/tests_tools/debug_mcp_request.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_colors.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_core_all.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_errors.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_events_all.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_scenarios.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_settings.py delete mode 100644 envs/calendar_env/server/tests_tools/test_mcp_tools_list.py delete mode 100644 envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py diff --git a/envs/calendar_env/server/tests_tools/__init__.py b/envs/calendar_env/server/tests_tools/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/envs/calendar_env/server/tests_tools/conftest.py b/envs/calendar_env/server/tests_tools/conftest.py deleted file mode 100644 index 9aceeee97..000000000 --- a/envs/calendar_env/server/tests_tools/conftest.py +++ /dev/null @@ -1,122 +0,0 @@ -# calendar/tests_tools/conftest.py -import importlib -import importlib.util -import os -import sqlite3 -import types -from pathlib import Path -import pytest - -# ---- How we load the tools registry ---- -TOOLS_MODULE_ENV = "CAL_TOOLS_MODULE" # optional -TOOLS_FILE_ENV = "CAL_TOOLS_PATH" # preferred for our adapter - -def _import_module_from_path(path: str, name: str = "calendar_tools_adapter") -> types.ModuleType: - p = Path(path).expanduser().resolve() - if not p.exists(): - raise RuntimeError(f"{TOOLS_FILE_ENV} points to missing file: {p}") - spec = importlib.util.spec_from_file_location(name, str(p)) - mod = importlib.util.module_from_spec(spec) - assert spec and spec.loader - spec.loader.exec_module(mod) - return mod - -@pytest.fixture(scope="session") -def tools_root_module() -> types.ModuleType: - """ - Prefer an explicit adapter module if provided via env vars. - Falls back to the legacy package only if neither env var is set. - """ - file_path = os.getenv(TOOLS_FILE_ENV) - if file_path: - return _import_module_from_path(file_path) - - mod_name = os.getenv(TOOLS_MODULE_ENV) - if mod_name: - return importlib.import_module(mod_name) - - # legacy fallback (not recommended because of stdlib 'calendar' collision) - return importlib.import_module("calendar_mcp.tools") - -@pytest.fixture(scope="session") -def tool_registry(tools_root_module): - """ - Expect the adapter to expose a TOOLS dict {name: handler}. - """ - reg = getattr(tools_root_module, "TOOLS", None) - assert isinstance(reg, dict) and reg, "Adapter did not expose any tools via TOOLS" - print(f"\n[conftest] tool registry size: {len(reg)}") - return reg - -# ---- Convenience fixtures the tests expect ---- -@pytest.fixture(scope="session") -def all_tool_names(tool_registry): - return sorted(tool_registry.keys()) - -@pytest.fixture() -def tool_call(tool_registry): - def _invoke(name: str, params: dict): - handler = tool_registry[name] # let KeyError surface if test asked for wrong name - try: - res = handler(params or {}) - if isinstance(res, tuple) and len(res) == 2 and isinstance(res[0], bool): - return res[0], res[1] - return True, res - except Exception as e: - return False, {"error": str(e)} - return _invoke - -@pytest.fixture() -def pick_tool(all_tool_names): - lowered = [n.lower() for n in all_tool_names] - def _pick(*hints: str) -> str: - for h in hints: - if h in all_tool_names: - return h - for h in hints: - hl = h.lower() - if hl in lowered: - return all_tool_names[lowered.index(hl)] - for h in hints: - hl = h.lower() - for i, n in enumerate(lowered): - if hl in n: - return all_tool_names[i] - raise KeyError(f"No tool matches {hints}. Sample: {all_tool_names[:10]}") - return _pick - -# ---- Minimal DB seed so tools can run ---- -@pytest.fixture(scope="session") -def sample_sql() -> str: - return """ - PRAGMA foreign_keys=ON; - CREATE TABLE IF NOT EXISTS users (user_id TEXT PRIMARY KEY, email TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS calendars (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, summary TEXT); - CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, calendar_id TEXT NOT NULL, summary TEXT, start TEXT, end TEXT); - INSERT OR IGNORE INTO users(user_id, email) VALUES ('alice_manager','alice.manager@techcorp.com'); - INSERT OR IGNORE INTO calendars(id, user_id, summary) VALUES ('alice-primary','alice_manager','Alice Primary'), - ('alice-projects','alice_manager','Alice Projects'); - INSERT OR IGNORE INTO events(id, calendar_id, summary, start, end) - VALUES ('ev1','alice-projects','Kickoff','2025-10-07T10:00:00Z','2025-10-07T11:00:00Z'); - """ - -@pytest.fixture() -def seeded_db(tmp_path, sample_sql): - db_dir = tmp_path / "mcp_databases" - db_dir.mkdir(exist_ok=True) - dbid = "pytestdb" - db_path = db_dir / f"{dbid}.sqlite" - con = sqlite3.connect(db_path) - try: - con.executescript(sample_sql) - con.commit() - finally: - con.close() - return { - "database_id": dbid, - "user_id": "alice_manager", - "email": "alice.manager@techcorp.com", - "primary_calendar_id": "alice-primary", - "some_calendar_id": "alice-projects", - "db_path": str(db_path), - } diff --git a/envs/calendar_env/server/tests_tools/debug_mcp_request.py b/envs/calendar_env/server/tests_tools/debug_mcp_request.py deleted file mode 100644 index 7fbd03d23..000000000 --- a/envs/calendar_env/server/tests_tools/debug_mcp_request.py +++ /dev/null @@ -1,40 +0,0 @@ -import sys, os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler - - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - data = await request.json() - print("\n=== RAW JSON BODY RECEIVED BY FASTAPI ===") - print(data) - result = await mcp_handler.handle_mcp_request(request) - print("\n=== RESULT FROM HANDLER ===") - print(result) - return result - - -if __name__ == "__main__": - client = TestClient(app) - - # Try variant 1: database_id at the same level as "name" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "create_calendar", - "database_id": "test-db-001", - "user_id": "test-user-001", - "arguments": {"summary": "Team Coordination"} - }, - "id": 1 - } - - r = client.post("/mcp", json=payload) - print("\n=== HTTP STATUS:", r.status_code, "===") - print(r.json()) diff --git a/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py b/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py deleted file mode 100644 index abecc9f1c..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_calendars_all.py +++ /dev/null @@ -1,406 +0,0 @@ -""" -End-to-end MCP tests for all Calendar tools via /mcp endpoint. -All tool executions and user lookups are mocked so tests run offline. -Covers: create, get, patch, update, delete, clear. -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app mounting the /mcp route -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Route that forwards requests to the real MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixture: fake UserManager to bypass DB lookups -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - """Bypass real DB access inside UserManager for tests.""" - class DummyUserManager: - def __init__(self, dbid): - self.dbid = dbid - def get_first_user_from_db(self): - return {"id": "test-user-001"} - def get_user_by_access_token(self, token): - return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Fixture: fake execute_tool_generic to simulate tool responses -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Patch execute_tool_generic so each tool returns predictable mock data.""" - # Mock the MCP_TOOLS list to include calendar list tools - from calendar_mcp.tools.calendar_list import CALENDAR_LIST_TOOLS - monkeypatch.setattr(tool_handlers, "MCP_TOOLS", CALENDAR_LIST_TOOLS) - - async def _fake(tool_name, tool_input, database_id, user_id): - import json as json_module - - if tool_name == "create_calendar": - data = {"id": "alice-team", "summary": tool_input.get("summary")} - return {"text": json_module.dumps(data), "isError": False} - if tool_name == "get_calendar": - data = {"id": tool_input.get("calendarId"), "summary": "Alice Johnson"} - return {"text": json_module.dumps(data), "isError": False} - if tool_name == "patch_calendar": - data = {"id": tool_input.get("calendarId"), "summary": tool_input.get("summary", "patched")} - return {"text": json_module.dumps(data), "isError": False} - if tool_name == "update_calendar": - data = {"id": tool_input.get("calendarId"), "summary": "Updated Full Calendar"} - return {"text": json_module.dumps(data), "isError": False} - if tool_name == "replace_calendar_in_list": - calendar_id = tool_input.get("calendarId") - if not calendar_id: - return {"isError": True, "text": "Calendar ID is required"} - - data = { - "kind": "calendar#calendarListEntry", - "etag": "etag-replaced", - "id": calendar_id, - "summary": tool_input.get("summaryOverride", "Replaced Calendar"), - "summaryOverride": tool_input.get("summaryOverride"), - "colorId": tool_input.get("colorId"), - "backgroundColor": tool_input.get("backgroundColor"), - "foregroundColor": tool_input.get("foregroundColor"), - "hidden": tool_input.get("hidden", False), - "selected": tool_input.get("selected", True), - "accessRole": "owner", - "defaultReminders": tool_input.get("defaultReminders", []) - } - return {"text": json_module.dumps(data), "isError": False} - if tool_name in ("delete_calendar", "clear_calendar"): - return {"text": "{}", "isError": False} - if tool_name == "watch_calendar_list": - channel_id = tool_input.get("id") - address = tool_input.get("address") - - if not address: - return {"isError": True, "text": "Webhook address is required"} - - if not channel_id: - return {"isError": True, "text": "Channel ID is required"} - - data = { - "kind": "api#channel", - "id": channel_id, - "resourceId": f"calendar-list-{user_id}", - "resourceUri": "/users/me/calendarList", - "token": tool_input.get("token", ""), - "expiration": "1735689600000", - "type": "web_hook", - "address": address - } - return {"text": json_module.dumps(data), "isError": False} - return {"isError": True, "text": f"Unhandled tool {tool_name}"} - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - - # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic - tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in CALENDAR_LIST_TOOLS} - - # Also patch TOOL_HANDLERS in mcp_handler module since it imports it - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "TOOL_HANDLERS", tool_handlers.TOOL_HANDLERS) - - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Helper to send JSON-RPC calls to /mcp with required headers -# ------------------------------------------------------------------------------ - -def rpc_call(tool_name, arguments, rpc_id=1): - """Send a JSON-RPC call including headers required by MCP handler.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - }, - "id": rpc_id - } - headers = { - "x-database-id": "test-db-001", - "x-access-token": "dummy-static-token" - } - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# Tests for each calendar tool -# ------------------------------------------------------------------------------ - -def test_mcp_create_calendar(): - """ create_calendar → should succeed and return mocked response""" - resp = rpc_call("create_calendar", {"summary": "Team Coordination"}) - assert resp.status_code == 200 - result = resp.json()["result"] - # Validate basic MCP structure - assert "content" in result - text = result["content"][0]["text"] - assert "" in text or "completed" in text - - -def test_mcp_get_calendar(): - """ get_calendar → should succeed""" - resp = rpc_call("get_calendar", {"calendarId": "alice-primary"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_patch_calendar(): - """ patch_calendar → should succeed""" - resp = rpc_call("patch_calendar", {"calendarId": "alice-primary", "summary": "Updated Title"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_update_calendar(): - """ update_calendar → should succeed""" - resp = rpc_call("update_calendar", {"calendarId": "bob-development"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_delete_calendar(): - """ delete_calendar → should succeed""" - resp = rpc_call("delete_calendar", {"calendarId": "carol-design"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_clear_calendar(): - """ clear_calendar → should succeed""" - resp = rpc_call("clear_calendar", {"calendarId": "dave-primary"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_watch_calendar_list_success(): - """watch_calendar_list → should successfully create watch channel (positive)""" - resp = rpc_call("watch_calendar_list", { - "id": "test-watch-channel-001", - "type": "web_hook", - "address": "https://example.com/webhook", - "token": "verification-token-123" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "test-watch-channel-001" in text or "" in text - - -def test_mcp_watch_calendar_list_missing_address(): - """watch_calendar_list → should fail when address is missing (negative)""" - resp = rpc_call("watch_calendar_list", { - "id": "test-watch-channel-002", - "type": "web_hook" - # Missing address - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "address" in text.lower() or "required" in text.lower() - - -def test_mcp_watch_calendar_list_missing_channel_id(): - """watch_calendar_list → should fail when channel ID is missing (negative)""" - resp = rpc_call("watch_calendar_list", { - "type": "web_hook", - "address": "https://example.com/webhook" - # Missing id - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "channel" in text.lower() or "id" in text.lower() or "required" in text.lower() - - -def test_mcp_watch_calendar_list_with_params(): - """watch_calendar_list → should succeed with additional params (positive)""" - resp = rpc_call("watch_calendar_list", { - "id": "test-watch-channel-003", - "type": "web_hook", - "address": "https://example.com/webhook", - "token": "verification-token-456", - "params": { - "ttl": "3600" - } - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "test-watch-channel-003" in text or "" in text - - -def test_mcp_watch_calendar_list_minimal(): - """watch_calendar_list → should succeed with minimal required params (positive)""" - resp = rpc_call("watch_calendar_list", { - "id": "minimal-watch-channel", - "type": "web_hook", - "address": "https://minimal.example.com/hook" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "minimal-watch-channel" in text or "" in text - - -def test_mcp_replace_calendar_in_list_full_update(): - """replace_calendar_in_list → should perform full update with all fields (positive)""" - resp = rpc_call("replace_calendar_in_list", { - "calendarId": "test-calendar-001", - "summaryOverride": "Fully Replaced Calendar", - "colorId": "5", - "hidden": False, - "selected": True, - "defaultReminders": [ - {"method": "email", "minutes": 30}, - {"method": "popup", "minutes": 10} - ] - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains updated data - assert "calendar#calendarListEntry" in text or "Fully Replaced Calendar" in text or "" in text - - -def test_mcp_replace_calendar_in_list_minimal(): - """replace_calendar_in_list → should succeed with minimal required params (positive)""" - resp = rpc_call("replace_calendar_in_list", { - "calendarId": "test-calendar-002" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains calendar data - assert "test-calendar-002" in text or "calendar#calendarListEntry" in text or "" in text - - -def test_mcp_replace_calendar_in_list_with_colors(): - """replace_calendar_in_list → should update with RGB colors (positive)""" - resp = rpc_call("replace_calendar_in_list", { - "calendarId": "test-calendar-003", - "colorRgbFormat": True, - "backgroundColor": "#FF5733", - "foregroundColor": "#FFFFFF", - "summaryOverride": "Custom Colored Calendar" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains color data - assert "#FF5733" in text or "#FFFFFF" in text or "Custom Colored Calendar" in text or "" in text - - -def test_mcp_replace_calendar_in_list_hidden(): - """replace_calendar_in_list → should update hidden status (positive)""" - resp = rpc_call("replace_calendar_in_list", { - "calendarId": "test-calendar-004", - "hidden": True, - "selected": False - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains updated data - assert "test-calendar-004" in text or "" in text - - -def test_mcp_replace_calendar_in_list_missing_calendar_id(): - """replace_calendar_in_list → should fail when calendarId is missing (negative)""" - resp = rpc_call("replace_calendar_in_list", { - "summaryOverride": "No Calendar ID" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "calendar" in text.lower() or "id" in text.lower() or "required" in text.lower() - - -def test_mcp_replace_calendar_in_list_with_reminders(): - """replace_calendar_in_list → should update with default reminders (positive)""" - resp = rpc_call("replace_calendar_in_list", { - "calendarId": "test-calendar-005", - "summaryOverride": "Calendar with Reminders", - "defaultReminders": [ - {"method": "email", "minutes": 60}, - {"method": "popup", "minutes": 15} - ] - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains reminder data - assert "email" in text or "popup" in text or "Calendar with Reminders" in text or "" in text - diff --git a/envs/calendar_env/server/tests_tools/test_mcp_colors.py b/envs/calendar_env/server/tests_tools/test_mcp_colors.py deleted file mode 100644 index 444e2a973..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_colors.py +++ /dev/null @@ -1,359 +0,0 @@ -""" -End-to-end MCP tests for Colors tool via /mcp endpoint. -All tool executions and user lookups are mocked so tests run offline. -Covers: get_colors (the only colors endpoint). -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app mounting the /mcp route -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Route that forwards requests to the real MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixture: fake UserManager to bypass DB lookups -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - """Bypass real DB access inside UserManager for tests.""" - class DummyUserManager: - def __init__(self, dbid): - self.dbid = dbid - def get_first_user_from_db(self): - return {"id": "test-user-001"} - def get_user_by_access_token(self, token): - return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Fixture: fake execute_tool_generic to simulate tool responses -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Patch execute_tool_generic so each tool returns predictable mock data.""" - # Mock the MCP_TOOLS list to include colors tools - from calendar_mcp.tools.colors import COLORS_TOOLS - monkeypatch.setattr(tool_handlers, "MCP_TOOLS", COLORS_TOOLS) - - async def _fake(tool_name, tool_input, database_id, user_id): - import json as json_module - - if tool_name == "get_colors": - # Return comprehensive color data matching Google Calendar API v3 format - data = { - "kind": "calendar#colors", - "updated": "2024-01-15T12:00:00.000Z", - "calendar": { - "1": { - "background": "#ac725e", - "foreground": "#1d1d1d" - }, - "2": { - "background": "#d06b64", - "foreground": "#1d1d1d" - }, - "3": { - "background": "#f83a22", - "foreground": "#1d1d1d" - }, - "4": { - "background": "#fa573c", - "foreground": "#1d1d1d" - }, - "5": { - "background": "#ff6b6b", - "foreground": "#1d1d1d" - }, - "6": { - "background": "#ffad46", - "foreground": "#1d1d1d" - }, - "7": { - "background": "#42d692", - "foreground": "#1d1d1d" - }, - "8": { - "background": "#16a765", - "foreground": "#1d1d1d" - }, - "9": { - "background": "#7bd148", - "foreground": "#1d1d1d" - }, - "10": { - "background": "#b3dc6c", - "foreground": "#1d1d1d" - }, - "11": { - "background": "#fbe983", - "foreground": "#1d1d1d" - }, - "12": { - "background": "#fad165", - "foreground": "#1d1d1d" - }, - "13": { - "background": "#92e1c0", - "foreground": "#1d1d1d" - }, - "14": { - "background": "#9fe1e7", - "foreground": "#1d1d1d" - }, - "15": { - "background": "#9fc6e7", - "foreground": "#1d1d1d" - }, - "16": { - "background": "#4986e7", - "foreground": "#1d1d1d" - }, - "17": { - "background": "#9a9cff", - "foreground": "#1d1d1d" - }, - "18": { - "background": "#b99aff", - "foreground": "#1d1d1d" - }, - "19": { - "background": "#c2c2c2", - "foreground": "#1d1d1d" - }, - "20": { - "background": "#cabdbf", - "foreground": "#1d1d1d" - }, - "21": { - "background": "#cca6ac", - "foreground": "#1d1d1d" - }, - "22": { - "background": "#f691b2", - "foreground": "#1d1d1d" - }, - "23": { - "background": "#cd74e6", - "foreground": "#1d1d1d" - }, - "24": { - "background": "#a47ae2", - "foreground": "#1d1d1d" - } - }, - "event": { - "1": { - "background": "#a4bdfc", - "foreground": "#1d1d1d" - }, - "2": { - "background": "#7ae7bf", - "foreground": "#1d1d1d" - }, - "3": { - "background": "#dbadff", - "foreground": "#1d1d1d" - }, - "4": { - "background": "#ff887c", - "foreground": "#1d1d1d" - }, - "5": { - "background": "#fbd75b", - "foreground": "#1d1d1d" - }, - "6": { - "background": "#ffb878", - "foreground": "#1d1d1d" - }, - "7": { - "background": "#46d6db", - "foreground": "#1d1d1d" - }, - "8": { - "background": "#e1e1e1", - "foreground": "#1d1d1d" - }, - "9": { - "background": "#5484ed", - "foreground": "#1d1d1d" - }, - "10": { - "background": "#51b749", - "foreground": "#1d1d1d" - }, - "11": { - "background": "#dc2127", - "foreground": "#1d1d1d" - } - } - } - return {"text": json_module.dumps(data), "isError": False} - - return {"isError": True, "text": f"Unhandled tool {tool_name}"} - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - - # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic - tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in COLORS_TOOLS} - - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Helper to send JSON-RPC calls to /mcp with required headers -# ------------------------------------------------------------------------------ - -def rpc_call(tool_name, arguments, rpc_id=1): - """Send a JSON-RPC call including headers required by MCP handler.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - }, - "id": rpc_id - } - headers = { - "x-database-id": "test-db-001", - "x-access-token": "dummy-static-token" - } - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# Tests for colors tool -# ------------------------------------------------------------------------------ - -def test_mcp_get_colors_success(): - """get_colors → should successfully retrieve all color definitions (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - # Validate basic MCP structure - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains color data - assert "calendar#colors" in text or "calendar" in text or "" in text - - -def test_mcp_get_colors_has_updated_timestamp(): - """get_colors → should include updated timestamp (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify updated timestamp is present - assert "updated" in text or "2024" in text or "" in text - - -def test_mcp_get_colors_multiple_calls(): - """get_colors → should handle multiple consecutive calls (positive)""" - resp1 = rpc_call("get_colors", {}, rpc_id=1) - resp2 = rpc_call("get_colors", {}, rpc_id=2) - resp3 = rpc_call("get_colors", {}, rpc_id=3) - - assert resp1.status_code == 200 - assert resp2.status_code == 200 - assert resp3.status_code == 200 - - # All should return valid responses - result1 = resp1.json()["result"] - result2 = resp2.json()["result"] - result3 = resp3.json()["result"] - - assert "content" in result1 - assert "content" in result2 - assert "content" in result3 - - -def test_mcp_get_colors_no_parameters_required(): - """get_colors → should work without any parameters (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Should still return valid color data - assert "calendar#colors" in text or "calendar" in text or "" in text - - -def test_mcp_get_colors_with_empty_arguments(): - """get_colors → should handle empty arguments object (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - - -def test_mcp_get_colors_idempotent(): - """get_colors → should return same data on repeated calls (positive)""" - resp1 = rpc_call("get_colors", {}) - resp2 = rpc_call("get_colors", {}) - - assert resp1.status_code == 200 - assert resp2.status_code == 200 - - result1 = resp1.json()["result"] - result2 = resp2.json()["result"] - - # Both responses should have the same structure - assert "content" in result1 - assert "content" in result2 - - -def test_mcp_get_colors_calendar_color_count(): - """get_colors → should contain 24 calendar colors (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify we have calendar colors (checking for some IDs) - assert '"1"' in text or '"24"' in text or "" in text - - -def test_mcp_get_colors_event_color_count(): - """get_colors → should contain 11 event colors (positive)""" - resp = rpc_call("get_colors", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify we have event colors (checking for some IDs) - assert '"1"' in text or '"11"' in text or "" in text \ No newline at end of file diff --git a/envs/calendar_env/server/tests_tools/test_mcp_core_all.py b/envs/calendar_env/server/tests_tools/test_mcp_core_all.py deleted file mode 100644 index 125841d6e..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_core_all.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -End-to-end MCP tests for core Calendar tools via /mcp endpoint. -Mocks out database, user lookups, and tool execution. -Covers: freebusy.query, colors.get, settings.list, acl.list, acl.get, acl.insert, acl.update, acl.patch, acl.delete, acl.watch -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app for /mcp -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Forward JSON-RPC requests to the real MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixture: fake UserManager (skip DB access) -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - """Bypass DB in UserManager for tests.""" - class DummyUserManager: - def __init__(self, dbid): self.dbid = dbid - def get_first_user_from_db(self): return {"id": "test-user-001"} - def get_user_by_access_token(self, token): return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Fixture: fake execute_tool_generic for core tool behaviors -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Simulate behavior of core Calendar tools.""" - async def _fake(tool_name, tool_input, database_id, user_id): - # Simulate error conditions - if tool_name in ["get_acl_rule", "update_acl_rule", "patch_acl_rule"]: - if not tool_input.get("calendarId"): - return {"isError": True, "text": "Missing calendarId"} - if not tool_input.get("ruleId"): - return {"isError": True, "text": "Missing ruleId"} - - if tool_name == "update_acl_rule": - if not tool_input.get("scope"): - return {"isError": True, "text": "Missing scope"} - - if tool_name == "watch_acl": - if not tool_input.get("calendarId"): - return {"isError": True, "text": "Missing calendarId"} - if not tool_input.get("id"): - return {"isError": True, "text": "Missing id"} - if not tool_input.get("type"): - return {"isError": True, "text": "Missing type"} - if not tool_input.get("address"): - return {"isError": True, "text": "Missing address"} - - if tool_name == "freebusy_query": - return {"calendars": {"primary": {"busy": [{"start": "2025-10-10T09:00:00Z", "end": "2025-10-10T10:00:00Z"}]}}} - if tool_name == "colors_get": - return {"calendar": {"1": {"background": "#ff0000"}}, "event": {"2": {"background": "#00ff00"}}} - if tool_name == "settings_list": - return {"items": [{"id": "timezone", "value": "America/New_York"}]} - if tool_name == "acl_list": - return {"items": [{"role": "owner", "scope": {"type": "user", "value": "owner@example.com"}}]} - if tool_name == "acl_insert": - return {"id": "rule-123", "role": "reader"} - if tool_name == "acl_delete": - return {} - if tool_name == "get_acl_rule": - return {"id": tool_input.get("ruleId"), "role": "reader", "scope": {"type": "user", "value": "user@example.com"}} - if tool_name == "update_acl_rule": - return {"id": tool_input.get("ruleId"), "role": tool_input.get("role", "reader"), "scope": tool_input.get("scope")} - if tool_name == "patch_acl_rule": - return {"id": tool_input.get("ruleId"), "role": tool_input.get("role", "reader"), "scope": {"type": "user", "value": "updated@example.com"}} - if tool_name == "watch_acl": - return {"kind": "api#channel", "id": tool_input.get("id"), "resourceId": "acl-watch"} - return {"isError": True, "text": f"Unhandled tool {tool_name}"} - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Helper to make JSON-RPC calls to /mcp -# ------------------------------------------------------------------------------ - -def rpc_call(tool_name, arguments, rpc_id=1): - """Send JSON-RPC call to /mcp with required headers.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - "id": rpc_id, - } - headers = {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# Tests: FreeBusy -# ------------------------------------------------------------------------------ - -def test_mcp_freebusy_query(): - """ freebusy.query → should return mock busy slots.""" - resp = rpc_call("freebusy_query", {"timeMin": "2025-10-10T00:00:00Z", "timeMax": "2025-10-10T23:59:59Z"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -# ------------------------------------------------------------------------------ -# Tests: Colors -# ------------------------------------------------------------------------------ - -def test_mcp_colors_get(): - """ colors.get → should return calendar and event colors.""" - resp = rpc_call("colors_get", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -# ------------------------------------------------------------------------------ -# Tests: Settings -# ------------------------------------------------------------------------------ - -def test_mcp_settings_list(): - """ settings.list → should return user settings.""" - resp = rpc_call("settings_list", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -# ------------------------------------------------------------------------------ -# Tests: ACL (Access Control List) -# ------------------------------------------------------------------------------ - -def test_mcp_acl_list(): - """ acl.list → should return mock ACL entries.""" - resp = rpc_call("acl_list", {"calendarId": "primary"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_acl_insert(): - """ acl.insert → should insert ACL rule.""" - resp = rpc_call("acl_insert", {"calendarId": "primary", "role": "reader"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_acl_delete(): - """ acl.delete → should delete ACL rule.""" - resp = rpc_call("acl_delete", {"calendarId": "primary", "ruleId": "rule-123"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -# ------------------------------------------------------------------------------ -# Tests: ACL Rule Management -# ------------------------------------------------------------------------------ - -def test_mcp_get_acl_rule_success(): - """GOOD: get_acl_rule with valid parameters""" - resp = rpc_call("get_acl_rule", {"calendarId": "primary", "ruleId": "rule-456"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_get_acl_rule_missing_rule_id(): - """BAD: get_acl_rule without ruleId""" - resp = rpc_call("get_acl_rule", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_mcp_update_acl_rule_success(): - """GOOD: update_acl_rule with complete parameters""" - resp = rpc_call("update_acl_rule", { - "calendarId": "primary", - "ruleId": "rule-789", - "scope": {"type": "user", "value": "newuser@example.com"}, - "role": "writer", - "sendNotifications": False - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_update_acl_rule_missing_scope(): - """BAD: update_acl_rule without scope""" - resp = rpc_call("update_acl_rule", {"calendarId": "primary", "ruleId": "rule-789"}) - assert resp.status_code == 200 - - -def test_mcp_patch_acl_rule_success(): - """GOOD: patch_acl_rule with role update""" - resp = rpc_call("patch_acl_rule", { - "calendarId": "primary", - "ruleId": "rule-101", - "role": "owner" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_patch_acl_rule_missing_calendar_id(): - """BAD: patch_acl_rule without calendarId""" - resp = rpc_call("patch_acl_rule", {"ruleId": "rule-101", "role": "owner"}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# Tests: ACL Watch -# ------------------------------------------------------------------------------ - -def test_mcp_watch_acl_success(): - """GOOD: watch_acl with all required parameters""" - resp = rpc_call("watch_acl", { - "calendarId": "primary", - "id": "acl-watch-001", - "type": "web_hook", - "address": "https://example.com/acl-webhook" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_mcp_watch_acl_missing_address(): - """BAD: watch_acl without address""" - resp = rpc_call("watch_acl", { - "calendarId": "primary", - "id": "acl-watch-002", - "type": "web_hook" - }) - assert resp.status_code == 200 diff --git a/envs/calendar_env/server/tests_tools/test_mcp_errors.py b/envs/calendar_env/server/tests_tools/test_mcp_errors.py deleted file mode 100644 index efba3cb2f..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_errors.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -MCP Error-Handling Smoke Tests ------------------------------- -Verifies that /mcp endpoint correctly handles invalid JSON, -unknown tools, missing fields, and bad database headers. -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app using the real MCP handler -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# 1. Invalid JSON in request body -# ------------------------------------------------------------------------------ - -def test_mcp_invalid_json(monkeypatch): - """Should return JSON-RPC error for malformed JSON.""" - # Mock the handler to catch the validation error and return proper response - async def mock_handle_mcp_request(request): - try: - # Try to parse JSON to trigger the error - import json - await request.json() - except json.JSONDecodeError: - # Return raw dict to avoid Pydantic validation issues - return { - "jsonrpc": "2.0", - "id": None, - "result": {"error": "Invalid JSON"} - } - # If no error, call original handler - return await mcp_handler.handle_mcp_request(request) - - monkeypatch.setattr("handlers.mcp_handler.handle_mcp_request", mock_handle_mcp_request) - - # Simulate raw invalid body by bypassing FastAPI json parser - response = client.post("/mcp", data="not-json") - body = response.json() - assert response.status_code == 200 - assert "error" in body["result"]["error"].lower() or "invalid" in str(body).lower() - - -# ------------------------------------------------------------------------------ -# 2. Missing required fields -# ------------------------------------------------------------------------------ - -def test_mcp_missing_method(): - """ Should fail gracefully when method is missing.""" - payload = {"jsonrpc": "2.0", "params": {"name": "get_calendar"}, "id": 1} - response = client.post("/mcp", json=payload, headers={"x-database-id": "test-db-001"}) - assert response.status_code == 200 - body = response.json() - assert "error" in str(body["result"]).lower() - - -# ------------------------------------------------------------------------------ -# 3. Unknown tool name -# ------------------------------------------------------------------------------ - -def test_mcp_unknown_tool(): - """ Should report unknown tool error.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": "non_existent_tool", "arguments": {}}, - "id": 99, - } - headers = {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} - response = client.post("/mcp", json=payload, headers=headers) - assert response.status_code == 200 - body = response.json() - result = body["result"] - assert "Unknown tool" in result["content"][0]["text"] or "" in result["content"][0]["text"] - - -# ------------------------------------------------------------------------------ -# 4. Missing database_id header -# ------------------------------------------------------------------------------ - -def test_mcp_missing_database_header(): - """ Should raise 'database_id is required' if header is absent.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": "create_calendar", "arguments": {"summary": "Team"}}, - "id": 5, - } - # No x-database-id header intentionally - response = client.post("/mcp", json=payload) - assert response.status_code == 200 - body = response.json() - msg = body["result"]["content"][0]["text"] - assert "database_id is required" in msg - - -# ------------------------------------------------------------------------------ -# 5. Valid JSON-RPC but unsupported method -# ------------------------------------------------------------------------------ - -def test_mcp_unknown_method(): - """ Should return Method not found error.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/invalid_method", - "params": {}, - "id": 7, - } - headers = {"x-database-id": "test-db-001"} - response = client.post("/mcp", json=payload, headers=headers) - body = response.json() - assert "Method not found" in str(body) diff --git a/envs/calendar_env/server/tests_tools/test_mcp_events_all.py b/envs/calendar_env/server/tests_tools/test_mcp_events_all.py deleted file mode 100644 index 1683b1627..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_events_all.py +++ /dev/null @@ -1,640 +0,0 @@ -""" -End-to-end MCP tests for all Event tools via /mcp endpoint. -All tool executions and user lookups are mocked so tests run offline. -Covers: list_events, get_event, insert_event, patch_event, update_event, delete_event, move_event, quick_add_event, import_event, get_event_instances, watch_events -Each function has 2 good scenarios and 3 bad scenarios. -They are classified in sub clasess -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app mounting the /mcp route -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Route that forwards requests to the real MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixture: fake UserManager to bypass DB lookups -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - """Bypass real DB access inside UserManager for tests.""" - class DummyUserManager: - def __init__(self, dbid): - self.dbid = dbid - def get_first_user_from_db(self): - return {"id": "test-user-001"} - def get_user_by_access_token(self, token): - return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Fixture: fake execute_tool_generic to simulate event tool responses -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Patch execute_tool_generic so each event tool returns predictable mock data.""" - async def _fake(tool_name, tool_input, database_id, user_id): - # Simulate error conditions - if not tool_input.get("calendarId"): - return {"isError": True, "text": "Missing calendarId"} - - if tool_name in ["get_event", "patch_event", "update_event", "delete_event", "move_event", "get_event_instances"]: - if not tool_input.get("eventId"): - return {"isError": True, "text": "Missing eventId"} - - if tool_name == "move_event": - if not tool_input.get("destination"): - return {"isError": True, "text": "Missing destination"} - - if tool_name == "quick_add_event": - if not tool_input.get("text"): - return {"isError": True, "text": "Missing text"} - - if tool_name == "import_event": - if not tool_input.get("start"): - return {"isError": True, "text": "Missing start"} - if not tool_input.get("end"): - return {"isError": True, "text": "Missing end"} - if not tool_input.get("iCalUID"): - return {"isError": True, "text": "Missing iCalUID"} - - if tool_name == "watch_events": - if not tool_input.get("id"): - return {"isError": True, "text": "Missing id"} - if not tool_input.get("type"): - return {"isError": True, "text": "Missing type"} - if not tool_input.get("address"): - return {"isError": True, "text": "Missing address"} - - if tool_name == "list_events": - return { - "items": [ - {"id": "evt-1", "summary": "Morning Meeting"}, - {"id": "evt-2", "summary": "Lunch Break"}, - ] - } - if tool_name == "get_event": - return {"id": tool_input.get("eventId"), "summary": "Project Sync"} - if tool_name == "insert_event": - return {"id": "evt-new", "summary": tool_input.get("summary", "Untitled Event")} - if tool_name == "patch_event": - return {"id": tool_input.get("eventId"), "summary": tool_input.get("summary", "Patched")} - if tool_name == "update_event": - return {"id": tool_input.get("eventId"), "summary": "Updated Event"} - if tool_name == "delete_event": - return {} - if tool_name == "move_event": - return {"id": tool_input.get("eventId"), "summary": "Moved Event"} - if tool_name == "quick_add_event": - return {"id": "evt-quick", "summary": "Quick Added Event"} - if tool_name == "import_event": - return {"id": "evt-import", "summary": "Imported Event"} - if tool_name == "get_event_instances": - return { - "items": [ - {"id": "evt-inst-1", "summary": "Recurring Meeting Instance 1"}, - {"id": "evt-inst-2", "summary": "Recurring Meeting Instance 2"}, - ] - } - if tool_name == "watch_events": - return {"kind": "api#channel", "id": tool_input.get("id"), "resourceId": "calendar-watch"} - return {"isError": True, "text": f"Unhandled tool {tool_name}"} - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Helper to send JSON-RPC calls to /mcp with required headers -# ------------------------------------------------------------------------------ - -def rpc_call(tool_name, arguments, rpc_id=1, db_id="test-db-001", token="dummy-static-token"): - """Send a JSON-RPC call including headers required by MCP handler.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - }, - "id": rpc_id - } - headers = { - "x-database-id": db_id, - "x-access-token": token - } - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# list_events: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_list_events_primary_calendar_success(): - """GOOD: list_events with primary calendar""" - resp = rpc_call("list_events", {"calendarId": "primary"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_list_events_secondary_calendar_success(): - """GOOD: list_events with secondary calendar""" - resp = rpc_call("list_events", {"calendarId": "work@company.com"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_list_events_missing_calendar_id(): - """BAD: list_events without calendarId""" - resp = rpc_call("list_events", {}) - assert resp.status_code == 200 - # Check for error in response content if applicable - - -def test_list_events_invalid_calendar_format(): - """BAD: list_events with malformed calendar ID""" - resp = rpc_call("list_events", {"calendarId": "not@valid@format@email"}) - assert resp.status_code == 200 - - -def test_list_events_empty_calendar_id(): - """BAD: list_events with empty string calendarId""" - resp = rpc_call("list_events", {"calendarId": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# get_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_get_event_valid_primary_calendar(): - """GOOD: get_event with valid eventId on primary calendar""" - resp = rpc_call("get_event", {"calendarId": "primary", "eventId": "evt-123"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_get_event_valid_secondary_calendar(): - """GOOD: get_event with valid eventId on secondary calendar""" - resp = rpc_call("get_event", {"calendarId": "team@company.com", "eventId": "evt-456"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_get_event_missing_event_id(): - """BAD: get_event without eventId""" - resp = rpc_call("get_event", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_get_event_missing_calendar_id(): - """BAD: get_event without calendarId""" - resp = rpc_call("get_event", {"eventId": "evt-123"}) - assert resp.status_code == 200 - - -def test_get_event_empty_event_id(): - """BAD: get_event with empty eventId""" - resp = rpc_call("get_event", {"calendarId": "primary", "eventId": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# insert_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_insert_event_with_summary(): - """GOOD: insert_event with summary""" - resp = rpc_call("insert_event", {"calendarId": "primary", "summary": "Team Standup"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_insert_event_with_unicode_summary(): - """GOOD: insert_event with unicode characters""" - resp = rpc_call("insert_event", {"calendarId": "primary", "summary": "会議"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_insert_event_missing_calendar_id(): - """BAD: insert_event without calendarId""" - resp = rpc_call("insert_event", {"summary": "Meeting"}) - assert resp.status_code == 200 - - -def test_insert_event_empty_calendar_id(): - """BAD: insert_event with empty calendarId""" - resp = rpc_call("insert_event", {"calendarId": "", "summary": "Meeting"}) - assert resp.status_code == 200 - - -def test_insert_event_null_summary(): - """BAD: insert_event with null summary""" - resp = rpc_call("insert_event", {"calendarId": "primary", "summary": None}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# patch_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_patch_event_update_summary(): - """GOOD: patch_event to update summary""" - resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "evt-555", "summary": "Updated Title"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_patch_event_partial_update(): - """GOOD: patch_event with minimal changes""" - resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "evt-666", "summary": "Quick Fix"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_patch_event_missing_event_id(): - """BAD: patch_event without eventId""" - resp = rpc_call("patch_event", {"calendarId": "primary", "summary": "New Title"}) - assert resp.status_code == 200 - - -def test_patch_event_missing_calendar_id(): - """BAD: patch_event without calendarId""" - resp = rpc_call("patch_event", {"eventId": "evt-555", "summary": "New Title"}) - assert resp.status_code == 200 - - -def test_patch_event_empty_event_id(): - """BAD: patch_event with empty eventId""" - resp = rpc_call("patch_event", {"calendarId": "primary", "eventId": "", "summary": "Title"}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# update_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_update_event_full_replace(): - """GOOD: update_event with full replacement""" - resp = rpc_call("update_event", {"calendarId": "primary", "eventId": "evt-888"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_update_event_with_all_fields(): - """GOOD: update_event with complete data""" - resp = rpc_call("update_event", { - "calendarId": "primary", - "eventId": "evt-999", - "summary": "Complete Update" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_update_event_missing_event_id(): - """BAD: update_event without eventId""" - resp = rpc_call("update_event", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_update_event_missing_calendar_id(): - """BAD: update_event without calendarId""" - resp = rpc_call("update_event", {"eventId": "evt-888"}) - assert resp.status_code == 200 - - -def test_update_event_empty_event_id(): - """BAD: update_event with empty eventId""" - resp = rpc_call("update_event", {"calendarId": "primary", "eventId": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# delete_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_delete_event_from_primary(): - """GOOD: delete_event from primary calendar""" - resp = rpc_call("delete_event", {"calendarId": "primary", "eventId": "evt-999"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_delete_event_from_secondary(): - """GOOD: delete_event from secondary calendar""" - resp = rpc_call("delete_event", {"calendarId": "work@example.com", "eventId": "evt-777"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_delete_event_missing_event_id(): - """BAD: delete_event without eventId""" - resp = rpc_call("delete_event", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_delete_event_missing_calendar_id(): - """BAD: delete_event without calendarId""" - resp = rpc_call("delete_event", {"eventId": "evt-999"}) - assert resp.status_code == 200 - - -def test_delete_event_empty_event_id(): - """BAD: delete_event with empty eventId""" - resp = rpc_call("delete_event", {"calendarId": "primary", "eventId": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# move_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_move_event_to_primary_calendar(): - """GOOD: move_event from secondary to primary calendar""" - resp = rpc_call("move_event", { - "calendarId": "work@company.com", - "eventId": "evt-123", - "destination": "primary" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_move_event_with_notifications(): - """GOOD: move_event with notification settings""" - resp = rpc_call("move_event", { - "calendarId": "primary", - "eventId": "evt-456", - "destination": "team@company.com", - "sendNotifications": True, - "sendUpdates": "all" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_move_event_missing_destination(): - """BAD: move_event without destination""" - resp = rpc_call("move_event", {"calendarId": "primary", "eventId": "evt-123"}) - assert resp.status_code == 200 - - -def test_move_event_missing_event_id(): - """BAD: move_event without eventId""" - resp = rpc_call("move_event", {"calendarId": "primary", "destination": "work@company.com"}) - assert resp.status_code == 200 - - -def test_move_event_missing_calendar_id(): - """BAD: move_event without calendarId""" - resp = rpc_call("move_event", {"eventId": "evt-123", "destination": "work@company.com"}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# quick_add_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_quick_add_event_simple_text(): - """GOOD: quick_add_event with simple text""" - resp = rpc_call("quick_add_event", {"calendarId": "primary", "text": "Meeting tomorrow at 2pm"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_quick_add_event_complex_text(): - """GOOD: quick_add_event with complex text and notifications""" - resp = rpc_call("quick_add_event", { - "calendarId": "primary", - "text": "Team lunch next Friday at 12:30pm at Downtown Cafe", - "sendNotifications": True - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_quick_add_event_missing_text(): - """BAD: quick_add_event without text""" - resp = rpc_call("quick_add_event", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_quick_add_event_missing_calendar_id(): - """BAD: quick_add_event without calendarId""" - resp = rpc_call("quick_add_event", {"text": "Meeting tomorrow"}) - assert resp.status_code == 200 - - -def test_quick_add_event_empty_text(): - """BAD: quick_add_event with empty text""" - resp = rpc_call("quick_add_event", {"calendarId": "primary", "text": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# import_event: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_import_event_basic(): - """GOOD: import_event with required fields""" - resp = rpc_call("import_event", { - "calendarId": "primary", - "iCalUID": "import-test-001", - "start": {"dateTime": "2023-12-01T10:00:00Z"}, - "end": {"dateTime": "2023-12-01T11:00:00Z"} - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_import_event_with_details(): - """GOOD: import_event with complete details""" - resp = rpc_call("import_event", { - "calendarId": "primary", - "iCalUID": "import-test-002", - "summary": "Imported Meeting", - "description": "Meeting imported from external calendar", - "start": {"dateTime": "2023-12-01T14:00:00Z"}, - "end": {"dateTime": "2023-12-01T15:00:00Z"}, - "supportsAttachments": True - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_import_event_missing_start(): - """BAD: import_event without start""" - resp = rpc_call("import_event", { - "calendarId": "primary", - "iCalUID": "import-test-003", - "end": {"dateTime": "2023-12-01T11:00:00Z"} - }) - assert resp.status_code == 200 - - -def test_import_event_missing_end(): - """BAD: import_event without end""" - resp = rpc_call("import_event", { - "calendarId": "primary", - "iCalUID": "import-test-004", - "start": {"dateTime": "2023-12-01T10:00:00Z"} - }) - assert resp.status_code == 200 - - -def test_import_event_missing_ical_uid(): - """BAD: import_event without iCalUID""" - resp = rpc_call("import_event", { - "calendarId": "primary", - "start": {"dateTime": "2023-12-01T10:00:00Z"}, - "end": {"dateTime": "2023-12-01T11:00:00Z"} - }) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# get_event_instances: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_get_event_instances_basic(): - """GOOD: get_event_instances with basic parameters""" - resp = rpc_call("get_event_instances", {"calendarId": "primary", "eventId": "recurring-evt-123"}) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_get_event_instances_with_time_range(): - """GOOD: get_event_instances with time range filter""" - resp = rpc_call("get_event_instances", { - "calendarId": "primary", - "eventId": "recurring-evt-456", - "timeMin": "2023-12-01T00:00:00Z", - "timeMax": "2023-12-31T23:59:59Z", - "maxResults": 10 - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_get_event_instances_missing_event_id(): - """BAD: get_event_instances without eventId""" - resp = rpc_call("get_event_instances", {"calendarId": "primary"}) - assert resp.status_code == 200 - - -def test_get_event_instances_missing_calendar_id(): - """BAD: get_event_instances without calendarId""" - resp = rpc_call("get_event_instances", {"eventId": "recurring-evt-123"}) - assert resp.status_code == 200 - - -def test_get_event_instances_empty_event_id(): - """BAD: get_event_instances with empty eventId""" - resp = rpc_call("get_event_instances", {"calendarId": "primary", "eventId": ""}) - assert resp.status_code == 200 - - -# ------------------------------------------------------------------------------ -# watch_events: 2 good + 3 bad scenarios -# ------------------------------------------------------------------------------ - -def test_watch_events_basic(): - """GOOD: watch_events with required parameters""" - resp = rpc_call("watch_events", { - "calendarId": "primary", - "id": "watch-channel-001", - "type": "web_hook", - "address": "https://example.com/webhook" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_watch_events_with_token(): - """GOOD: watch_events with authentication token""" - resp = rpc_call("watch_events", { - "calendarId": "primary", - "id": "watch-channel-002", - "type": "web_hook", - "address": "https://secure.example.com/webhook", - "token": "secure-token-123", - "eventTypes": "default" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_watch_events_missing_id(): - """BAD: watch_events without id""" - resp = rpc_call("watch_events", { - "calendarId": "primary", - "type": "web_hook", - "address": "https://example.com/webhook" - }) - assert resp.status_code == 200 - - -def test_watch_events_missing_type(): - """BAD: watch_events without type""" - resp = rpc_call("watch_events", { - "calendarId": "primary", - "id": "watch-channel-003", - "address": "https://example.com/webhook" - }) - assert resp.status_code == 200 - - -def test_watch_events_missing_address(): - """BAD: watch_events without address""" - resp = rpc_call("watch_events", { - "calendarId": "primary", - "id": "watch-channel-004", - "type": "web_hook" - }) - assert resp.status_code == 200 \ No newline at end of file diff --git a/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py b/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py deleted file mode 100644 index ac527ac6b..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_scenarios.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -MCP Functional Scenarios ------------------------- -Ten comprehensive integration tests combining multiple tools and edge cases. -These simulate real-world sequences like creating, patching, and deleting -calendars/events using the /mcp endpoint. -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app mounting /mcp -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixtures: fake DB, user manager, and tool executor -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - class DummyUserManager: - def __init__(self, dbid): self.dbid = dbid - def get_first_user_from_db(self): return {"id": "test-user-001"} - def get_user_by_access_token(self, token): return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Generic fake executor for all scenarios.""" - async def _fake(tool_name, tool_input, dbid, userid): - # Simulate basic behavior - responses = { - "create_calendar": {"id": "cal-new", "summary": tool_input.get("summary")}, - "patch_calendar": {"id": "cal-new", "summary": tool_input.get("summary", "patched")}, - "get_calendar": {"id": tool_input.get("calendarId"), "summary": "Sample Calendar"}, - "delete_calendar": {}, - "list_events": {"items": [{"id": "evt-1", "summary": "Meeting"}]}, - "insert_event": {"id": "evt-new", "summary": tool_input.get("summary")}, - "patch_event": {"id": tool_input.get("eventId"), "summary": "Patched Event"}, - "freebusy_query": {"calendars": {"primary": {"busy": []}}}, - "acl_list": {"items": [{"role": "owner"}]}, - "colors_get": {"calendar": {"1": {"background": "#ff0000"}}}, - } - return responses.get(tool_name, {"isError": True, "text": f"Unhandled {tool_name}"}) - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - return monkeypatch - - -@pytest.fixture -def headers(): - return {"x-database-id": "test-db-001", "x-access-token": "dummy-static-token"} - - -def rpc_call(tool_name, arguments, headers): - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - "id": 1 - } - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# 10 Comprehensive MCP Scenarios -# ------------------------------------------------------------------------------ - -def test_01_create_calendar_success(headers): - """ Create a calendar and verify response.""" - resp = rpc_call("create_calendar", {"summary": "Engineering Team"}, headers) - assert resp.status_code == 200 - assert "" in str(resp.json()) or "completed" in str(resp.json()) - - -def test_02_patch_calendar_title(headers): - """ Patch calendar title.""" - resp = rpc_call("patch_calendar", {"calendarId": "cal-new", "summary": "Updated Calendar"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_03_insert_event_into_calendar(headers): - """ Insert event inside a calendar.""" - resp = rpc_call("insert_event", {"calendarId": "cal-new", "summary": "Team Sync"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_04_list_events_from_calendar(headers): - """ List events after insertion.""" - resp = rpc_call("list_events", {"calendarId": "cal-new"}, headers) - assert resp.status_code == 200 - result = resp.json()["result"] - assert "content" in result - - -def test_05_update_event_summary(headers): - """ Patch existing event summary.""" - resp = rpc_call("patch_event", {"calendarId": "cal-new", "eventId": "evt-1", "summary": "Rescheduled Sync"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_06_query_freebusy_availability(headers): - """ FreeBusy query should return empty slots.""" - resp = rpc_call("freebusy_query", {"timeMin": "2025-10-10T00:00:00Z", "timeMax": "2025-10-10T23:59:59Z"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_07_retrieve_calendar_colors(headers): - """ Get available calendar colors.""" - resp = rpc_call("colors_get", {}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_08_acl_list_access_rights(headers): - """ List ACL entries.""" - resp = rpc_call("acl_list", {"calendarId": "primary"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_09_delete_calendar(headers): - """ Delete an existing calendar.""" - resp = rpc_call("delete_calendar", {"calendarId": "cal-new"}, headers) - result = resp.json()["result"] - assert "content" in result - - -def test_10_invalid_tool_request(headers): - """ Invalid tool should produce Unknown tool error.""" - resp = rpc_call("unknown_tool", {}, headers) - result = resp.json()["result"] - text = result["content"][0]["text"] - assert "Unknown tool" in text or "" in text diff --git a/envs/calendar_env/server/tests_tools/test_mcp_settings.py b/envs/calendar_env/server/tests_tools/test_mcp_settings.py deleted file mode 100644 index 53fed18e0..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_settings.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -End-to-end MCP tests for all Settings tools via /mcp endpoint. -All tool executions and user lookups are mocked so tests run offline. -Covers: list_settings, get_settings, watch_settings. -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler, tool_handlers - - -# ------------------------------------------------------------------------------ -# Minimal FastAPI app mounting the /mcp route -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Route that forwards requests to the real MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Fixture: fake UserManager to bypass DB lookups -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_user_manager(monkeypatch): - """Bypass real DB access inside UserManager for tests.""" - class DummyUserManager: - def __init__(self, dbid): - self.dbid = dbid - def get_first_user_from_db(self): - return {"id": "test-user-001"} - def get_user_by_access_token(self, token): - return {"id": "test-user-001"} - - import handlers.mcp_handler as mcp - monkeypatch.setattr(mcp, "UserManager", DummyUserManager) - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Fixture: fake execute_tool_generic to simulate tool responses -# ------------------------------------------------------------------------------ - -@pytest.fixture(autouse=True) -def fake_exec(monkeypatch): - """Patch execute_tool_generic so each tool returns predictable mock data.""" - # Mock the MCP_TOOLS list to include settings tools - from calendar_mcp.tools.settings import SETTINGS_TOOLS - monkeypatch.setattr(tool_handlers, "MCP_TOOLS", SETTINGS_TOOLS) - - async def _fake(tool_name, tool_input, database_id, user_id): - import json as json_module - - if tool_name == "list_settings": - data = { - "kind": "calendar#settings", - "etag": "settings-collection-etag", - "items": [ - { - "kind": "calendar#setting", - "etag": "setting-etag-1", - "id": "timezone", - "value": "America/Los_Angeles" - }, - { - "kind": "calendar#setting", - "etag": "setting-etag-2", - "id": "dateFieldOrder", - "value": "MDY" - }, - { - "kind": "calendar#setting", - "etag": "setting-etag-3", - "id": "timeFormat", - "value": "12" - } - ] - } - return {"text": json_module.dumps(data), "isError": False} - - if tool_name == "get_settings": - setting_id = tool_input.get("settingId") - if setting_id == "timezone": - data = { - "kind": "calendar#setting", - "etag": "setting-etag-timezone", - "id": "timezone", - "value": "America/Los_Angeles" - } - return {"text": json_module.dumps(data), "isError": False} - elif setting_id == "dateFieldOrder": - data = { - "kind": "calendar#setting", - "etag": "setting-etag-date", - "id": "dateFieldOrder", - "value": "MDY" - } - return {"text": json_module.dumps(data), "isError": False} - elif setting_id == "timeFormat": - data = { - "kind": "calendar#setting", - "etag": "setting-etag-time", - "id": "timeFormat", - "value": "12" - } - return {"text": json_module.dumps(data), "isError": False} - else: - return {"isError": True, "text": f"Setting {setting_id} not found"} - - if tool_name == "watch_settings": - channel_id = tool_input.get("id") - address = tool_input.get("address") - - if not address: - return {"isError": True, "text": "Webhook address is required"} - - if not channel_id: - return {"isError": True, "text": "Channel ID is required"} - - data = { - "kind": "api#channel", - "id": channel_id, - "resourceId": "settings-test-user-001", - "resourceUri": "/settings", - "token": tool_input.get('token', ''), - "expiration": "1735689600000" - } - return {"text": json_module.dumps(data), "isError": False} - - return {"isError": True, "text": f"Unhandled tool {tool_name}"} - - monkeypatch.setattr(tool_handlers, "execute_tool_generic", _fake) - - # Rebuild TOOL_HANDLERS with the mocked execute_tool_generic - tool_handlers.TOOL_HANDLERS = {tool["name"]: _fake for tool in SETTINGS_TOOLS} - - return monkeypatch - - -# ------------------------------------------------------------------------------ -# Helper to send JSON-RPC calls to /mcp with required headers -# ------------------------------------------------------------------------------ - -def rpc_call(tool_name, arguments, rpc_id=1): - """Send a JSON-RPC call including headers required by MCP handler.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - }, - "id": rpc_id - } - headers = { - "x-database-id": "test-db-001", - "x-access-token": "dummy-static-token" - } - return client.post("/mcp", json=payload, headers=headers) - - -# ------------------------------------------------------------------------------ -# Tests for each settings tool -# ------------------------------------------------------------------------------ - -def test_mcp_list_settings(): - """list_settings → should succeed and return all settings""" - resp = rpc_call("list_settings", {}) - assert resp.status_code == 200 - result = resp.json()["result"] - - # Validate basic MCP structure - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains settings data - assert "calendar#settings" in text or "timezone" in text or "" in text - - -def test_mcp_get_settings_timezone(): - """get_settings → should retrieve timezone setting (positive)""" - resp = rpc_call("get_settings", {"settingId": "timezone"}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains timezone setting - assert "timezone" in text or "America/Los_Angeles" in text or "" in text - - -def test_mcp_get_settings_date_field_order(): - """get_settings → should retrieve dateFieldOrder setting (positive)""" - resp = rpc_call("get_settings", {"settingId": "dateFieldOrder"}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains date field order setting - assert "dateFieldOrder" in text or "MDY" in text or "" in text - - -def test_mcp_get_settings_time_format(): - """get_settings → should retrieve timeFormat setting (positive)""" - resp = rpc_call("get_settings", {"settingId": "timeFormat"}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains time format setting - assert "timeFormat" in text or "12" in text or "" in text - - -def test_mcp_get_settings_not_found(): - """get_settings → should return error for non-existent setting (negative)""" - resp = rpc_call("get_settings", {"settingId": "nonexistent_setting"}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "not found" in text.lower() or "error" in text.lower() - - -def test_mcp_watch_settings_success(): - """watch_settings → should successfully create watch channel (positive)""" - resp = rpc_call("watch_settings", { - "id": "test-channel-001", - "type": "web_hook", - "address": "https://example.com/webhook", - "token": "secret-token-123" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "test-channel-001" in text or "" in text - - -def test_mcp_watch_settings_missing_address(): - """watch_settings → should fail when address is missing (negative)""" - resp = rpc_call("watch_settings", { - "id": "test-channel-002", - "type": "web_hook" - # Missing address - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "Request Error: All connection attempts failed" in text - - -def test_mcp_watch_settings_missing_channel_id(): - """watch_settings → should fail when channel ID is missing (negative)""" - resp = rpc_call("watch_settings", { - "type": "web_hook", - "address": "https://example.com/webhook" - # Missing id - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify error message - assert "request error" in text.lower() - - -def test_mcp_watch_settings_with_params(): - """watch_settings → should succeed with additional params (positive)""" - resp = rpc_call("watch_settings", { - "id": "test-channel-003", - "type": "web_hook", - "address": "https://example.com/webhook", - "token": "secret-token-456", - "params": { - "ttl": "3600" - } - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "test-channel-003" in text or "" in text - - -def test_mcp_watch_settings_minimal(): - """watch_settings → should succeed with minimal required params (positive)""" - resp = rpc_call("watch_settings", { - "id": "minimal-channel", - "type": "web_hook", - "address": "https://minimal.example.com/hook" - }) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - text = result["content"][0]["text"] - - # Verify response contains channel data - assert "api#channel" in text or "minimal-channel" in text or "" in text - - -# ------------------------------------------------------------------------------ -# Edge case tests -# ------------------------------------------------------------------------------ - -def test_mcp_get_settings_empty_setting_id(): - """get_settings → should handle empty settingId gracefully""" - resp = rpc_call("get_settings", {"settingId": ""}) - assert resp.status_code == 200 - result = resp.json()["result"] - - assert "content" in result - # Should return some response (error or empty) - - -def test_mcp_list_settings_multiple_calls(): - """list_settings → should handle multiple consecutive calls""" - resp1 = rpc_call("list_settings", {}, rpc_id=1) - resp2 = rpc_call("list_settings", {}, rpc_id=2) - - assert resp1.status_code == 200 - assert resp2.status_code == 200 - - # Both should return valid responses - result1 = resp1.json()["result"] - result2 = resp2.json()["result"] - - assert "content" in result1 - assert "content" in result2 diff --git a/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py b/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py deleted file mode 100644 index 1f09fd60f..000000000 --- a/envs/calendar_env/server/tests_tools/test_mcp_tools_list.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -MCP integration test: Verify that /mcp tools/list returns all registered tools. -Checks registry consistency between MCP_TOOLS_LIST and TOOL_HANDLERS. -""" - -import sys, os, pytest -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient -from handlers import mcp_handler -from handlers.tool_handlers import MCP_TOOLS_LIST, TOOL_HANDLERS - - -# ------------------------------------------------------------------------------ -# FastAPI app mounting /mcp -# ------------------------------------------------------------------------------ - -app = FastAPI() - -@app.post("/mcp") -async def mcp_entry(request: Request): - """Forward JSON-RPC request to MCP handler.""" - return await mcp_handler.handle_mcp_request(request) - -client = TestClient(app) - - -# ------------------------------------------------------------------------------ -# Test 1: Direct call to /mcp tools/list -# ------------------------------------------------------------------------------ - -def test_mcp_tools_list_endpoint(): - """ Verify that /mcp tools/list returns JSON-RPC result with all tools.""" - payload = { - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - "id": 1 - } - headers = {"x-database-id": "test-db-001"} # optional header - response = client.post("/mcp", json=payload, headers=headers) - - assert response.status_code == 200, "Expected HTTP 200 from /mcp" - body = response.json() - assert "result" in body, f"Missing JSON-RPC result: {body}" - - result = body["result"] - assert "tools" in result, f"Expected 'tools' key in result, got: {result}" - tool_count = len(result["tools"]) - print(f"\n📦 /mcp tools/list returned {tool_count} tools") - - assert tool_count == len(MCP_TOOLS_LIST), ( - f"Mismatch between registered tool list ({len(MCP_TOOLS_LIST)}) and returned count ({tool_count})" - ) - - -# ------------------------------------------------------------------------------ -# Test 2: Internal registry consistency -# ------------------------------------------------------------------------------ - -def test_tool_registry_consistency(): - """ Verify that every tool name in MCP_TOOLS_LIST has a handler.""" - tool_names = {t["name"] for t in MCP_TOOLS_LIST} - missing = [name for name in tool_names if name not in TOOL_HANDLERS] - - print(f"\n {len(tool_names)} tools in MCP_TOOLS_LIST") - print(f" {len(TOOL_HANDLERS)} handlers registered") - - assert not missing, f"Missing handlers for: {missing}" - assert len(tool_names) >= 10, "Expected at least 10 registered tools" diff --git a/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py b/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py deleted file mode 100644 index af5993306..000000000 --- a/envs/calendar_env/server/tests_tools/test_tool_handlers_generic.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Tests for execute_tool_generic() from handlers.tool_handlers. -These mock httpx and router inspection to validate behavior without live APIs. -""" - -import pytest -import json as json_module -import httpx -from handlers import tool_handlers - - -# -------------------------------------------------------------------------- -# Common async helper -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def call_execute(tool_name="create_calendar", arguments=None, status=200, json_data=None, method="POST"): - """Helper to invoke execute_tool_generic() with mocked httpx.""" - arguments = arguments or {} - - # async-compatible request mock - async def mock_request(self, m, url, headers=None, json=None, params=None): - class MockResponse: - def __init__(self): - self.status_code = status - self._json = json_data - self.text = json_module.dumps(json_data) if json_data else "" - def json(self): return self._json - return MockResponse() - - # Patch network + endpoint discovery - tool_handlers.httpx.AsyncClient.request = mock_request - tool_handlers.get_api_endpoint_for_tool = lambda name: (method, f"/mock/{name}") - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic( - tool_name, arguments, database_id="test-db-001", user_id="test-user-001" - ) - return result - - -# -------------------------------------------------------------------------- -# 1. Successful POST -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_success_post(): - data = {"id": "123", "summary": "OK"} - result = await call_execute(json_data=data) - assert not result["isError"], f"Unexpected error: {result['text']}" - assert '"id": "123"' in result["text"] - - -# -------------------------------------------------------------------------- -# 2. Successful GET -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_success_get(): - async def mock_get(self, url, headers=None, params=None): - class R: - status_code = 200 - def json(self): return {"ping": "pong"} - text = json_module.dumps({"ping": "pong"}) - return R() - - # Mock tool configuration - original_tools = getattr(tool_handlers, 'MCP_TOOLS', []) - tool_handlers.MCP_TOOLS = [{"name": "get_calendar_list", "description": "test"}] - - try: - tool_handlers.httpx.AsyncClient.get = mock_get - tool_handlers.get_api_endpoint_for_tool = lambda name: ("GET", "/mock/path") - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("get_calendar_list", {}, "test-db-001", "u1") - assert not result["isError"], f"Unexpected error: {result['text']}" - assert "pong" in result["text"] - finally: - # Restore original tools - tool_handlers.MCP_TOOLS = original_tools - - -# -------------------------------------------------------------------------- -# 3. 204 (DELETE) -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_delete_204(): - async def mock_del(self, url, headers=None, params=None): - class R: status_code = 204; text = "" - return R() - tool_handlers.httpx.AsyncClient.delete = mock_del - tool_handlers.get_api_endpoint_for_tool = lambda name: ("DELETE", "/mock/path") - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("delete_calendar", {}, "test-db-001", "u1") - assert result["status_code"] == 204 - - -# -------------------------------------------------------------------------- -# 4. 404 friendly error -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_404_friendly_error(): - async def mock_get(self, url, headers=None, params=None): - class R: - status_code = 404 - def json(self): return {"detail": "Calendar not found"} - text = "" - return R() - - # Mock tool configuration - original_tools = getattr(tool_handlers, 'MCP_TOOLS', []) - tool_handlers.MCP_TOOLS = [{"name": "get_calendar", "description": "test"}] - - try: - tool_handlers.httpx.AsyncClient.get = mock_get - tool_handlers.get_api_endpoint_for_tool = lambda name: ("GET", "/calendars/{calendarId}") - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("get_calendar", {"calendarId": "bad"}, "test-db-001", "user1") - assert result["isError"] - assert "not found" in result["text"].lower() - finally: - # Restore original tools - tool_handlers.MCP_TOOLS = original_tools - - -# -------------------------------------------------------------------------- -# 5. Missing path parameter -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_missing_param(monkeypatch): - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("GET", "/calendars/{calendarId}")) - tool_handlers.log_tool_response = lambda *a, **kw: None - result = await tool_handlers.execute_tool_generic("get_calendar", {}, "test-db-001", "user1") - assert result["isError"] - assert "The parameter 'calendarId' is required" in result["text"] - - -# -------------------------------------------------------------------------- -# 6. Validation error (Pydantic detail list) -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_validation_error(monkeypatch): - async def mock_request(self, m, url, headers=None, json=None, params=None): - class R: - status_code = 422 - def json(self): - return {"detail": [{"loc": ["body", "summary"], "msg": "field required"}]} - text = "" - return R() - - monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", mock_request) - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") - assert result["isError"] - assert "Validation" in result["text"] - - -# -------------------------------------------------------------------------- -# 7. Non-JSON error body -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_non_json_error(monkeypatch): - async def mock_request(self, m, url, headers=None, json=None, params=None): - class R: - status_code = 500 - text = "Internal Error" - def json(self): raise ValueError("Not JSON") - return R() - monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", mock_request) - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") - assert result["isError"] - assert "HTTP 500" in result["text"] - - -# -------------------------------------------------------------------------- -# 8. Request Exception -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_request_exception(monkeypatch): - async def fail_request(*a, **kw): raise httpx.RequestError("Connection refused") - monkeypatch.setattr(tool_handlers.httpx.AsyncClient, "request", fail_request) - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: ("POST", "/mock")) - tool_handlers.log_tool_response = lambda *a, **kw: None - - result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") - assert result["isError"] - assert "Request Error" in result["text"] - - -# -------------------------------------------------------------------------- -# 9. Unexpected Exception -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_unexpected_exception(monkeypatch): - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: 1/0) - tool_handlers.log_tool_response = lambda *a, **kw: None - result = await tool_handlers.execute_tool_generic("create_calendar", {}, "test-db-001", "user1") - assert result["isError"] - assert "Unexpected" in result["text"] - - -# -------------------------------------------------------------------------- -# 10. API endpoint not found -# -------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_execute_endpoint_not_found(monkeypatch): - monkeypatch.setattr(tool_handlers, "get_api_endpoint_for_tool", lambda n: None) - tool_handlers.log_tool_response = lambda *a, **kw: None - result = await tool_handlers.execute_tool_generic("unknown_tool", {}, "test-db-001", "user1") - assert result["isError"] - assert "API endpoint not found" in result["text"] or "Tool configuration not found" in result["text"] From 2e3ff1ed1c82ad1459a507d5a20994a47ddab707 Mon Sep 17 00:00:00 2001 From: Santosh Sah Date: Tue, 10 Feb 2026 19:33:38 +0530 Subject: [PATCH 3/5] Modified the readme file according to huggingface standard --- envs/calendar_env/{server => }/Dockerfile | 7 ++-- envs/calendar_env/README.md | 17 ++++++++-- ...nd_mcp_Single_Gym_Client_Meta_Turing.ipynb | 31 +++++++++--------- envs/calendar_env/mcp_databases/seed_store.db | Bin 0 -> 16384 bytes 4 files changed, 34 insertions(+), 21 deletions(-) rename envs/calendar_env/{server => }/Dockerfile (81%) create mode 100644 envs/calendar_env/mcp_databases/seed_store.db diff --git a/envs/calendar_env/server/Dockerfile b/envs/calendar_env/Dockerfile similarity index 81% rename from envs/calendar_env/server/Dockerfile rename to envs/calendar_env/Dockerfile index bb668fa4c..975acd968 100644 --- a/envs/calendar_env/server/Dockerfile +++ b/envs/calendar_env/Dockerfile @@ -26,10 +26,13 @@ RUN pip install --no-cache-dir --upgrade pip && \ # Copy application code COPY . . +# Place seed database where the server expects it +COPY server/mcp_databases /app/mcp_databases + # Create directory for database files RUN mkdir -p /app/mcp_databases -# Expose port 8010 +# Expose port 8004 EXPOSE 8004 # Health check @@ -37,4 +40,4 @@ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ CMD python -c "import requests; requests.get('http://localhost:8004/health')" || exit 1 # Run the application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8004"] \ No newline at end of file +CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8004"] diff --git a/envs/calendar_env/README.md b/envs/calendar_env/README.md index 20793dfaf..7ffdb9386 100644 --- a/envs/calendar_env/README.md +++ b/envs/calendar_env/README.md @@ -1,3 +1,15 @@ +--- +title: Calendar Environment Server +emoji: 📅 +colorFrom: blue +colorTo: green +sdk: docker +pinned: false +app_port: 8004 +base_path: /docs +tags: + - openenv +--- # Calendar Environment This environment exposes a Calendar Gym tools through the OpenEnv reset/step/state interface. The server runs a FastAPI app that serves the OpenEnv endpoints. @@ -7,7 +19,7 @@ This environment exposes a Calendar Gym tools through the OpenEnv reset/step/sta ### Docker (Recommended) ```bash -cd envs/calendar_env/server +cd envs/calendar_env docker build -t calendar-env:latest . docker run --rm -p 8004:8004 calendar-env:latest curl http://localhost:8004/health @@ -22,8 +34,7 @@ cd envs/calendar_env python3 -m venv venv source venv/bin/activate pip install -r requirements.txt -cd server -uvicorn main:app --host 0.0.0.0 --port 8004 --reload +uvicorn server.app:app --host 0.0.0.0 --port 8004 ``` ## Client Setup diff --git a/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb b/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb index e3ebe1fe7..be6ce04bf 100644 --- a/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb +++ b/envs/calendar_env/client_notebooks/OpenEnv_and_mcp_Single_Gym_Client_Meta_Turing.ipynb @@ -58,7 +58,7 @@ "2. **Run the gym environment Locally**\n", " ### Docker (Recommended)\n", " ```bash\n", - " cd calendar_env/server\n", + " cd calendar_env\n", " docker build -t calendar-env:latest .\n", " docker run --rm -p 8004:8004 calendar-env:latest\n", " curl http://localhost:8004/health\n", @@ -73,8 +73,7 @@ " python3 -m venv venv\n", " source venv/bin/activate\n", " pip install -r requirements.txt\n", - " cd server\n", - " uvicorn main:app --host 0.0.0.0 --port 8004 --reload\n", + " uvicorn server.app:app --host 0.0.0.0 --port 8004\n", " ```\n", "\n", "3. **Install Cloudflare Tunnel** (If you want to run this notebook on colab)\n", @@ -187,7 +186,7 @@ }, { "cell_type": "code", - "execution_count": 74, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -291,7 +290,7 @@ }, { "cell_type": "code", - "execution_count": 75, + "execution_count": null, "metadata": { "id": "PVBcjH1z8E_X" }, @@ -320,7 +319,7 @@ }, { "cell_type": "code", - "execution_count": 76, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -451,7 +450,7 @@ }, { "cell_type": "code", - "execution_count": 77, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -541,7 +540,7 @@ }, { "cell_type": "code", - "execution_count": 78, + "execution_count": null, "metadata": { "id": "xWtUwkMN8E_a" }, @@ -695,7 +694,7 @@ }, { "cell_type": "code", - "execution_count": 79, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -1182,7 +1181,7 @@ }, { "cell_type": "code", - "execution_count": 80, + "execution_count": null, "metadata": { "id": "DyVp1JiV8E_c" }, @@ -1365,7 +1364,7 @@ }, { "cell_type": "code", - "execution_count": 81, + "execution_count": null, "metadata": { "id": "64YSa4aJ8E_c" }, @@ -1835,7 +1834,7 @@ }, { "cell_type": "code", - "execution_count": 82, + "execution_count": null, "metadata": { "id": "Uk3J_sgL8E_d" }, @@ -2301,7 +2300,7 @@ }, { "cell_type": "code", - "execution_count": 83, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -2445,7 +2444,7 @@ }, { "cell_type": "code", - "execution_count": 84, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -3022,7 +3021,7 @@ }, { "cell_type": "code", - "execution_count": 85, + "execution_count": null, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -3130,4 +3129,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} +} \ No newline at end of file diff --git a/envs/calendar_env/mcp_databases/seed_store.db b/envs/calendar_env/mcp_databases/seed_store.db new file mode 100644 index 0000000000000000000000000000000000000000..1f3cde40c8696db419557d5c89c55e593a6a656b GIT binary patch literal 16384 zcmeI#F;Buk6bJAtB@&uI+!!2siwTX!LBnWbg^M;qky;E)O{HgY2^6Fy!BL(4u8xjg z2{bJlW<&m$T-x5dzPtOi%X{dxMa=2*bUyK8dO;3}qL3>}2_dTNMcFrP`&ropc3f3* z_W50r6V&Q4seV;oLX}IQk%=79}NN!fB*y_009U<00Izz00bZa zfjt(`lCz=y-{UWgqd@=y5P$##AOHafKmY;|fB*ytLH&=v00Izz00bZa0SG_<0uX=z J1R${g0zZvkoc90# literal 0 HcmV?d00001 From dd14a8b2826e26f494de08079d2024b83882fb89 Mon Sep 17 00:00:00 2001 From: Santosh Sah Date: Tue, 10 Feb 2026 19:57:48 +0530 Subject: [PATCH 4/5] Added HuggingFace link in environment --- docs/environments.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/environments.md b/docs/environments.md index bb4b930c8..251dc33cb 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -437,5 +437,25 @@ The OpenEnv community has built a catalog of ready-to-run environments that cove +
+
+
+ Calendar Gym +

+ This environment exposes a Calendar Gym tools through the OpenEnv reset/step/state interface. The server runs a FastAPI app that serves the OpenEnv endpoints. +

+
+ +
+
> Want to publish your own environment? Head over to the [Build Your Own Environment](environment-builder.md) guide for a step-by-step walkthrough. From 2e19f835a5fcb4e523595c36a6ebdde6c4e38680 Mon Sep 17 00:00:00 2001 From: Santosh Sah Date: Tue, 10 Feb 2026 21:19:39 +0530 Subject: [PATCH 5/5] removed unused files to comply with max of 100 files --- .../server/mcp_databases/seed_store.db | Bin 45056 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 envs/calendar_env/server/mcp_databases/seed_store.db diff --git a/envs/calendar_env/server/mcp_databases/seed_store.db b/envs/calendar_env/server/mcp_databases/seed_store.db deleted file mode 100644 index c2f2ed141e42ace106948f602647c0f8ef794cb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45056 zcmeHQTW=%Db=K^7?d%x##>j(V1j$y$NjL+G61#a5Wf(AZnUQ8BjYM%~cGfV6Cc8=X zsL7_-q%>M14|ah3iXeZ&dCQ~!Nsu3qr@SN&`Oc~8X7zWIES8Cp|OUi-_n2M^YMDa6{^+Q<0+`}jZnd5DYegg5y6K)YUa`SIGu zzdZc`o<98HKdk-mzkm3z_>=$S6v!!%Qy`~6PJx^PIR$bG{m;ds zT;kPtYLz`ym1Wf8+KIwl`|0m~@Zftt{_L{{|Lme3 z&xvY|E_gmuRRa>0AJ~~<#k3P|wEZ#I*y9dqI6SG=< z8hdGE4V~Ci&glfbi94RU{z$awNdJPxSrXU!`t4hIh*=oZ%3FVvsN<9 zddH|#%yONQi)x}1{fR#m&u@pAh$q+1q*xTkGjHk^U*lfvTAnkqt%*3dhGWkWd$1kI zlP!kyPG^R9Iz9EyBV>K>h?NQ|I!()i>W(wzVt`P;07J5H$^VK z1c}_f>!8%RrLZpMR>4!4AV;tF&mTQ0Sf1N+Izww@4V+0~UC4wDmGH}{)4S;TlkrB+ zAJWq%y)It(7o)%*MG17FrQKR)W20GWwkEc9T)b?&xvD?^@|`(8stnKiTfvF(p z%Cm#si?`R++m~OS)|_YN#?{UKq<#DP&>uI?_xjH-UR)l1`FZb6_he|8opPmEt#O4z zbgpM@o;Ww1cmCuOX$Jq2$wLHrQW*I+g{SKxk??dK-R=6_j*W5g{ITpV5;lS%1~Jvu z9ltBi=?T?}8~PL7$GiB&hpm&F@>ym4rdvA-8ZBekF7=+9vuD-YgZkwQdn;%fM_1+s zIyikdJM>SjbNkug;QdSQix0ckJEP;qR=rcJ7As}Fhev+UX^sXC%+u96*|R3Thi(S$ z0Gv)ma>~Zd1@?GS{oJLu#anttuktNjL@DvUcIyYd>YI^yHr#3%LAx?ODt&m@xOrKg zJ*&O{!gN|aW8=(v8|>SyetU3m`(k!@`N}(0Bl+_E!JywUjbf>>MV0K&CK!p$BY$Kw zKdms7wsq}v0?0K{r$Bcb-HCrQN_B7#kHii=q8I6o{zeJ$?v2YwVAyW$?8c-~r5+9s z>H~~qt2BGjGo90`4K-*Ub>>-rFR;!e=en=P%|LQ)r(Q1_2G!G4{JMJqt{wPDx?>-I z&Wa;cZgJ%?*E3JTORT&g;-u^7BA5+_*5npKFkf3pebI>&#(3h&cY40(%WM~}ZIfaN zz;r!mNXqXyd~r8TQdEUf&r_-;r94h$3q;rl)~vC&L{Xeh{P$>#IG*dMkfw1RP#}7j zn7;ToA^oWYRtMZfw3%yQK1ww@Ui}ano%$B04jt&6(I6l~S+?hn99)v_ht76_DReWJ zn$ci1u~5HfgQt$g+BntNHXdNk@+Y=C!bBleG$+m*Y83rZ(NN8#lGHp!-CEF2=H%5V z3%#oI0&e4aH>O=P`5<}&JaF!eTukVDy56rGR70}_7ht)?$v%wXqb~NkH#_8 z_*3;zv@t`{8&QG&I1R!C zF}^$Yfny+egG4V9mQMKnKA{D&u@;0?Fe)p96&Q@BgF`qWhF42q(lYTO+B-mqy#5^M zkU8Kj16NkKZ%ZYRj>wR1sH{`Gl|aOTx(%El0Qd&z4+3NX3lcciq<0~aK@6k%K#GX3=^vY)pVaNk9AY0*{?Tq2} z=B<>qHVMh6t+ zRUCc?sUXy>7d{kE9tr#q@UA;^J%FzUhm4&IF6nRI10pNBI0r?+9CYtlUB`Q*p>buw z#jubfBM4)6GTKQ0x6lSgcNZ94AmrQ;(9py2EWilvsFnx=U|2w3lVzJlZdZ}0Q;6yy z>{J09e_4E6E>@~^r@eLqOk42=-J|rcs@Gf>ōye{4%qj<~kD9FgAkcI2*QsY~k z`2MhH#o9zvkUdsyVX`1Ps_#skQO_w_h=-yaKx?Zc(LynznIMB4+LqIsO@P@NhXROPev~wk|>lu~z z1w-NIqY<@jI)kRTb_2KTdhQgc*g4|W-L<^ehkIx;@k0A{jMDplZx&ERKzRaHM~X-m zHJE{ogI#NMID&q1UQI6ms{>j;Og{E)w~ykZgmXu}^qk+2h4{1StG-k(TKRz7Ud@Pw z0KWBS6Z)HtxYb#V+gt=bAAJT$S2CIc-Y!SArjVnYu<>33`%@6=W&-vP2y=lXl7vp- zA5RtjQT(H5Vy**udehm&5iJHn(u;czLMc3iT?BE&B)ZPirHrMZi89Izj>S;WT*}bk zvx?{=@)5-b8&D~Qg6AoqrJQI1xSh~o2@Dgk%{b7==_3$iDkok-XbKFOprD2k(Ab`^ z#^Z@^^)5Ctwa2=yL(u_>P*I^GrN(xtgx{a>zm!{hXc)#@yP$Vdh*%`tA@D;kjc7MkH2a!VXbDCi8fHREg> z>1ulWH;*kujqN!-0^&m%GppM2_5_myF{rvdhR#9A6Y=U{p)drcrP^n!d_$ZvsmXFz zNN-F>xPz(?(BGI#EOG8xy-Oa?*<`>Lsx`%E1shaL@4}j5Lc_#EHQ4@%KizW!Xs#}E zJ0wf~N*dr=Uffd(*o5Q7iM{|xuT~bM+$k_fmxShMG=ug$awxKlqK5C}H#1y0kF*a#fQ^h_Um68j^)%GV-j8Ig?01%gziwF30&; zvkLHj#1>KoFJ8wph(MilYhqo9Cm^sDkE(mRAs}l! zX9r_Me9<@$r5qxV^1>O3L&z9}WW+M0$?|?^oRK*F3Np--8+9;}f4aGWE(8t2McdiGYDKm{`LhiDT%OAi0V$I>KKY8PXC>A&s_C|3+tt ztlCzXB_iSCnJ#S>RN}&w;ESmD2^;6@atg1Om%Cx&P8qtpOVFaOd&x^vC^LJ4vm1Sxc&Dz2t=7%XOL9$4UQRu~a$*{NcitOW-#0uo+zKLhae?QFp3VJGba&T*Nv!kX&m*G^$|7)t@?GsYLmxsNIZV!d zZ$^U60*ytWJ%ie`@zN-mDv>9#K6VBcJ9Z97JyO6hDM?>LafjuHt09WmgihzRElGZW z#f>EwyBi37CD})VdaMI35VEK|ywN%+7(2ayXWOXG1H?04&9EeA6>pejn3n;?HwL0f zVdhx22#UYP)jQ(BP*;>o?ezLbf4I=x)D}XKt*NEO_|Pb3))T4_En9})1+9%S>wM|M zA*5=urUjzQYw9?w&fk_Tg4U3;w7iAaOJ6AMwvNU5Z7_9)V1S6YdyeJNgq2t=^(Mpk zVhKT|Ua&a?AJF=#2r_=H)e^4+b19&i@Q*D8#xg0u!*5Covg*mDKpp|X?&b8oOZ+JOJ~aNa=7>5=fG)oHp~LGjnjcKlCi%Yc{QJ4fRr?=sW})ra3tHu<$4goj^w3^- z6{2Qq8>Gm7mzFXAXuibXz-2IoElWPyotZswrsS4$M%ce|aGcO+K*ksu8rbbXR(UxT`5I5Vaum z0fr3(fAZNCi+atRmhLsTD%NXA`fxUNuq6wJhwV(L3DQm3y%k$4sA42Gfi=UjlW<~ zV;1b}B=xQ8KwPMn>{Y0Rn0 z%!uxJM&jc6Dmu!FL>g)vTZ9t6aAE-gz-R@_wt*6&GjG)Xy6}0Y%)&W(*tt>L=(vKj zIdJ55jJ|!2s_)F1yg#m=lXv9%yu|@W%?;%CS$Rcvw8QG|TFnAc;K?h!Q^cD!?*S1MZeNUi^7iwdgE|5+26PJ@%|wLV z*zs~`oAm4z=wxH)KR7d4%9Au$AZgS%*=5ynOf_-H4_=T2AgAeaIxi4LX6U#-@N`1z zn)h?z$s=UJ#D!;ucS|r6kYkPdx(Bymbkf{ihhd3Ohl5KN3i<9#4Z6TS|DiMNV(O&2 z>HI~p=hN0HTu`#|ujv0Wb-=qf9oo64!pUvrO#W<>gd_A{qlOp-udGFVEFp= z)p+W4gL>uF;CkPjjfcS|oh=s04VoWo#WM8~wZezLS6dyyB89(Kdc{_4i~}vaurUji z3^RSym<1%6^Osx^Ve9^qE%N34U!XefRHYK;B2%KMHGo{N%d1_cO`@JA$Zo@@Pb>+N zlc=$XVa*F?yjJ(7B(R)R3GP9vAl&1m;}O;BF2>es%PYdD#@1Urd#2tk`5iY zF$p}{4EU;HSn&~ISAoRiy~r@}(AMYI8!yI}Jh7zh2Oh3?F;Zn1d7ntD7?^^Ik4+1}g&8CkJdM$bk$`xa|FY1_D_R-@&5PyCVB*h4 zFmo}{JK=RUG*43w+}GXa(f-L^vqc*8r&h0SRvFmxQ^ThJka{P$2a9v8D7#ea)|hN7 z7RACCE2_U$woKH3_5UYmnw(i#|p59_bH7v_8xUnm?ih)tFqMUxMS1K`^tu}%NXNr~8@7Js4`_Nq; zcN;6KV%oI^^Y4mXG}26O)U0}q*<{tS(p+Ef+D3&(WYx0LEZ;5Ly&403RxK;d^Zl;V z*y84{T2`9r`<89ok6vl6Z}1h%#l0@!4sICENA^B_OryDbgcG>2?1_68dEZERsipaz zqzu1NWNHvuh=LJ$vOjsj&6L?d4LuWXv*HJ|3`_z&3nyfOt+h-<0X+}hXCcwFEVL*Y zQ=aGoJ6QxNE0T{W9?PR;vwehrkcXx)zoJEG7^xwb^s?`BINu!oR8Wc0s7UNK?fuXP zC0aNhJw85G4<0k#fdsM&K443Jb0FelPK zXOhM9C+d-s9R+{obPP{5Z6-VOH6^|tGL8x@r-oUO+@6i#kYz3})LtKv;|GSk#_mv2jL zRq7_a%e&;-x20~ZDZ+~oIW!tM9{Gmkm!@d88~Iaw&?yW>r_68p)}or>)hyu@jKC-oK7>ipRnaYb z)oAB?s6uLWPnz>#jPnNYB`a+9c^1G%dbc@M`^;7BSr0C{Y0ULV&;WxwH z1)FiU*u?uJsNo;lbXYzUgx+3#Hr-StP^D9$MZF6FS3zaVk9&21R|4poQjxPm)TL{L zU`{E((x{lF5=FN}xNFmF8t-9 z%d~*Qhol6Os=mdReI_Z56e+7;MaueiWicsBTZ>3p)hMZEN}VY)CQpr)iKhvICY};! zASpg4hEyeH3N`|YUu0+;%;0M`7B~K>It?qzgfr( z>5me0(;rbTzK@wQAqPr(r8xC{FIXVpq#)%N3t2VF9N3VCaPy?n8C^(;yR;RONw#%tB41dTTDEJmL+E<#f@b{qn@02r5P|k%_5p$ zusItBU`7%)pRM^>f{fuz9_3p&)A*hZVo{tag~N*af%Q zlL{y|E-Grplp>&kQ_4v%D6);OCN=4c%px1j#}U!^zzqZOr|FJsIuEwZLhh?%?0SVZCqW81X}%80U_tpXLBMp*g)~ggC#o5dTvJ0>aT7h|Lwxp?Lgp1= zal$}@4jWn^*0rq!Va-h9=!9)7)`>OxVD2KZR(tbr^<|3uGVi(kH03s}V&KaolNVp% z#Wp>G!NTQFAbutcp@dDox#M>iy{A~6^GLGHKPk!dk|pdC6b-g>qAvMx4?1?yv$j9A zkg=vR!X^%zMwj?jE>1&0EWK)mDlZD>#5ro$7BIkw-c<4-Ubl|01mhDV)&)WY@zvfD z*04Y_istDdAPP*I^9%nb0PL~P2)Ai$6^P(&ioVB}i7DQ5MrTy>CMT>(>Kz1snB_Vp7nOHV^e6sMJii?RSU$nW?J+wZ<8U*#_?nJa-HkR) zc_l8b3_3qyNbhube}K2A`+NzJF+_Dtjt)) zfVV8Eq4C`?Y8Gq&ZXF{p=`gl+F{0=OF@PyF>d0$(Y?8X6SWCAjtv&fY@91;h(tv^` z5p+Nzc!BBj-MV{KuBL4`QuSRmb~(a?fdMfg!;#I^?9EU-Wphms)*SI#^g1Wdg_aio zqB=gOepP?|a$ z*s6DG)nbMHy@_U%@`Jvg|1F+03`<(;aLeEI%h(C?T= zvDA=wkQu?3H;-@{6!X&xLm799&|TI(IXXg?eH4|!chtR|3AUx^BHq1m`3MZ#t)1PN zG^*6Y;X!?Xk!+P_FM6hPdbObj&7;md>+c2DndDse)wmf*&h6CeMG1%uM>7O5+<7G9 zh>*DQ2-O)BNO;NHH<32UKD3pf7M)08D0hl}8tR2>+s=qilkxaOQAic8pxN%KN%T!e z)+D-DKczfQr7t2tewost2GpQRp$lV#@$9S?V8C4(Hr1_b7zDYTAc8X z1ulg<=rm2K$pZN`S8uJ3I{?*EEmoBi2LOF^;n)DUP&?E?0%7nUiIeRh{*YpH6qU82 z1;u5@XdQ1qu?1~at=|4{e}USRYW%4Ruoi8o0D2=zu*NX*KIZlR+P|;S@1M%gza4(` z3GB;X{KsF-&CB$M57Opky;7}IcCoQf78_fZ0m8+KHVlC)?~i3#;-r)McDcTR*zj6u zD>p4Caa