feat(calendar): vista mes/semana/día, TZ, selector de calendario, colores y CRUD de eventos
Backend (server/main.py): - GET /api/calendars: lista las colecciones de calendario bajo el calendar-home con nombre y color (compone dav_list_calendars del registry). - GET /api/calendar?cal=&from=&to=: eventos de una colección concreta (caché por colección validada por ctag). dtstart/dtend ahora en ISO con offset + tz original + all_day; parseo robusto de TZID/UTC/todo-el-día con zoneinfo. - POST/PUT/DELETE /api/event[/<uid>]: CRUD de VEVENT contra Xandikos (fuente de verdad). Construye el VCALENDAR (con VTIMEZONE para zonas con DST), reutiliza el UID al editar (idempotente), trata 404 del DELETE como idempotente, invalida la caché de la colección tras escribir. Frontend: - CalendarView reescrita: conmutador Mes/Semana/Día con rejilla horaria propia (Mantine + dayjs, sin react-big-calendar para evitar fricción con React 19), mini-calendario de navegación, selector de calendario (con color), selector de zona horaria que recoloca los eventos, colores por evento (del VEVENT o del calendario). - EventModal: alta/edición/borrado con summary, inicio/fin, todo-el-día, TZ, calendario, color, ubicación y descripción. Fechas en formato local 24h. - calendar.ts: helpers de TZ (dayjs utc+timezone), posicionado por hora, semana empezando en lunes, locale es. api.ts: tipos y funciones de eventos/calendarios. Verificado: ciclo real crear→editar→borrar contra Xandikos (cero residuo), render del calendario en navegador (React 19 + Mantine v9 montan), pnpm build verde, 40 tests verdes (+ smoke gateado). MKCALENDAR queda fuera (documentado). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+272
-1
@@ -230,7 +230,12 @@ def test_vevent_to_json_and_range():
|
||||
assert len(events) == 1
|
||||
evt = events[0]
|
||||
assert evt["summary"] == "Reunión OSINT"
|
||||
assert evt["dtstart"].startswith("20260615")
|
||||
# Contrato nuevo: dtstart en ISO con offset (UTC -> +00:00) + tz original.
|
||||
assert evt["dtstart"] == "2026-06-15T09:00:00+00:00"
|
||||
assert evt["tz"] == "UTC"
|
||||
assert evt["all_day"] is False
|
||||
# dtstart_ical conserva el prefijo crudo para el filtro de rango.
|
||||
assert evt["dtstart_ical"] == "20260615"
|
||||
assert srv._event_in_range(evt, "20260601", "20260630") is True
|
||||
assert srv._event_in_range(evt, "20260101", "20260131") is False
|
||||
|
||||
@@ -604,6 +609,272 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
|
||||
crud_client.delete("/api/contact/%s" % slug)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vevent_tzid_se_normaliza_a_iso_con_offset():
|
||||
"""Un DTSTART;TZID=Europe/Madrid sale como ISO con el offset de Madrid."""
|
||||
vcal = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:tz-1\r\n"
|
||||
"SUMMARY:Con zona\r\n"
|
||||
"DTSTART;TZID=Europe/Madrid:20260615T100000\r\n"
|
||||
"DTEND;TZID=Europe/Madrid:20260615T110000\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
)
|
||||
evt = srv._vcalendar_to_events(vcal)[0]
|
||||
# Junio: Madrid está en CEST (+02:00).
|
||||
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
|
||||
assert evt["dtend"] == "2026-06-15T11:00:00+02:00"
|
||||
assert evt["tz"] == "Europe/Madrid"
|
||||
assert evt["all_day"] is False
|
||||
|
||||
|
||||
def test_vevent_all_day_value_date():
|
||||
"""Un DTSTART;VALUE=DATE:YYYYMMDD se marca all_day y sale como fecha ISO."""
|
||||
vcal = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:ad-1\r\n"
|
||||
"SUMMARY:Todo el día\r\nDTSTART;VALUE=DATE:20260615\r\n"
|
||||
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
)
|
||||
evt = srv._vcalendar_to_events(vcal)[0]
|
||||
assert evt["all_day"] is True
|
||||
assert evt["dtstart"] == "2026-06-15"
|
||||
assert evt["dtstart_ical"] == "20260615"
|
||||
|
||||
|
||||
def test_build_vcalendar_tzid():
|
||||
"""El builder emite DTSTART;TZID + un VTIMEZONE para una zona con DST."""
|
||||
data = srv.EventIn(
|
||||
cal="",
|
||||
summary="Reunión, con coma",
|
||||
dtstart="2026-06-15T10:00",
|
||||
dtend="2026-06-15T11:00",
|
||||
tz="Europe/Madrid",
|
||||
all_day=False,
|
||||
location="Oficina; sala 2",
|
||||
)
|
||||
vcal = srv._build_vcalendar(data, "uid-abc")
|
||||
assert "BEGIN:VEVENT" in vcal
|
||||
assert "UID:uid-abc" in vcal
|
||||
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in vcal
|
||||
assert "DTEND;TZID=Europe/Madrid:20260615T110000" in vcal
|
||||
assert "BEGIN:VTIMEZONE" in vcal and "TZID:Europe/Madrid" in vcal
|
||||
# El summary/location se escapan (coma y punto y coma).
|
||||
assert "SUMMARY:Reunión\\, con coma" in vcal
|
||||
assert "LOCATION:Oficina\\; sala 2" in vcal
|
||||
# Round-trip: parsear lo construido reproduce la hora local de Madrid.
|
||||
evt = srv._vcalendar_to_events(vcal)[0]
|
||||
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
|
||||
assert evt["tz"] == "Europe/Madrid"
|
||||
|
||||
|
||||
def test_build_vcalendar_all_day():
|
||||
data = srv.EventIn(summary="Festivo", dtstart="2026-06-15", all_day=True)
|
||||
vcal = srv._build_vcalendar(data, "uid-ad")
|
||||
assert "DTSTART;VALUE=DATE:20260615" in vcal
|
||||
assert "BEGIN:VTIMEZONE" not in vcal # all-day no necesita VTIMEZONE
|
||||
|
||||
|
||||
def test_build_vcalendar_con_offset_va_a_utc():
|
||||
"""Una entrada con offset explícito se convierte a UTC (...Z)."""
|
||||
data = srv.EventIn(
|
||||
summary="Con offset", dtstart="2026-06-15T10:00:00+02:00", tz="Europe/Madrid"
|
||||
)
|
||||
vcal = srv._build_vcalendar(data, "uid-off")
|
||||
# 10:00+02:00 == 08:00 UTC.
|
||||
assert "DTSTART:20260615T080000Z" in vcal
|
||||
|
||||
|
||||
def test_build_vcalendar_fecha_invalida_lanza():
|
||||
data = srv.EventIn(summary="Mala", dtstart="no-es-fecha")
|
||||
with pytest.raises(ValueError):
|
||||
srv._build_vcalendar(data, "uid-x")
|
||||
|
||||
|
||||
def test_safe_event_resource_coincide_con_caldav_put():
|
||||
"""El nombre .ics del DELETE coincide con el que deriva caldav_put_event."""
|
||||
uid = "abc/def:ghi 123"
|
||||
assert srv._safe_event_resource(uid) == "abc_def_ghi_123.ics"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendario: listado de colecciones + CRUD de eventos (DAV mockeado)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cal_client(vault, monkeypatch, tmp_path):
|
||||
"""Cliente con el PUT/DELETE/list/get de CalDAV mockeado (CRUD sin red).
|
||||
|
||||
Registra los PUT/DELETE intentados y simula un servidor con una colección de
|
||||
calendario y un store de eventos en memoria (para que el GET tras crear vea
|
||||
el evento). ``calls`` expone lo que el server intentó.
|
||||
"""
|
||||
calls = {"put": [], "delete": []}
|
||||
# Store en memoria: resource_name -> VCALENDAR text.
|
||||
store = {}
|
||||
ctag = {"v": "cal-v1"}
|
||||
|
||||
def _put_event(base, user, pw, coll, uid, vcal, **kw):
|
||||
calls["put"].append({"uid": uid, "vcal": vcal, "coll": coll})
|
||||
store[srv._safe_event_resource(uid)] = vcal
|
||||
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
|
||||
return {"status": "ok", "http_status": 201, "url": coll + uid + ".ics"}
|
||||
|
||||
def _delete(base, user, pw, resource_path, **kw):
|
||||
calls["delete"].append(resource_path)
|
||||
name = resource_path.rstrip("/").rsplit("/", 1)[-1]
|
||||
existed = store.pop(name, None)
|
||||
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
|
||||
if existed is None:
|
||||
return {"status": "error", "http_status": 404, "error": "http 404"}
|
||||
return {"status": "ok", "http_status": 204, "url": resource_path}
|
||||
|
||||
def _get_collection(base, user, pw, collection, content_type="ical", **kw):
|
||||
res = [
|
||||
{"href": collection + name, "etag": '"%s"' % name, "data": data}
|
||||
for name, data in store.items()
|
||||
]
|
||||
return {"status": "ok", "http_status": 207, "resources": res}
|
||||
|
||||
def _ctag(base, user, pw, collection, **kw):
|
||||
return {"status": "ok", "http_status": 207, "ctag": ctag["v"]}
|
||||
|
||||
def _list_calendars(base, user, pw, home, **kw):
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": 207,
|
||||
"calendars": [
|
||||
{
|
||||
"href": "/enmanuel/calendars/calendar/",
|
||||
"name": "calendar",
|
||||
"color": None,
|
||||
},
|
||||
{
|
||||
"href": "/enmanuel/calendars/trabajo/",
|
||||
"name": "Trabajo",
|
||||
"color": "#FF2968FF",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(srv, "caldav_put_event", _put_event)
|
||||
monkeypatch.setattr(srv, "dav_delete_resource", _delete)
|
||||
monkeypatch.setattr(srv, "dav_get_collection", _get_collection)
|
||||
monkeypatch.setattr(srv, "dav_collection_ctag", _ctag)
|
||||
monkeypatch.setattr(srv, "dav_list_calendars", _list_calendars)
|
||||
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
|
||||
cache_dir = tmp_path / "cal_cache"
|
||||
monkeypatch.setattr(srv, "_CALENDAR_CACHE_FILE", str(cache_dir / "calendar.json"))
|
||||
monkeypatch.setattr(srv, "_CACHE_DIR", str(cache_dir))
|
||||
app = srv.create_app(vault)
|
||||
client = TestClient(app)
|
||||
client._cal_calls = calls # type: ignore[attr-defined]
|
||||
return client
|
||||
|
||||
|
||||
def test_calendars_endpoint_lista_con_color(cal_client):
|
||||
r = cal_client.get("/api/calendars")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok" and data["count"] == 2
|
||||
by_name = {c["name"]: c for c in data["calendars"]}
|
||||
assert by_name["Trabajo"]["color"] == "#FF2968FF"
|
||||
assert by_name["calendar"]["color"] is None
|
||||
|
||||
|
||||
def test_event_crud_full_cycle(cal_client):
|
||||
"""Golden: crear → leer → editar → borrar un evento (VEVENT sobre CalDAV)."""
|
||||
calls = cal_client._cal_calls
|
||||
|
||||
# -- CREATE --
|
||||
body = {
|
||||
"summary": "ZZ Test Event",
|
||||
"dtstart": "2026-06-15T10:00",
|
||||
"dtend": "2026-06-15T11:00",
|
||||
"tz": "Europe/Madrid",
|
||||
"location": "Sala",
|
||||
"description": "Prueba CRUD",
|
||||
}
|
||||
r = cal_client.post("/api/event", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
uid = r.json()["uid"]
|
||||
assert uid
|
||||
assert calls["put"], "debió hacer PUT del VEVENT"
|
||||
assert "SUMMARY:ZZ Test Event" in calls["put"][-1]["vcal"]
|
||||
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in calls["put"][-1]["vcal"]
|
||||
|
||||
# -- READ (aparece en /api/calendar) --
|
||||
cal_client.post("/api/refresh") # fuerza recarga desde el store mockeado
|
||||
g = cal_client.get("/api/calendar", params={"from": "2026-06-01", "to": "2026-06-30"})
|
||||
assert g.status_code == 200
|
||||
uids = [e["uid"] for e in g.json()["events"]]
|
||||
assert uid in uids
|
||||
evt = next(e for e in g.json()["events"] if e["uid"] == uid)
|
||||
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
|
||||
assert evt["tz"] == "Europe/Madrid"
|
||||
|
||||
# -- UPDATE --
|
||||
body2 = dict(body)
|
||||
body2["summary"] = "ZZ Test Event (editado)"
|
||||
body2["dtstart"] = "2026-06-15T12:00"
|
||||
ur = cal_client.put("/api/event/%s" % uid, json=body2)
|
||||
assert ur.status_code == 200, ur.text
|
||||
assert "SUMMARY:ZZ Test Event (editado)" in calls["put"][-1]["vcal"]
|
||||
assert "20260615T120000" in calls["put"][-1]["vcal"]
|
||||
# El UID se reutiliza (idempotente): mismo recurso.
|
||||
assert calls["put"][-1]["uid"] == uid
|
||||
|
||||
# -- DELETE --
|
||||
dr = cal_client.delete("/api/event/%s" % uid)
|
||||
assert dr.status_code == 200, dr.text
|
||||
assert dr.json()["deleted"] is True
|
||||
assert any(uid in p for p in calls["delete"])
|
||||
# Tras borrar, ya no aparece.
|
||||
cal_client.post("/api/refresh")
|
||||
g2 = cal_client.get("/api/calendar")
|
||||
assert uid not in [e["uid"] for e in g2.json()["events"]]
|
||||
|
||||
|
||||
def test_event_create_fecha_invalida_400(cal_client):
|
||||
r = cal_client.post(
|
||||
"/api/event", json={"summary": "X", "dtstart": "no-fecha"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_event_create_summary_vacio_400(cal_client):
|
||||
r = cal_client.post(
|
||||
"/api/event", json={"summary": " ", "dtstart": "2026-06-15T10:00"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_event_delete_idempotente_404_es_ok(cal_client):
|
||||
"""Borrar un evento inexistente NO es error: Xandikos 404 → idempotente."""
|
||||
r = cal_client.delete("/api/event/no-existe-uid")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["deleted"] is True
|
||||
|
||||
|
||||
def test_calendar_endpoint_degrada_sin_red(client, monkeypatch):
|
||||
"""Sin Xandikos, /api/calendars y /api/calendar devuelven 503 claro."""
|
||||
monkeypatch.setattr(
|
||||
srv, "dav_list_calendars", lambda *a, **k: {"status": "error", "error": "sin red"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
srv, "dav_get_collection", lambda *a, **k: {"status": "error", "error": "sin red"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
srv, "dav_collection_ctag", lambda *a, **k: {"status": "error", "error": "sin red"}
|
||||
)
|
||||
client.app.state.vault._xandikos_password = "x"
|
||||
assert client.get("/api/calendars").status_code == 503
|
||||
assert client.get("/api/calendar").status_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user