iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. chronos_mcp/__init__.py +5 -0
  2. chronos_mcp/__main__.py +9 -0
  3. chronos_mcp/accounts.py +410 -0
  4. chronos_mcp/bulk.py +946 -0
  5. chronos_mcp/caldav_utils.py +149 -0
  6. chronos_mcp/calendars.py +204 -0
  7. chronos_mcp/config.py +187 -0
  8. chronos_mcp/credentials.py +190 -0
  9. chronos_mcp/events.py +515 -0
  10. chronos_mcp/exceptions.py +477 -0
  11. chronos_mcp/journals.py +477 -0
  12. chronos_mcp/logging_config.py +23 -0
  13. chronos_mcp/models.py +202 -0
  14. chronos_mcp/py.typed +0 -0
  15. chronos_mcp/rrule.py +259 -0
  16. chronos_mcp/search.py +315 -0
  17. chronos_mcp/server.py +121 -0
  18. chronos_mcp/tasks.py +518 -0
  19. chronos_mcp/tools/__init__.py +29 -0
  20. chronos_mcp/tools/accounts.py +151 -0
  21. chronos_mcp/tools/base.py +59 -0
  22. chronos_mcp/tools/bulk.py +557 -0
  23. chronos_mcp/tools/calendars.py +142 -0
  24. chronos_mcp/tools/events.py +698 -0
  25. chronos_mcp/tools/journals.py +310 -0
  26. chronos_mcp/tools/tasks.py +414 -0
  27. chronos_mcp/utils.py +163 -0
  28. chronos_mcp/validation.py +636 -0
  29. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
  30. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
  31. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
  32. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
  33. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  34. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
  35. tests/__init__.py +0 -0
  36. tests/conftest.py +91 -0
  37. tests/unit/__init__.py +0 -0
  38. tests/unit/test_accounts.py +380 -0
  39. tests/unit/test_accounts_ssrf.py +134 -0
  40. tests/unit/test_base.py +135 -0
  41. tests/unit/test_bulk.py +380 -0
  42. tests/unit/test_bulk_create.py +408 -0
  43. tests/unit/test_bulk_delete.py +341 -0
  44. tests/unit/test_bulk_resource_limits.py +74 -0
  45. tests/unit/test_caldav_utils.py +300 -0
  46. tests/unit/test_calendars.py +286 -0
  47. tests/unit/test_config.py +111 -0
  48. tests/unit/test_config_validation.py +128 -0
  49. tests/unit/test_credentials_security.py +189 -0
  50. tests/unit/test_cryptography_security.py +178 -0
  51. tests/unit/test_events.py +536 -0
  52. tests/unit/test_exceptions.py +58 -0
  53. tests/unit/test_journals.py +1097 -0
  54. tests/unit/test_models.py +95 -0
  55. tests/unit/test_race_conditions.py +202 -0
  56. tests/unit/test_recurring_events.py +156 -0
  57. tests/unit/test_rrule.py +217 -0
  58. tests/unit/test_search.py +372 -0
  59. tests/unit/test_search_advanced.py +333 -0
  60. tests/unit/test_server_input_validation.py +219 -0
  61. tests/unit/test_ssrf_protection.py +505 -0
  62. tests/unit/test_tasks.py +918 -0
  63. tests/unit/test_thread_safety.py +301 -0
  64. tests/unit/test_tools_journals.py +617 -0
  65. tests/unit/test_tools_tasks.py +968 -0
  66. tests/unit/test_url_validation_security.py +234 -0
  67. tests/unit/test_utils.py +180 -0
  68. tests/unit/test_validation.py +983 -0
