"""
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