""" CalDAV Client - Python client for Radicale CalDAV server Supports calendar operations: list, create, update, delete events """ import logging from typing import Optional, List, Dict, Any from datetime import datetime import uuid logger = logging.getLogger(__name__) class CalDAVClient: """ CalDAV client for Radicale server. Provides methods to: - List calendars - List/create/update/delete events - Handle VEVENT format """ def __init__( self, server_url: str, username: str, password: str, timeout: int = 30 ): self.server_url = server_url.rstrip("/") self.username = username self.password = password self.timeout = timeout # Will be set after principal discovery self.principal_url: Optional[str] = None self._session = None # Import requests for HTTP try: import requests from requests.auth import HTTPBasicAuth self._requests = requests self._auth = HTTPBasicAuth(username, password) except ImportError: logger.warning("requests not available") self._requests = None def _request( self, method: str, path: str, headers: Optional[Dict] = None, data: Optional[str] = None ) -> Any: """Make HTTP request to CalDAV server""" if not self._requests: raise RuntimeError("requests library not available") url = f"{self.server_url}{path}" default_headers = { "Content-Type": "application/xml; charset=utf-8", "Accept": "application/xml" } if headers: default_headers.update(headers) response = self._requests.request( method=method, url=url, auth=self._auth, headers=default_headers, data=data, timeout=self.timeout, verify=False # For self-signed certs ) return response def discover_principal(self) -> str: """Discover principal URL""" # PROPFIND to find principal propfind_xml = """ """ response = self._request( "PROPFIND", "/", {"Depth": "0"}, propfind_xml ) # Parse principal URL from response # Simplified: assume /{username}/ self.principal_url = f"/{self.username}/" return self.principal_url def list_calendars(self) -> List[Dict[str, str]]: """List all calendars for principal""" if not self.principal_url: self.discover_principal() # CALDAV calendar-home-set query propfind_xml = """ """ try: response = self._request( "PROPFIND", self.principal_url, {"Depth": "1"}, propfind_xml ) except Exception as e: logger.warning(f"Failed to list calendars: {e}") # Return default calendar return [{"id": "default", "display_name": "Default Calendar"}] # Parse response (simplified) calendars = [] calendars.append({ "id": "default", "display_name": "Default Calendar", "url": f"{self.principal_url}default/" }) return calendars def list_events( self, calendar_id: str = "default", time_min: Optional[str] = None, time_max: Optional[str] = None ) -> List[Dict[str, Any]]: """List events in calendar""" if not self.principal_url: self.discover_principal() calendar_url = f"{self.principal_url}{calendar_id}/" # Build calendar-query calendar_query_xml = f""" """ try: response = self._request( "REPORT", calendar_url, {"Depth": "1"}, calendar_query_xml ) except Exception as e: logger.warning(f"Failed to list events: {e}") return [] # Parse events (simplified - return empty for now) events = [] return events def get_event( self, uid: str, calendar_id: str = "default" ) -> Optional[Dict[str, Any]]: """Get single event""" if not self.principal_url: self.discover_principal() calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics" try: response = self._request("GET", calendar_url) if response.status_code == 404: return None # Parse VEVENT return self._parse_vevent(response.text) except Exception as e: logger.error(f"Failed to get event: {e}") return None def create_event( self, calendar_id: str = "default", title: str = "", start: str = "", end: str = "", timezone: str = "Europe/Kiev", location: Optional[str] = None, description: Optional[str] = None, attendees: Optional[List[str]] = None ) -> str: """Create new event, returns UID""" if not self.principal_url: self.discover_principal() # Generate UID uid = str(uuid.uuid4()) # Build VEVENT vevent = self._build_vevent( uid=uid, title=title, start=start, end=end, timezone=timezone, location=location, description=description, attendees=attendees ) # PUT to calendar calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics" self._request( "PUT", calendar_url, {"Content-Type": "text/calendar; charset=utf-8"}, vevent ) return uid def update_event( self, uid: str, calendar_id: str = "default", title: Optional[str] = None, start: Optional[str] = None, end: Optional[str] = None, timezone: str = "Europe/Kiev", location: Optional[str] = None, description: Optional[str] = None ) -> bool: """Update existing event""" if not self.principal_url: self.discover_principal() # Get existing event existing = self.get_event(uid, calendar_id) if not existing: raise ValueError(f"Event {uid} not found") # Update fields title = title or existing.get("title", "") start = start or existing.get("start", "") end = end or existing.get("end", "") location = location if location is not None else existing.get("location") description = description if description is not None else existing.get("description") # Rebuild VEVENT vevent = self._build_vevent( uid=uid, title=title, start=start, end=end, timezone=timezone, location=location, description=description ) calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics" self._request( "PUT", calendar_url, {"Content-Type": "text/calendar; charset=utf-8"}, vevent ) return True def delete_event(self, uid: str, calendar_id: str = "default") -> bool: """Delete event""" if not self.principal_url: self.discover_principal() calendar_url = f"{self.principal_url}{calendar_id}/{uid}.ics" self._request("DELETE", calendar_url) return True def _build_vevent( self, uid: str, title: str, start: str, end: str, timezone: str, location: Optional[str], description: Optional[str], attendees: Optional[List[str]] = None ) -> str: """Build VEVENT iCalendar string""" # Format datetime for iCalendar start_dt = start.replace("-", "").replace(":", "") end_dt = end.replace("-", "").replace(":", "") # Build attendees attendee_lines = "" if attendees: for email in attendees: attendee_lines += f"ATTENDEE:mailto:{email}\n" vevent = f"""BEGIN:VCALENDAR VERSION:2.0 PRODID:-//DAARION//Calendar//EN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VEVENT UID:{uid} DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')} DTSTART:{start_dt} DTEND:{end_dt} SUMMARY:{title} {location and f'LOCATION:{location}' or ''} {description and f'DESCRIPTION:{description}' or ''} {attendee_lines} END:VEVENT END:VCALENDAR""" return vevent def _parse_vevent(self, ics_data: str) -> Dict[str, Any]: """Parse VEVENT from iCalendar data""" event = {} lines = ics_data.split("\n") for line in lines: line = line.strip() if line.startswith("UID:"): event["uid"] = line[4:] elif line.startswith("SUMMARY:"): event["title"] = line[8:] elif line.startswith("DTSTART"): event["start"] = line.replace("DTSTART:", "").replace("T", " ") elif line.startswith("DTEND"): event["end"] = line.replace("DTEND:", "").replace("T", " ") elif line.startswith("LOCATION:"): event["location"] = line[9:] elif line.startswith("DESCRIPTION:"): event["description"] = line[13:] return event