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.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secure credential management for Chronos MCP using system keyring.
|
|
3
|
+
|
|
4
|
+
This module provides secure storage for CalDAV passwords using the system keyring
|
|
5
|
+
when available, with fallback to configuration file (with warnings).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
# Try to import keyring, but handle its absence gracefully
|
|
11
|
+
try:
|
|
12
|
+
import keyring
|
|
13
|
+
|
|
14
|
+
KEYRING_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
KEYRING_AVAILABLE = False
|
|
17
|
+
keyring = None
|
|
18
|
+
|
|
19
|
+
from .logging_config import setup_logging
|
|
20
|
+
|
|
21
|
+
logger = setup_logging()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialManager:
|
|
25
|
+
"""
|
|
26
|
+
Manages secure credential storage using system keyring with fallback.
|
|
27
|
+
|
|
28
|
+
Keyring service name follows the pattern: chronos-mcp
|
|
29
|
+
Keyring keys follow the pattern: caldav:{alias}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
SERVICE_NAME = "chronos-mcp"
|
|
33
|
+
KEY_PREFIX = "caldav:"
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
"""Initialize the credential manager."""
|
|
37
|
+
self.keyring_available = KEYRING_AVAILABLE
|
|
38
|
+
self._keyring_backend = None
|
|
39
|
+
|
|
40
|
+
if self.keyring_available:
|
|
41
|
+
try:
|
|
42
|
+
# Test keyring availability by getting the backend
|
|
43
|
+
self._keyring_backend = keyring.get_keyring()
|
|
44
|
+
backend_name = type(self._keyring_backend).__name__
|
|
45
|
+
|
|
46
|
+
# Check if we have a null/fail backend
|
|
47
|
+
if "fail" in backend_name.lower() or "null" in backend_name.lower():
|
|
48
|
+
self.keyring_available = False
|
|
49
|
+
logger.warning(f"Keyring backend is non-functional: {backend_name}")
|
|
50
|
+
else:
|
|
51
|
+
logger.info(f"Using keyring backend: {backend_name}")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
self.keyring_available = False
|
|
54
|
+
logger.warning(f"Keyring initialization failed: {e}")
|
|
55
|
+
else:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"Keyring module not available - passwords will be stored in config file"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _get_keyring_key(self, alias: str) -> str:
|
|
61
|
+
"""Generate the keyring key for an account alias."""
|
|
62
|
+
return f"{self.KEY_PREFIX}{alias}"
|
|
63
|
+
|
|
64
|
+
def get_password(
|
|
65
|
+
self, alias: str, fallback_password: Optional[str] = None
|
|
66
|
+
) -> Optional[str]:
|
|
67
|
+
"""
|
|
68
|
+
Retrieve password from keyring, with fallback to provided value.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
alias: Account alias
|
|
72
|
+
fallback_password: Password from config file (used if keyring fails)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Password string or None if not found
|
|
76
|
+
"""
|
|
77
|
+
if self.keyring_available:
|
|
78
|
+
try:
|
|
79
|
+
key = self._get_keyring_key(alias)
|
|
80
|
+
password = keyring.get_password(self.SERVICE_NAME, key)
|
|
81
|
+
|
|
82
|
+
if password:
|
|
83
|
+
logger.debug(
|
|
84
|
+
"Retrieved password from keyring for account: [REDACTED]"
|
|
85
|
+
)
|
|
86
|
+
return password
|
|
87
|
+
elif fallback_password:
|
|
88
|
+
logger.warning(
|
|
89
|
+
f"Password for '{alias}' found in config file but not in keyring. "
|
|
90
|
+
"Consider running the migration script to securely store passwords in keyring: "
|
|
91
|
+
"python -m chronos_mcp.scripts.migrate_to_keyring"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(f"Failed to retrieve password from keyring: {e}")
|
|
96
|
+
|
|
97
|
+
if fallback_password:
|
|
98
|
+
if not self.keyring_available:
|
|
99
|
+
logger.debug("Using password from config file for account: [REDACTED]")
|
|
100
|
+
return fallback_password
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def set_password(self, alias: str, password: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Store password in keyring.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
alias: Account alias
|
|
110
|
+
password: Password to store
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if successfully stored, False otherwise
|
|
114
|
+
"""
|
|
115
|
+
if not self.keyring_available:
|
|
116
|
+
logger.debug(
|
|
117
|
+
"Keyring not available, cannot store password for account: [REDACTED]"
|
|
118
|
+
)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
key = self._get_keyring_key(alias)
|
|
123
|
+
keyring.set_password(self.SERVICE_NAME, key, password)
|
|
124
|
+
logger.info("Password stored in keyring for account: [REDACTED]")
|
|
125
|
+
return True
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"Failed to store password in keyring: {e}")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def delete_password(self, alias: str) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Remove password from keyring.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
alias: Account alias
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if successfully deleted, False otherwise
|
|
139
|
+
"""
|
|
140
|
+
if not self.keyring_available:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
key = self._get_keyring_key(alias)
|
|
145
|
+
keyring.delete_password(self.SERVICE_NAME, key)
|
|
146
|
+
logger.info("Password removed from keyring for account: [REDACTED]")
|
|
147
|
+
return True
|
|
148
|
+
except keyring.errors.PasswordDeleteError:
|
|
149
|
+
logger.debug("No password in keyring for account: [REDACTED]")
|
|
150
|
+
return False
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Failed to delete password from keyring: {e}")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def get_status(self) -> Dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Get credential manager status information.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary with status information
|
|
161
|
+
"""
|
|
162
|
+
status = {
|
|
163
|
+
"keyring_available": self.keyring_available,
|
|
164
|
+
"backend": None,
|
|
165
|
+
"backend_type": None,
|
|
166
|
+
"secure": False,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if self.keyring_available and self._keyring_backend:
|
|
170
|
+
backend_name = type(self._keyring_backend).__name__
|
|
171
|
+
status["backend"] = str(self._keyring_backend)
|
|
172
|
+
status["backend_type"] = backend_name
|
|
173
|
+
|
|
174
|
+
# Determine if backend is secure
|
|
175
|
+
secure_backends = ["Keychain", "SecretService", "KWallet", "Windows"]
|
|
176
|
+
status["secure"] = any(sb in backend_name for sb in secure_backends)
|
|
177
|
+
|
|
178
|
+
return status
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Singleton instance
|
|
182
|
+
_credential_manager = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_credential_manager() -> CredentialManager:
|
|
186
|
+
"""Get the singleton credential manager instance."""
|
|
187
|
+
global _credential_manager
|
|
188
|
+
if _credential_manager is None:
|
|
189
|
+
_credential_manager = CredentialManager()
|
|
190
|
+
return _credential_manager
|
chronos_mcp/events.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event operations for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import caldav
|
|
10
|
+
from caldav import Event as CalDAVEvent
|
|
11
|
+
from icalendar import Calendar as iCalendar
|
|
12
|
+
from icalendar import Event as iEvent
|
|
13
|
+
|
|
14
|
+
from .caldav_utils import get_item_with_fallback
|
|
15
|
+
from .calendars import CalendarManager
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
CalendarNotFoundError,
|
|
18
|
+
EventCreationError,
|
|
19
|
+
EventDeletionError,
|
|
20
|
+
EventNotFoundError,
|
|
21
|
+
)
|
|
22
|
+
from .logging_config import setup_logging
|
|
23
|
+
from .models import Alarm, Attendee, Event
|
|
24
|
+
from .utils import ical_to_datetime, validate_rrule
|
|
25
|
+
|
|
26
|
+
logger = setup_logging()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventManager:
|
|
30
|
+
"""Manage calendar events"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, calendar_manager: CalendarManager):
|
|
33
|
+
self.calendars = calendar_manager
|
|
34
|
+
|
|
35
|
+
def _get_default_account(self) -> Optional[str]:
|
|
36
|
+
try:
|
|
37
|
+
return self.calendars.accounts.config.config.default_account
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def create_event(
|
|
42
|
+
self,
|
|
43
|
+
calendar_uid: str,
|
|
44
|
+
summary: str,
|
|
45
|
+
start: datetime,
|
|
46
|
+
end: datetime,
|
|
47
|
+
description: Optional[str] = None,
|
|
48
|
+
location: Optional[str] = None,
|
|
49
|
+
all_day: bool = False,
|
|
50
|
+
attendees: Optional[List[Dict[str, Any]]] = None,
|
|
51
|
+
alarm_minutes: Optional[int] = None,
|
|
52
|
+
recurrence_rule: Optional[str] = None,
|
|
53
|
+
related_to: Optional[List[str]] = None,
|
|
54
|
+
account_alias: Optional[str] = None,
|
|
55
|
+
request_id: Optional[str] = None,
|
|
56
|
+
) -> Optional[Event]:
|
|
57
|
+
"""Create a new event - raises exceptions on failure"""
|
|
58
|
+
request_id = request_id or str(uuid.uuid4())
|
|
59
|
+
|
|
60
|
+
calendar = self.calendars.get_calendar(
|
|
61
|
+
calendar_uid, account_alias, request_id=request_id
|
|
62
|
+
)
|
|
63
|
+
if not calendar:
|
|
64
|
+
raise CalendarNotFoundError(
|
|
65
|
+
calendar_uid, account_alias, request_id=request_id
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Fix all-day event times
|
|
70
|
+
if all_day:
|
|
71
|
+
# Ensure start is at midnight
|
|
72
|
+
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
73
|
+
# End should be midnight of the next day (24 hours later)
|
|
74
|
+
end = start + timedelta(days=1)
|
|
75
|
+
|
|
76
|
+
# Validate RRULE if provided
|
|
77
|
+
if recurrence_rule:
|
|
78
|
+
is_valid, error_msg = validate_rrule(recurrence_rule)
|
|
79
|
+
if not is_valid:
|
|
80
|
+
raise EventCreationError(
|
|
81
|
+
summary, f"Invalid RRULE: {error_msg}", request_id=request_id
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
cal = iCalendar()
|
|
85
|
+
event = iEvent()
|
|
86
|
+
|
|
87
|
+
# Generate UID if not provided
|
|
88
|
+
event_uid = str(uuid.uuid4())
|
|
89
|
+
|
|
90
|
+
event.add("uid", event_uid)
|
|
91
|
+
event.add("summary", summary)
|
|
92
|
+
event.add("dtstart", start)
|
|
93
|
+
event.add("dtend", end)
|
|
94
|
+
event.add("dtstamp", datetime.now(timezone.utc))
|
|
95
|
+
|
|
96
|
+
if description:
|
|
97
|
+
event.add("description", description)
|
|
98
|
+
if location:
|
|
99
|
+
event.add("location", location)
|
|
100
|
+
if recurrence_rule:
|
|
101
|
+
event.add("rrule", recurrence_rule)
|
|
102
|
+
|
|
103
|
+
if attendees:
|
|
104
|
+
for att in attendees:
|
|
105
|
+
attendee_str = f"mailto:{att['email']}"
|
|
106
|
+
event.add(
|
|
107
|
+
"attendee",
|
|
108
|
+
attendee_str,
|
|
109
|
+
parameters={
|
|
110
|
+
"CN": att.get("name", att["email"]),
|
|
111
|
+
"ROLE": att.get("role", "REQ-PARTICIPANT"),
|
|
112
|
+
"PARTSTAT": att.get("status", "NEEDS-ACTION"),
|
|
113
|
+
"RSVP": "TRUE" if att.get("rsvp", True) else "FALSE",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if related_to:
|
|
118
|
+
for related_uid in related_to:
|
|
119
|
+
event.add("related-to", related_uid)
|
|
120
|
+
|
|
121
|
+
if alarm_minutes:
|
|
122
|
+
from icalendar import Alarm as iAlarm
|
|
123
|
+
|
|
124
|
+
alarm = iAlarm()
|
|
125
|
+
alarm.add("action", "DISPLAY")
|
|
126
|
+
alarm.add("trigger", timedelta(minutes=-alarm_minutes))
|
|
127
|
+
alarm.add("description", summary)
|
|
128
|
+
event.add_component(alarm)
|
|
129
|
+
|
|
130
|
+
cal.add_component(event)
|
|
131
|
+
|
|
132
|
+
# Save to CalDAV server
|
|
133
|
+
caldav_event = calendar.save_event(cal.to_ical().decode("utf-8"))
|
|
134
|
+
|
|
135
|
+
event_model = Event(
|
|
136
|
+
uid=event_uid,
|
|
137
|
+
summary=summary,
|
|
138
|
+
description=description,
|
|
139
|
+
start=start,
|
|
140
|
+
end=end,
|
|
141
|
+
all_day=all_day,
|
|
142
|
+
location=location,
|
|
143
|
+
calendar_uid=calendar_uid,
|
|
144
|
+
account_alias=account_alias or self._get_default_account() or "default",
|
|
145
|
+
recurrence_rule=recurrence_rule,
|
|
146
|
+
related_to=related_to or [],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Add attendees to model
|
|
150
|
+
if attendees:
|
|
151
|
+
event_model.attendees = [
|
|
152
|
+
Attendee(
|
|
153
|
+
email=att["email"],
|
|
154
|
+
name=att.get("name", att["email"]),
|
|
155
|
+
role=att.get("role", "REQ-PARTICIPANT"),
|
|
156
|
+
status=att.get("status", "NEEDS-ACTION"),
|
|
157
|
+
rsvp=att.get("rsvp", True),
|
|
158
|
+
)
|
|
159
|
+
for att in attendees
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
# Add alarm to model
|
|
163
|
+
if alarm_minutes:
|
|
164
|
+
event_model.alarms = [
|
|
165
|
+
Alarm(
|
|
166
|
+
action="DISPLAY",
|
|
167
|
+
trigger=f"-PT{alarm_minutes}M",
|
|
168
|
+
description=summary,
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
return event_model
|
|
173
|
+
|
|
174
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
175
|
+
logger.error(
|
|
176
|
+
f"Authorization error creating event '{summary}': {e}",
|
|
177
|
+
extra={"request_id": request_id},
|
|
178
|
+
)
|
|
179
|
+
raise EventCreationError(
|
|
180
|
+
summary, "Authorization failed", request_id=request_id
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(
|
|
184
|
+
f"Error creating event '{summary}': {e}",
|
|
185
|
+
extra={"request_id": request_id},
|
|
186
|
+
)
|
|
187
|
+
raise EventCreationError(summary, str(e), request_id=request_id)
|
|
188
|
+
|
|
189
|
+
def get_events_range(
|
|
190
|
+
self,
|
|
191
|
+
calendar_uid: str,
|
|
192
|
+
start_date: datetime,
|
|
193
|
+
end_date: datetime,
|
|
194
|
+
account_alias: Optional[str] = None,
|
|
195
|
+
request_id: Optional[str] = None,
|
|
196
|
+
) -> List[Event]:
|
|
197
|
+
"""Get events within a date range - raises exceptions on failure"""
|
|
198
|
+
request_id = request_id or str(uuid.uuid4())
|
|
199
|
+
|
|
200
|
+
calendar = self.calendars.get_calendar(
|
|
201
|
+
calendar_uid, account_alias, request_id=request_id
|
|
202
|
+
)
|
|
203
|
+
if not calendar:
|
|
204
|
+
raise CalendarNotFoundError(
|
|
205
|
+
calendar_uid, account_alias, request_id=request_id
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
events = []
|
|
209
|
+
try:
|
|
210
|
+
# Search for events in date range
|
|
211
|
+
results = calendar.date_search(start=start_date, end=end_date, expand=True)
|
|
212
|
+
|
|
213
|
+
for caldav_event in results:
|
|
214
|
+
event_data = self._parse_caldav_event(
|
|
215
|
+
caldav_event, calendar_uid, account_alias
|
|
216
|
+
)
|
|
217
|
+
if event_data:
|
|
218
|
+
events.append(event_data)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.error(f"Error getting events: {e}")
|
|
222
|
+
|
|
223
|
+
return events
|
|
224
|
+
|
|
225
|
+
def _parse_caldav_event(
|
|
226
|
+
self, caldav_event: CalDAVEvent, calendar_uid: str, account_alias: Optional[str]
|
|
227
|
+
) -> Optional[Event]:
|
|
228
|
+
"""Parse CalDAV event to Event model"""
|
|
229
|
+
try:
|
|
230
|
+
# Parse iCalendar data
|
|
231
|
+
ical = iCalendar.from_ical(caldav_event.data)
|
|
232
|
+
|
|
233
|
+
for component in ical.walk():
|
|
234
|
+
if component.name == "VEVENT":
|
|
235
|
+
# Parse date/time values
|
|
236
|
+
dtstart = component.get("dtstart")
|
|
237
|
+
dtend = component.get("dtend")
|
|
238
|
+
|
|
239
|
+
start_dt = ical_to_datetime(dtstart)
|
|
240
|
+
end_dt = ical_to_datetime(dtend)
|
|
241
|
+
|
|
242
|
+
# Detect all-day events
|
|
243
|
+
# Check if the original values were DATE (not DATE-TIME) or if it's midnight to midnight
|
|
244
|
+
is_all_day = False
|
|
245
|
+
if dtstart and dtend:
|
|
246
|
+
# Check if values are DATE type (no time component)
|
|
247
|
+
if hasattr(dtstart, "dt") and not hasattr(dtstart.dt, "hour"):
|
|
248
|
+
is_all_day = True
|
|
249
|
+
# Also check for midnight-to-midnight pattern
|
|
250
|
+
elif (
|
|
251
|
+
start_dt.hour == 0
|
|
252
|
+
and start_dt.minute == 0
|
|
253
|
+
and start_dt.second == 0
|
|
254
|
+
and end_dt.hour == 0
|
|
255
|
+
and end_dt.minute == 0
|
|
256
|
+
and end_dt.second == 0
|
|
257
|
+
and (end_dt - start_dt).days >= 1
|
|
258
|
+
):
|
|
259
|
+
is_all_day = True
|
|
260
|
+
|
|
261
|
+
# Parse basic event data
|
|
262
|
+
event = Event(
|
|
263
|
+
uid=str(component.get("uid", "")),
|
|
264
|
+
summary=str(component.get("summary", "No Title")),
|
|
265
|
+
description=(
|
|
266
|
+
str(component.get("description", ""))
|
|
267
|
+
if component.get("description")
|
|
268
|
+
else None
|
|
269
|
+
),
|
|
270
|
+
start=start_dt,
|
|
271
|
+
end=end_dt,
|
|
272
|
+
all_day=is_all_day,
|
|
273
|
+
location=(
|
|
274
|
+
str(component.get("location", ""))
|
|
275
|
+
if component.get("location")
|
|
276
|
+
else None
|
|
277
|
+
),
|
|
278
|
+
calendar_uid=calendar_uid,
|
|
279
|
+
account_alias=account_alias
|
|
280
|
+
or self._get_default_account()
|
|
281
|
+
or "default",
|
|
282
|
+
recurrence_rule=(
|
|
283
|
+
str(component.get("rrule", ""))
|
|
284
|
+
if component.get("rrule")
|
|
285
|
+
else None
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Parse attendees
|
|
290
|
+
attendees = component.get("attendee", [])
|
|
291
|
+
if attendees:
|
|
292
|
+
if not isinstance(attendees, list):
|
|
293
|
+
attendees = [attendees]
|
|
294
|
+
|
|
295
|
+
for attendee in attendees:
|
|
296
|
+
params = (
|
|
297
|
+
attendee.params if hasattr(attendee, "params") else {}
|
|
298
|
+
)
|
|
299
|
+
email = str(attendee).replace("mailto:", "")
|
|
300
|
+
event.attendees.append(
|
|
301
|
+
Attendee(
|
|
302
|
+
email=email,
|
|
303
|
+
name=params.get("CN", email),
|
|
304
|
+
role=params.get("ROLE", "REQ-PARTICIPANT"),
|
|
305
|
+
status=params.get("PARTSTAT", "NEEDS-ACTION"),
|
|
306
|
+
rsvp=params.get("RSVP", "TRUE").upper() == "TRUE",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return event
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Error parsing event: {e}")
|
|
314
|
+
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def delete_event(
|
|
318
|
+
self,
|
|
319
|
+
calendar_uid: str,
|
|
320
|
+
event_uid: str,
|
|
321
|
+
account_alias: Optional[str] = None,
|
|
322
|
+
request_id: Optional[str] = None,
|
|
323
|
+
) -> bool:
|
|
324
|
+
"""Delete an event by UID - raises exceptions on failure"""
|
|
325
|
+
request_id = request_id or str(uuid.uuid4())
|
|
326
|
+
|
|
327
|
+
calendar = self.calendars.get_calendar(
|
|
328
|
+
calendar_uid, account_alias, request_id=request_id
|
|
329
|
+
)
|
|
330
|
+
if not calendar:
|
|
331
|
+
raise CalendarNotFoundError(
|
|
332
|
+
calendar_uid, account_alias, request_id=request_id
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
# Use utility function to find event with automatic fallback
|
|
337
|
+
event = get_item_with_fallback(
|
|
338
|
+
calendar, event_uid, "event", request_id=request_id
|
|
339
|
+
)
|
|
340
|
+
event.delete()
|
|
341
|
+
logger.info(
|
|
342
|
+
f"Deleted event '{event_uid}'",
|
|
343
|
+
extra={"request_id": request_id},
|
|
344
|
+
)
|
|
345
|
+
return True
|
|
346
|
+
except ValueError:
|
|
347
|
+
# get_item_with_fallback raises ValueError when not found
|
|
348
|
+
raise EventNotFoundError(event_uid, calendar_uid, request_id=request_id)
|
|
349
|
+
|
|
350
|
+
except EventNotFoundError:
|
|
351
|
+
raise # Re-raise our own exception
|
|
352
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
353
|
+
logger.error(
|
|
354
|
+
f"Authorization error deleting event '{event_uid}': {e}",
|
|
355
|
+
extra={"request_id": request_id},
|
|
356
|
+
)
|
|
357
|
+
raise EventDeletionError(
|
|
358
|
+
event_uid, "Authorization failed", request_id=request_id
|
|
359
|
+
)
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.error(
|
|
362
|
+
f"Error deleting event '{event_uid}': {e}",
|
|
363
|
+
extra={"request_id": request_id},
|
|
364
|
+
)
|
|
365
|
+
raise EventDeletionError(event_uid, str(e), request_id=request_id)
|
|
366
|
+
|
|
367
|
+
def update_event(
|
|
368
|
+
self,
|
|
369
|
+
calendar_uid: str,
|
|
370
|
+
event_uid: str,
|
|
371
|
+
summary: Optional[str] = None,
|
|
372
|
+
description: Optional[str] = None,
|
|
373
|
+
start: Optional[datetime] = None,
|
|
374
|
+
end: Optional[datetime] = None,
|
|
375
|
+
location: Optional[str] = None,
|
|
376
|
+
all_day: Optional[bool] = None,
|
|
377
|
+
attendees: Optional[List[Dict[str, Any]]] = None,
|
|
378
|
+
alarm_minutes: Optional[int] = None,
|
|
379
|
+
recurrence_rule: Optional[str] = None,
|
|
380
|
+
account_alias: Optional[str] = None,
|
|
381
|
+
request_id: Optional[str] = None,
|
|
382
|
+
) -> Optional[Event]:
|
|
383
|
+
"""Update an existing event - raises exceptions on failure
|
|
384
|
+
|
|
385
|
+
Only provided fields will be updated. Other fields remain unchanged.
|
|
386
|
+
"""
|
|
387
|
+
request_id = request_id or str(uuid.uuid4())
|
|
388
|
+
|
|
389
|
+
calendar = self.calendars.get_calendar(
|
|
390
|
+
calendar_uid, account_alias, request_id=request_id
|
|
391
|
+
)
|
|
392
|
+
if not calendar:
|
|
393
|
+
raise CalendarNotFoundError(
|
|
394
|
+
calendar_uid, account_alias, request_id=request_id
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
# Get existing event using utility function with automatic fallback
|
|
399
|
+
try:
|
|
400
|
+
caldav_event = get_item_with_fallback(
|
|
401
|
+
calendar, event_uid, "event", request_id=request_id
|
|
402
|
+
)
|
|
403
|
+
except ValueError:
|
|
404
|
+
raise EventNotFoundError(event_uid, calendar_uid, request_id=request_id)
|
|
405
|
+
# Parse existing event data
|
|
406
|
+
ical = iCalendar.from_ical(caldav_event.data)
|
|
407
|
+
existing_event = None
|
|
408
|
+
|
|
409
|
+
for component in ical.walk():
|
|
410
|
+
if component.name == "VEVENT":
|
|
411
|
+
existing_event = component
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if not existing_event:
|
|
415
|
+
raise EventCreationError(
|
|
416
|
+
f"Event {event_uid}",
|
|
417
|
+
"Could not parse existing event data",
|
|
418
|
+
request_id=request_id,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Validate RRULE if provided
|
|
422
|
+
if recurrence_rule is not None:
|
|
423
|
+
is_valid, error_msg = validate_rrule(recurrence_rule)
|
|
424
|
+
if not is_valid:
|
|
425
|
+
raise EventCreationError(
|
|
426
|
+
summary or str(existing_event.get("summary", "")),
|
|
427
|
+
f"Invalid RRULE: {error_msg}",
|
|
428
|
+
request_id=request_id,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Update only provided fields
|
|
432
|
+
if summary is not None:
|
|
433
|
+
existing_event["summary"] = summary
|
|
434
|
+
|
|
435
|
+
if description is not None:
|
|
436
|
+
if description:
|
|
437
|
+
existing_event["description"] = description
|
|
438
|
+
elif "description" in existing_event:
|
|
439
|
+
del existing_event["description"]
|
|
440
|
+
|
|
441
|
+
if start is not None:
|
|
442
|
+
existing_event["dtstart"].dt = start
|
|
443
|
+
|
|
444
|
+
if end is not None:
|
|
445
|
+
existing_event["dtend"].dt = end
|
|
446
|
+
|
|
447
|
+
if location is not None:
|
|
448
|
+
if location:
|
|
449
|
+
existing_event["location"] = location
|
|
450
|
+
elif "location" in existing_event:
|
|
451
|
+
del existing_event["location"]
|
|
452
|
+
if recurrence_rule is not None:
|
|
453
|
+
if recurrence_rule:
|
|
454
|
+
existing_event["rrule"] = recurrence_rule
|
|
455
|
+
elif "rrule" in existing_event:
|
|
456
|
+
del existing_event["rrule"]
|
|
457
|
+
|
|
458
|
+
# Update attendees if provided
|
|
459
|
+
if attendees is not None:
|
|
460
|
+
# Remove existing attendees
|
|
461
|
+
if "attendee" in existing_event:
|
|
462
|
+
del existing_event["attendee"]
|
|
463
|
+
|
|
464
|
+
# Add new attendees
|
|
465
|
+
for att in attendees:
|
|
466
|
+
attendee_str = f"mailto:{att['email']}"
|
|
467
|
+
existing_event.add(
|
|
468
|
+
"attendee",
|
|
469
|
+
attendee_str,
|
|
470
|
+
parameters={
|
|
471
|
+
"CN": att.get("name", att["email"]),
|
|
472
|
+
"ROLE": att.get("role", "REQ-PARTICIPANT"),
|
|
473
|
+
"PARTSTAT": att.get("status", "NEEDS-ACTION"),
|
|
474
|
+
"RSVP": "TRUE" if att.get("rsvp", True) else "FALSE",
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Update alarm if provided
|
|
479
|
+
if alarm_minutes is not None:
|
|
480
|
+
# Remove existing alarms
|
|
481
|
+
for component in list(existing_event.subcomponents):
|
|
482
|
+
if component.name == "VALARM":
|
|
483
|
+
existing_event.subcomponents.remove(component)
|
|
484
|
+
|
|
485
|
+
# Add new alarm if specified
|
|
486
|
+
if alarm_minutes > 0:
|
|
487
|
+
from icalendar import Alarm as iAlarm
|
|
488
|
+
|
|
489
|
+
alarm = iAlarm()
|
|
490
|
+
alarm.add("action", "DISPLAY")
|
|
491
|
+
alarm.add("trigger", timedelta(minutes=-alarm_minutes))
|
|
492
|
+
alarm.add("description", existing_event.get("summary", ""))
|
|
493
|
+
existing_event.add_component(alarm)
|
|
494
|
+
|
|
495
|
+
# Update last-modified timestamp
|
|
496
|
+
existing_event["last-modified"] = datetime.now(timezone.utc)
|
|
497
|
+
|
|
498
|
+
# Save the updated event
|
|
499
|
+
caldav_event.data = ical.to_ical().decode("utf-8")
|
|
500
|
+
caldav_event.save()
|
|
501
|
+
# Parse and return the updated event
|
|
502
|
+
return self._parse_caldav_event(caldav_event, calendar_uid, account_alias)
|
|
503
|
+
|
|
504
|
+
except EventNotFoundError:
|
|
505
|
+
raise
|
|
506
|
+
except EventCreationError:
|
|
507
|
+
raise
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.error(
|
|
510
|
+
f"Error updating event '{event_uid}': {e}",
|
|
511
|
+
extra={"request_id": request_id},
|
|
512
|
+
)
|
|
513
|
+
raise EventCreationError(
|
|
514
|
+
event_uid, f"Failed to update event: {str(e)}", request_id=request_id
|
|
515
|
+
)
|