feat(dav): expand_rrule + dav_make_calendar para recurrencia y multi-calendario

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>
This commit is contained in:
2026-06-12 23:30:01 +02:00
parent a76760edba
commit 1c8a86594f
7 changed files with 919 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
"""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