"""Crea una nueva coleccion de calendario CalDAV bajo un calendar-home. Funcion impura: hace una peticion HTTP MKCALENDAR (metodo HTTP literal) para crear una "agenda" nueva bajo el calendar-home de un principal, y opcionalmente un PROPPATCH posterior para fijarle el color (Apple `calendar-color`) y la descripcion (`{urn:ietf:params:xml:ns:caldav}calendar-description`). El nombre visible (DAV:displayname) se setea ya en el cuerpo del MKCALENDAR. El slug (segmento de path de la coleccion) se sanea a `[a-z0-9_-]` (minusculas, espacios -> '-'); si queda vacio se devuelve un error de validacion. La coleccion se crea en `/`. Idempotente: un 201 (Created) es exito; un 405 (Method Not Allowed) o un 301 (la coleccion ya existe en ese path) se devuelven como {status:'ok', existed:True}. El display_name y la description se escapan para XML. Construye `Authorization: Basic base64(user:pass)` a mano con stdlib. Maneja errores sin lanzar (salvo validacion de args). Solo usa stdlib (urllib, base64, re, ssl, xml.sax.saxutils). Probado contra Xandikos. """ import base64 import re import ssl import urllib.error import urllib.request from xml.sax.saxutils import escape as _xml_escape _UNSAFE_SLUG_RE = re.compile(r"[^a-z0-9_-]") def _basic_auth_header(username: str, password: str) -> str: raw = ("%s:%s" % (username, password)).encode("utf-8") return "Basic " + base64.b64encode(raw).decode("ascii") def _sanitize_slug(slug: str) -> str: """Sanea un slug a `[a-z0-9_-]`. Pasa a minusculas, convierte espacios (y runs de espacios) en un guion, y elimina cualquier otro caracter no permitido. Colapsa guiones repetidos y recorta guiones de los extremos. Puede devolver "" si no queda nada usable; el caller trata "" como error de validacion. """ s = slug.strip().lower() s = re.sub(r"\s+", "-", s) s = _UNSAFE_SLUG_RE.sub("", s) s = re.sub(r"-{2,}", "-", s).strip("-") return s def _build_mkcalendar_xml(display_name: str) -> str: """Cuerpo XML minimo del MKCALENDAR que setea el displayname (escapado).""" name = _xml_escape(display_name) return ( '' '' "" "%s" "" "" ) % name def _build_proppatch_xml(color: str = "", description: str = "") -> str: """Cuerpo XML del PROPPATCH que fija color (Apple) y/o descripcion (CalDAV). Solo incluye las props no vacias. El color va como `calendar-color` del namespace `http://apple.com/ns/ical/` con el hex tal cual lo pasa el caller (p.ej. '#RRGGBB'). La descripcion es `calendar-description` de CalDAV. Ambos valores se escapan para XML. """ props = [] if color: props.append("%s" % _xml_escape(color)) if description: props.append( "%s" % _xml_escape(description) ) return ( '' '' "%s" "" ) % "".join(props) def _join_url(base_url: str, calendar_home: str, slug: str) -> str: return base_url.rstrip("/") + "/" + calendar_home.strip("/") + "/" + slug + "/" def dav_make_calendar( base_url: str, username: str, password: str, calendar_home: str, slug: str, display_name: str = "", color: str = "", description: str = "", *, timeout_s: float = 20.0, verify_tls: bool = True, ) -> dict: """Crea una nueva coleccion de calendario CalDAV (MKCALENDAR + PROPPATCH). Crea la coleccion en `/` via MKCALENDAR, fijando el displayname en el propio cuerpo. Si se pasa `color` y/o `description`, hace un PROPPATCH posterior para setearlos. Idempotente: si la coleccion ya existe (405/301) devuelve {status:'ok', existed:True}. Args: base_url: URL base del servidor DAV (sin barra final), p.ej. 'https://dav-x.organic-machine.com'. username: usuario para HTTP Basic auth. password: contrasena para HTTP Basic auth. Resolver desde pass. calendar_home: ruta del calendar-home del principal (con barra final), p.ej. '/enmanuel/calendars/'. La coleccion cuelga de el. slug: segmento de path de la coleccion (p.ej. 'trabajo'); se sanea a [a-z0-9_-]. Si queda vacio tras sanear, error de validacion. display_name: nombre visible (DAV:displayname). Si vacio, usa el slug. color: color de la coleccion como hex '#rrggbb' (Apple calendar-color). Opcional. description: descripcion (CalDAV calendar-description). Opcional. timeout_s: timeout de cada peticion en segundos. Default 20.0. verify_tls: si True (default) verifica el certificado TLS. Returns: dict. En exito: {status:'ok', http_status:int, href:str} (y existed:True si ya existia). En error (sin lanzar): {status:'error', http_status: int|None, href:str, error:str}. """ clean = _sanitize_slug(slug) href = (calendar_home.rstrip("/") + "/" + clean + "/") if clean else "" if not clean: return { "status": "error", "http_status": None, "href": href, "error": "slug invalido: queda vacio tras sanear a [a-z0-9_-]", } name = display_name if display_name else clean url = _join_url(base_url, calendar_home, clean) context = None if verify_tls else ssl._create_unverified_context() headers = { "Authorization": _basic_auth_header(username, password), "Content-Type": "application/xml; charset=utf-8", } # 1) MKCALENDAR — crea la coleccion + displayname. mk_body = _build_mkcalendar_xml(name).encode("utf-8") req = urllib.request.Request( url, data=mk_body, method="MKCALENDAR", headers=headers ) existed = False http_status = None try: with urllib.request.urlopen(req, timeout=timeout_s, context=context) as resp: http_status = resp.status except urllib.error.HTTPError as e: # 405/301: la coleccion ya existe en ese path -> idempotente. if e.code in (301, 405): existed = True http_status = e.code else: return { "status": "error", "http_status": e.code, "href": href, "error": "http %s" % e.code, } except urllib.error.URLError as e: return { "status": "error", "http_status": None, "href": href, "error": str(e.reason), } except Exception as e: # noqa: BLE001 return {"status": "error", "http_status": None, "href": href, "error": str(e)} # 2) PROPPATCH — color y/o descripcion. Fallo aqui no es fatal: la coleccion # ya existe. Servidores que no soporten calendar-color pueden ignorarlo. if color or description: pp_body = _build_proppatch_xml(color, description).encode("utf-8") pp_req = urllib.request.Request( url, data=pp_body, method="PROPPATCH", headers=headers ) try: urllib.request.urlopen(pp_req, timeout=timeout_s, context=context).close() except Exception: # noqa: BLE001 # No fatal: el calendario quedo creado; el color/desc puede no # soportarse en este servidor. Se ignora silenciosamente. pass result = {"status": "ok", "http_status": http_status, "href": href} if existed: result["existed"] = True return result