1c8a86594f
Dos funciones nuevas del grupo de capacidad `dav`: - expand_rrule_py_infra (pure): expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango [from, to]. Solo stdlib (datetime, re). Soporta FREQ DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT, UNTIL, BYDAY. 9 tests. - dav_make_calendar_py_infra (impure): crea una coleccion de calendario nueva via MKCALENDAR + PROPPATCH de nombre/color. Idempotente si ya existe. 11 tests. Consumidas por la app osint_web (eventos recurrentes + creacion de agendas). Pagina del grupo dav actualizada con ambas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
7.8 KiB
Python
203 lines
7.8 KiB
Python
"""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 `<calendar_home><slug>/`.
|
|
|
|
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 (
|
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
|
'<C:mkcalendar xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
|
|
"<D:set><D:prop>"
|
|
"<D:displayname>%s</D:displayname>"
|
|
"</D:prop></D:set>"
|
|
"</C:mkcalendar>"
|
|
) % 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("<A:calendar-color>%s</A:calendar-color>" % _xml_escape(color))
|
|
if description:
|
|
props.append(
|
|
"<C:calendar-description>%s</C:calendar-description>"
|
|
% _xml_escape(description)
|
|
)
|
|
return (
|
|
'<?xml version="1.0" encoding="utf-8" ?>'
|
|
'<D:propertyupdate xmlns:D="DAV:" '
|
|
'xmlns:A="http://apple.com/ns/ical/" '
|
|
'xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
|
"<D:set><D:prop>%s</D:prop></D:set>"
|
|
"</D:propertyupdate>"
|
|
) % "".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 `<calendar_home><slug>/` 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
|