@@ -0,0 +1,477 @@
1
+ """
2
+ Journal operations for Chronos MCP
3
+ """
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import List, Optional
8
+
9
+ import caldav
10
+ from caldav import Event as CalDAVEvent
11
+ from icalendar import Calendar as iCalendar
12
+ from icalendar import Journal as iJournal
13
+
14
+ from .caldav_utils import get_item_with_fallback
15
+ from .calendars import CalendarManager
16
+ from .exceptions import (
17
+ CalendarNotFoundError,
18
+ ChronosError,
19
+ EventCreationError,
20
+ EventDeletionError,
21
+ JournalNotFoundError,
22
+ )
23
+ from .logging_config import setup_logging
24
+ from .models import Journal
25
+ from .utils import ical_to_datetime
26
+
27
+ logger = setup_logging()
28
+
29
+
30
+ class JournalManager:
31
+ """Manage calendar journals (VJOURNAL)"""
32
+
33
+ def __init__(self, calendar_manager: CalendarManager):
34
+ self.calendars = calendar_manager
35
+
36
+ def _get_default_account(self) -> Optional[str]:
37
+ try:
38
+ return self.calendars.accounts.config.config.default_account
39
+ except Exception:
40
+ return None
41
+
42
+ def create_journal(
43
+ self,
44
+ calendar_uid: str,
45
+ summary: str,
46
+ description: Optional[str] = None,
47
+ dtstart: Optional[datetime] = None,
48
+ related_to: Optional[List[str]] = None,
49
+ account_alias: Optional[str] = None,
50
+ request_id: Optional[str] = None,
51
+ ) -> Optional[Journal]:
52
+ """Create a new journal entry - raises exceptions on failure"""
53
+ request_id = request_id or str(uuid.uuid4())
54
+
55
+ calendar = self.calendars.get_calendar(
56
+ calendar_uid, account_alias, request_id=request_id
57
+ )
58
+ if not calendar:
59
+ raise CalendarNotFoundError(
60
+ calendar_uid, account_alias, request_id=request_id
61
+ )
62
+
63
+ try:
64
+ # Use current time if dtstart not provided
65
+ if dtstart is None:
66
+ dtstart = datetime.now(timezone.utc)
67
+
68
+ cal = iCalendar()
69
+ journal = iJournal()
70
+
71
+ # Generate UID if not provided
72
+ journal_uid = str(uuid.uuid4())
73
+
74
+ journal.add("uid", journal_uid)
75
+ journal.add("summary", summary)
76
+ journal.add("dtstart", dtstart)
77
+ journal.add("dtstamp", datetime.now(timezone.utc))
78
+
79
+ if description:
80
+ journal.add("description", description)
81
+
82
+ if related_to:
83
+ for related_uid in related_to:
84
+ journal.add("related-to", related_uid)
85
+
86
+ cal.add_component(journal)
87
+
88
+ # Save to CalDAV server using component-specific method when available
89
+ ical_data = cal.to_ical().decode("utf-8")
90
+
91
+ if hasattr(calendar, "save_journal"):
92
+ logger.debug(
93
+ "Using calendar.save_journal() for optimized journal creation",
94
+ extra={"request_id": request_id},
95
+ )
96
+ try:
97
+ caldav_journal = calendar.save_journal(ical_data)
98
+ except Exception as e:
99
+ logger.warning(
100
+ f"calendar.save_journal() failed: {e}, falling back to save_event()",
101
+ extra={"request_id": request_id},
102
+ )
103
+ caldav_journal = calendar.save_event(ical_data)
104
+ else:
105
+ logger.debug(
106
+ "Server doesn't support calendar.save_journal(), using calendar.save_event()",
107
+ extra={"request_id": request_id},
108
+ )
109
+ caldav_journal = calendar.save_event(ical_data)
110
+
111
+ journal_model = Journal(
112
+ uid=journal_uid,
113
+ summary=summary,
114
+ description=description,
115
+ dtstart=dtstart,
116
+ related_to=related_to or [],
117
+ calendar_uid=calendar_uid,
118
+ account_alias=account_alias or self._get_default_account() or "default",
119
+ )
120
+
121
+ return journal_model
122
+
123
+ except caldav.lib.error.AuthorizationError as e:
124
+ logger.error(
125
+ f"Authorization error creating journal '{summary}': {e}",
126
+ extra={"request_id": request_id},
127
+ )
128
+ raise EventCreationError(
129
+ summary, "Authorization failed", request_id=request_id
130
+ )
131
+ except Exception as e:
132
+ logger.error(
133
+ f"Error creating journal '{summary}': {e}",
134
+ extra={"request_id": request_id},
135
+ )
136
+ raise EventCreationError(summary, str(e), request_id=request_id)
137
+
138
+ def get_journal(
139
+ self,
140
+ journal_uid: str,
141
+ calendar_uid: str,
142
+ account_alias: Optional[str] = None,
143
+ request_id: Optional[str] = None,
144
+ ) -> Optional[Journal]:
145
+ """Get a specific journal by UID"""
146
+ request_id = request_id or str(uuid.uuid4())
147
+
148
+ calendar = self.calendars.get_calendar(
149
+ calendar_uid, account_alias, request_id=request_id
150
+ )
151
+ if not calendar:
152
+ raise CalendarNotFoundError(
153
+ calendar_uid, account_alias, request_id=request_id
154
+ )
155
+
156
+ try:
157
+ # Use utility function to find journal with automatic fallback
158
+ caldav_journal = get_item_with_fallback(
159
+ calendar, journal_uid, "journal", request_id=request_id
160
+ )
161
+ return self._parse_caldav_journal(caldav_journal, calendar_uid, account_alias)
162
+ except ValueError:
163
+ # get_item_with_fallback raises ValueError when not found
164
+ raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
165
+
166
+ except JournalNotFoundError:
167
+ raise
168
+ except Exception as e:
169
+ logger.error(
170
+ f"Error getting journal '{journal_uid}': {e}",
171
+ extra={"request_id": request_id},
172
+ )
173
+ raise ChronosError(
174
+ f"Failed to get journal: {str(e)}", request_id=request_id
175
+ )
176
+
177
+ def list_journals(
178
+ self,
179
+ calendar_uid: str,
180
+ limit: Optional[int] = None,
181
+ account_alias: Optional[str] = None,
182
+ request_id: Optional[str] = None,
183
+ ) -> List[Journal]:
184
+ """List all journals in a calendar"""
185
+ request_id = request_id or str(uuid.uuid4())
186
+
187
+ calendar = self.calendars.get_calendar(
188
+ calendar_uid, account_alias, request_id=request_id
189
+ )
190
+ if not calendar:
191
+ raise CalendarNotFoundError(
192
+ calendar_uid, account_alias, request_id=request_id
193
+ )
194
+
195
+ journals = []
196
+ try:
197
+ # Try component-specific method first for better performance
198
+ if hasattr(calendar, "journals"):
199
+ try:
200
+ logger.debug(
201
+ "Using calendar.journals() for server-side filtering",
202
+ extra={"request_id": request_id},
203
+ )
204
+ journal_objects = calendar.journals()
205
+
206
+ for caldav_journal in journal_objects:
207
+ journal_data = self._parse_caldav_journal(
208
+ caldav_journal, calendar_uid, account_alias
209
+ )
210
+ if journal_data:
211
+ journals.append(journal_data)
212
+
213
+ except Exception as e:
214
+ logger.warning(
215
+ f"calendar.journals() failed: {e}, falling back to calendar.events()",
216
+ extra={"request_id": request_id},
217
+ )
218
+ # Fall through to fallback method
219
+ raise
220
+ else:
221
+ # Fallback method for servers without journals() support
222
+ logger.debug(
223
+ "Server doesn't support calendar.journals(), using calendar.events() with client-side filtering",
224
+ extra={"request_id": request_id},
225
+ )
226
+ events = calendar.events()
227
+
228
+ for caldav_event in events:
229
+ journal_data = self._parse_caldav_journal(
230
+ caldav_event, calendar_uid, account_alias
231
+ )
232
+ if journal_data:
233
+ journals.append(journal_data)
234
+
235
+ except Exception as e:
236
+ # If journals() method failed, try the fallback approach
237
+ if hasattr(calendar, "journals"):
238
+ try:
239
+ logger.info(
240
+ "Retrying with calendar.events() fallback method",
241
+ extra={"request_id": request_id},
242
+ )
243
+ events = calendar.events()
244
+
245
+ for caldav_event in events:
246
+ journal_data = self._parse_caldav_journal(
247
+ caldav_event, calendar_uid, account_alias
248
+ )
249
+ if journal_data:
250
+ journals.append(journal_data)
251
+ except Exception as fallback_error:
252
+ logger.error(
253
+ f"Error listing journals (both methods failed): {fallback_error}",
254
+ extra={"request_id": request_id},
255
+ )
256
+ else:
257
+ logger.error(
258
+ f"Error listing journals: {e}", extra={"request_id": request_id}
259
+ )
260
+
261
+ # Apply limit if specified
262
+ if limit and len(journals) > limit:
263
+ journals = journals[:limit]
264
+
265
+ return journals
266
+
267
+ def update_journal(
268
+ self,
269
+ journal_uid: str,
270
+ calendar_uid: str,
271
+ summary: Optional[str] = None,
272
+ description: Optional[str] = None,
273
+ dtstart: Optional[datetime] = None,
274
+ related_to: Optional[List[str]] = None,
275
+ account_alias: Optional[str] = None,
276
+ request_id: Optional[str] = None,
277
+ ) -> Optional[Journal]:
278
+ """Update an existing journal - raises exceptions on failure"""
279
+ request_id = request_id or str(uuid.uuid4())
280
+
281
+ calendar = self.calendars.get_calendar(
282
+ calendar_uid, account_alias, request_id=request_id
283
+ )
284
+ if not calendar:
285
+ raise CalendarNotFoundError(
286
+ calendar_uid, account_alias, request_id=request_id
287
+ )
288
+
289
+ try:
290
+ # Use utility function to find journal with automatic fallback
291
+ try:
292
+ caldav_journal = get_item_with_fallback(
293
+ calendar, journal_uid, "journal", request_id=request_id
294
+ )
295
+ except ValueError:
296
+ raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
297
+
298
+ # Parse existing journal data
299
+ ical = iCalendar.from_ical(caldav_journal.data)
300
+ existing_journal = None
301
+
302
+ for component in ical.walk():
303
+ if component.name == "VJOURNAL":
304
+ existing_journal = component
305
+ break
306
+
307
+ if not existing_journal:
308
+ raise EventCreationError(
309
+ f"Journal {journal_uid}",
310
+ "Could not parse existing journal data",
311
+ request_id=request_id,
312
+ )
313
+
314
+ # Update only provided fields
315
+ if summary is not None:
316
+ existing_journal["SUMMARY"] = summary
317
+
318
+ if description is not None:
319
+ if description:
320
+ existing_journal["DESCRIPTION"] = description
321
+ elif "DESCRIPTION" in existing_journal:
322
+ del existing_journal["DESCRIPTION"]
323
+
324
+ if dtstart is not None:
325
+ if "DTSTART" in existing_journal:
326
+ del existing_journal["DTSTART"]
327
+ existing_journal.add("DTSTART", dtstart)
328
+
329
+ # Handle RELATED-TO property updates
330
+ if related_to is not None:
331
+ # Remove all existing RELATED-TO properties
332
+ if "RELATED-TO" in existing_journal:
333
+ del existing_journal["RELATED-TO"]
334
+
335
+ # Add new RELATED-TO properties if provided
336
+ if related_to:
337
+ for related_uid in related_to:
338
+ existing_journal.add("RELATED-TO", related_uid)
339
+
340
+ # Update last-modified timestamp
341
+ if "LAST-MODIFIED" in existing_journal:
342
+ del existing_journal["LAST-MODIFIED"]
343
+ existing_journal.add("LAST-MODIFIED", datetime.now(timezone.utc))
344
+
345
+ # Save the updated journal
346
+ caldav_journal.data = ical.to_ical().decode("utf-8")
347
+ caldav_journal.save()
348
+
349
+ # Parse and return the updated journal
350
+ return self._parse_caldav_journal(
351
+ caldav_journal, calendar_uid, account_alias
352
+ )
353
+
354
+ except JournalNotFoundError:
355
+ raise
356
+ except EventCreationError:
357
+ raise
358
+ except Exception as e:
359
+ logger.error(
360
+ f"Error updating journal '{journal_uid}': {e}",
361
+ extra={"request_id": request_id},
362
+ )
363
+ raise EventCreationError(
364
+ journal_uid,
365
+ f"Failed to update journal: {str(e)}",
366
+ request_id=request_id,
367
+ )
368
+
369
+ def delete_journal(
370
+ self,
371
+ calendar_uid: str,
372
+ journal_uid: str,
373
+ account_alias: Optional[str] = None,
374
+ request_id: Optional[str] = None,
375
+ ) -> bool:
376
+ """Delete a journal by UID - raises exceptions on failure"""
377
+ request_id = request_id or str(uuid.uuid4())
378
+
379
+ calendar = self.calendars.get_calendar(
380
+ calendar_uid, account_alias, request_id=request_id
381
+ )
382
+ if not calendar:
383
+ raise CalendarNotFoundError(
384
+ calendar_uid, account_alias, request_id=request_id
385
+ )
386
+
387
+ try:
388
+ # Use utility function to find journal with automatic fallback
389
+ journal = get_item_with_fallback(
390
+ calendar, journal_uid, "journal", request_id=request_id
391
+ )
392
+ journal.delete()
393
+ logger.info(
394
+ f"Deleted journal '{journal_uid}'",
395
+ extra={"request_id": request_id},
396
+ )
397
+ return True
398
+ except ValueError:
399
+ # get_item_with_fallback raises ValueError when not found
400
+ raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
401
+
402
+ except JournalNotFoundError:
403
+ raise
404
+ except caldav.lib.error.AuthorizationError as e:
405
+ logger.error(
406
+ f"Authorization error deleting journal '{journal_uid}': {e}",
407
+ extra={"request_id": request_id},
408
+ )
409
+ raise EventDeletionError(
410
+ journal_uid, "Authorization failed", request_id=request_id
411
+ )
412
+ except Exception as e:
413
+ logger.error(
414
+ f"Error deleting journal '{journal_uid}': {e}",
415
+ extra={"request_id": request_id},
416
+ )
417
+ raise EventDeletionError(journal_uid, str(e), request_id=request_id)
418
+
419
+ def _parse_caldav_journal(
420
+ self, caldav_event: CalDAVEvent, calendar_uid: str, account_alias: Optional[str]
421
+ ) -> Optional[Journal]:
422
+ """Parse CalDAV VJOURNAL to Journal model"""
423
+ try:
424
+ # Parse iCalendar data
425
+ ical = iCalendar.from_ical(caldav_event.data)
426
+
427
+ for component in ical.walk():
428
+ # Debug logging to see what components we find
429
+ logger.debug(f"Found component: {component.name}")
430
+ if component.name == "VJOURNAL":
431
+ # Parse date/time values
432
+ dtstart_dt = None
433
+ if component.get("dtstart"):
434
+ dtstart_dt = ical_to_datetime(component.get("dtstart"))
435
+
436
+ # Parse categories
437
+ categories = []
438
+ if component.get("categories"):
439
+ cat_value = component.get("categories")
440
+ if isinstance(cat_value, list):
441
+ categories = [str(cat) for cat in cat_value]
442
+ else:
443
+ categories = [str(cat_value)]
444
+
445
+ # Parse RELATED-TO properties
446
+ related_to = []
447
+ if component.get("related-to"):
448
+ related_prop = component.get("related-to")
449
+ if isinstance(related_prop, list):
450
+ related_to = [str(r) for r in related_prop]
451
+ else:
452
+ related_to = [str(related_prop)]
453
+
454
+ # Parse basic journal data
455
+ journal = Journal(
456
+ uid=str(component.get("uid", "")),
457
+ summary=str(component.get("summary", "No Title")),
458
+ description=(
459
+ str(component.get("description", ""))
460
+ if component.get("description")
461
+ else None
462
+ ),
463
+ dtstart=dtstart_dt or datetime.now(timezone.utc),
464
+ categories=categories,
465
+ related_to=related_to,
466
+ calendar_uid=calendar_uid,
467
+ account_alias=account_alias
468
+ or self._get_default_account()
469
+ or "default",
470
+ )
471
+
472
+ return journal
473
+
474
+ except Exception as e:
475
+ logger.error(f"Error parsing journal: {e}")
476
+
477
+ return None
@@ -0,0 +1,23 @@
1
+ """
2
+ Shared logging configuration for Chronos MCP
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+
8
+
9
+ def setup_logging():
10
+ """Configure logging to stderr for all Chronos modules"""
11
+ # Only configure if not already configured
12
+ if not logging.getLogger().handlers:
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ stream=sys.stderr,
16
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
17
+ )
18
+
19
+ import inspect
20
+
21
+ frame = inspect.stack()[1]
22
+ module = inspect.getmodule(frame[0])
23
+ return logging.getLogger(module.__name__ if module else __name__)
chronos_mcp/models.py ADDED
@@ -0,0 +1,202 @@
1
+ """
2
+ Data models for Chronos MCP
3
+ """
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import List, Optional
8
+
9
+ from pydantic import BaseModel, Field, HttpUrl, field_validator
10
+
11
+
12
+ class AccountStatus(str, Enum):
13
+ """Account connection status"""
14
+
15
+ CONNECTED = "connected"
16
+ DISCONNECTED = "disconnected"
17
+ ERROR = "error"
18
+ UNKNOWN = "unknown"
19
+
20
+
21
+ class EventStatus(str, Enum):
22
+ """Event status values"""
23
+
24
+ TENTATIVE = "TENTATIVE"
25
+ CONFIRMED = "CONFIRMED"
26
+ CANCELLED = "CANCELLED"
27
+
28
+
29
+ class TaskStatus(str, Enum):
30
+ """Task status values"""
31
+
32
+ NEEDS_ACTION = "NEEDS-ACTION"
33
+ IN_PROCESS = "IN-PROCESS"
34
+ COMPLETED = "COMPLETED"
35
+ CANCELLED = "CANCELLED"
36
+
37
+
38
+ class AttendeeRole(str, Enum):
39
+ """Attendee roles"""
40
+
41
+ CHAIR = "CHAIR"
42
+ REQ_PARTICIPANT = "REQ-PARTICIPANT"
43
+ OPT_PARTICIPANT = "OPT-PARTICIPANT"
44
+ NON_PARTICIPANT = "NON-PARTICIPANT"
45
+
46
+
47
+ class AttendeeStatus(str, Enum):
48
+ """Attendee participation status"""
49
+
50
+ NEEDS_ACTION = "NEEDS-ACTION"
51
+ ACCEPTED = "ACCEPTED"
52
+ DECLINED = "DECLINED"
53
+ TENTATIVE = "TENTATIVE"
54
+ DELEGATED = "DELEGATED"
55
+
56
+
57
+ class Account(BaseModel):
58
+ """CalDAV account configuration"""
59
+
60
+ alias: str = Field(..., description="Account alias/identifier")
61
+ url: HttpUrl = Field(..., description="CalDAV server URL")
62
+ username: str = Field(..., description="Username for authentication")
63
+ password: Optional[str] = Field(
64
+ None, description="Password (optional if using keyring)"
65
+ )
66
+ display_name: Optional[str] = Field(
67
+ None, description="Display name for the account"
68
+ )
69
+ status: AccountStatus = Field(
70
+ AccountStatus.UNKNOWN, description="Connection status"
71
+ )
72
+ last_sync: Optional[datetime] = Field(None, description="Last successful sync time")
73
+
74
+ @field_validator('password')
75
+ @classmethod
76
+ def validate_password_field(cls, v: Optional[str]) -> Optional[str]:
77
+ """Validate password for security (defense-in-depth)"""
78
+ if v is not None and v != "":
79
+ from .validation import InputValidator
80
+ from .exceptions import ValidationError as ChronosValidationError
81
+ try:
82
+ # Validate to prevent injection attacks at model layer
83
+ return InputValidator.validate_text_field(v, "password", required=False)
84
+ except ChronosValidationError as e:
85
+ # Re-raise as Pydantic ValidationError for proper handling
86
+ from pydantic_core import PydanticCustomError
87
+ raise PydanticCustomError(
88
+ 'password_validation',
89
+ 'Password validation failed: {error}',
90
+ {'error': str(e)}
91
+ )
92
+ return v
93
+
94
+
95
+ class Calendar(BaseModel):
96
+ """Calendar information"""
97
+
98
+ uid: str = Field(..., description="Calendar unique identifier")
99
+ name: str = Field(..., description="Calendar display name")
100
+ description: Optional[str] = Field(None, description="Calendar description")
101
+ color: Optional[str] = Field(None, description="Calendar color (hex)")
102
+ account_alias: str = Field(..., description="Associated account alias")
103
+ url: Optional[str] = Field(None, description="Calendar URL")
104
+ read_only: bool = Field(False, description="Whether calendar is read-only")
105
+
106
+
107
+ class Attendee(BaseModel):
108
+ """Event attendee"""
109
+
110
+ email: str = Field(..., description="Attendee email address")
111
+ name: Optional[str] = Field(None, description="Attendee display name")
112
+ role: AttendeeRole = Field(
113
+ AttendeeRole.REQ_PARTICIPANT, description="Attendee role"
114
+ )
115
+ status: AttendeeStatus = Field(
116
+ AttendeeStatus.NEEDS_ACTION, description="Participation status"
117
+ )
118
+ rsvp: bool = Field(True, description="Whether RSVP is requested")
119
+
120
+
121
+ class Alarm(BaseModel):
122
+ """Event reminder/alarm"""
123
+
124
+ trigger: str = Field(
125
+ ..., description="Trigger time (e.g., '-PT15M' for 15 minutes before)"
126
+ )
127
+ action: str = Field("DISPLAY", description="Alarm action type")
128
+ description: Optional[str] = Field(None, description="Alarm description")
129
+
130
+
131
+ class Event(BaseModel):
132
+ """Calendar event"""
133
+
134
+ uid: str = Field(..., description="Event unique identifier")
135
+ summary: str = Field(..., description="Event title/summary")
136
+ description: Optional[str] = Field(None, description="Event description")
137
+ start: datetime = Field(..., description="Event start time")
138
+ end: datetime = Field(..., description="Event end time")
139
+ all_day: bool = Field(False, description="Whether this is an all-day event")
140
+ location: Optional[str] = Field(None, description="Event location")
141
+ status: EventStatus = Field(EventStatus.CONFIRMED, description="Event status")
142
+ attendees: List[Attendee] = Field(
143
+ default_factory=list, description="Event attendees"
144
+ )
145
+ alarms: List[Alarm] = Field(
146
+ default_factory=list, description="Event alarms/reminders"
147
+ )
148
+ recurrence_rule: Optional[str] = Field(
149
+ None, description="RRULE for recurring events"
150
+ )
151
+ recurrence_id: Optional[datetime] = Field(
152
+ None, description="Recurrence instance identifier"
153
+ )
154
+ calendar_uid: str = Field(..., description="Parent calendar UID")
155
+ account_alias: str = Field(..., description="Associated account alias")
156
+ categories: List[str] = Field(
157
+ default_factory=list, description="Event categories/tags"
158
+ )
159
+ url: Optional[str] = Field(None, description="Associated URL")
160
+ related_to: List[str] = Field(
161
+ default_factory=list, description="Related component UIDs"
162
+ )
163
+
164
+
165
+ class Task(BaseModel):
166
+ """Calendar task (VTODO)"""
167
+
168
+ uid: str = Field(..., description="Task unique identifier")
169
+ summary: str = Field(..., description="Task summary")
170
+ description: Optional[str] = Field(None, description="Task description")
171
+ due: Optional[datetime] = Field(None, description="Task due date")
172
+ completed: Optional[datetime] = Field(None, description="Task completion date")
173
+ priority: Optional[int] = Field(
174
+ None, description="Task priority (1-9, 1 is highest)"
175
+ )
176
+ status: TaskStatus = Field(TaskStatus.NEEDS_ACTION, description="Task status")
177
+ percent_complete: int = Field(0, description="Completion percentage (0-100)")
178
+ categories: List[str] = Field(
179
+ default_factory=list, description="Task categories/tags"
180
+ )
181
+ related_to: List[str] = Field(
182
+ default_factory=list, description="Related component UIDs"
183
+ )
184
+ calendar_uid: str = Field(..., description="Parent calendar UID")
185
+ account_alias: str = Field(..., description="Associated account alias")
186
+
187
+
188
+ class Journal(BaseModel):
189
+ """Calendar journal entry (VJOURNAL)"""
190
+
191
+ uid: str = Field(..., description="Journal unique identifier")
192
+ summary: str = Field(..., description="Journal title/summary")
193
+ description: Optional[str] = Field(None, description="Journal content")
194
+ dtstart: datetime = Field(..., description="Journal entry date/time")
195
+ categories: List[str] = Field(
196
+ default_factory=list, description="Journal categories/tags"
197
+ )
198
+ related_to: List[str] = Field(
199
+ default_factory=list, description="Related component UIDs"
200
+ )
201
+ calendar_uid: str = Field(..., description="Parent calendar UID")
202
+ account_alias: str = Field(..., description="Associated account alias")