From 20e98e660a7d69f3fb38d1877730823e4512b842 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Apr 2026 18:31:58 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20HA-Events=20bearbeitbar,=20Selected?= =?UTF-8?q?=E2=89=A0Today=20Styling,=20Serien-L=C3=B6schung=20-=20HA-Event?= =?UTF-8?q?s:=20Update/Delete-Endpoints=20via=20HA=20REST=20API=20implemen?= =?UTF-8?q?tiert=20-=20HA=20read-only=20Guard=20entfernt,=20stattdessen=20?= =?UTF-8?q?korrekte=20API-Anbindung=20-=20Selected-Day:=20Outline-Ring=20s?= =?UTF-8?q?tatt=20gef=C3=BCllter=20Kreis=20(Today=20bleibt=20gef=C3=BCllt)?= =?UTF-8?q?=20-=20Serien-L=C3=B6schung:=20RECURRENCE-ID=20aus=20CalDAV-Eve?= =?UTF-8?q?nts=20erkennen,=20damit=20=20=20expandierte=20Serientermine=20a?= =?UTF-8?q?ls=20recurring=20markiert=20werden=20und=20der=20=20=20L=C3=B6s?= =?UTF-8?q?ch-Dialog=20Einzel-/Serienl=C3=B6schung=20anbietet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 3 + backend/routers/homeassistant_router.py | 108 ++++++++++++++++++++++++ frontend/css/app.css | 3 +- frontend/js/calendar.js | 20 +++-- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/backend/caldav_client.py b/backend/caldav_client.py index dc43b9e..1268413 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -107,6 +107,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: color = str(component.get("X-CALENDARR-COLOR", "") or "") rrule_prop = component.get("RRULE") rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None + recurrence_id = component.get("RECURRENCE-ID") + is_recurring = rrule_str is not None or recurrence_id is not None dtstart_prop = component.get("DTSTART") dtend_prop = component.get("DTEND") @@ -157,6 +159,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: "description": description, "color": color or None, "rrule": rrule_str, + "recurring": is_recurring, } ) except Exception as exc: diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py index e6ef38a..4a0bb88 100644 --- a/backend/routers/homeassistant_router.py +++ b/backend/routers/homeassistant_router.py @@ -109,6 +109,45 @@ def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}") +def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict): + """Update an event via HA REST API.""" + body = {} + if "title" in data: + body["summary"] = data["title"] + if "description" in data: + body["description"] = data["description"] + if "location" in data: + body["location"] = data["location"] + if "start" in data and "end" in data: + if data.get("allDay"): + body["start"] = {"date": data["start"][:10]} + body["end"] = {"date": data["end"][:10]} + else: + body["start"] = {"dateTime": data["start"]} + body["end"] = {"dateTime": data["end"]} + resp = http_requests.put( + f"{url.rstrip('/')}/api/calendars/{entity_id}/{uid}", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json=body, + timeout=15, + verify=False, + ) + resp.raise_for_status() + return resp + + +def _ha_delete_event(url: str, token: str, entity_id: str, uid: str): + """Delete an event via HA REST API.""" + resp = http_requests.delete( + f"{url.rstrip('/')}/api/calendars/{entity_id}/{uid}", + headers={"Authorization": f"Bearer {token}"}, + timeout=15, + verify=False, + ) + resp.raise_for_status() + return resp + + def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict: start = ev.get("start", {}) end = ev.get("end", {}) @@ -442,3 +481,72 @@ def update_calendar( cal.sidebar_hidden = data.sidebar_hidden db.commit() return {"ok": True} + + +# ── Event CRUD ─────────────────────────────────────────── + +class HAEventUpdate(BaseModel): + title: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + allDay: Optional[bool] = None + location: Optional[str] = None + description: Optional[str] = None + + +@router.put("/events/{calendar_id}/{uid}") +def update_event( + calendar_id: int, + uid: str, + data: HAEventUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.HomeAssistantCalendar) + .join(models.HomeAssistantAccount) + .filter( + models.HomeAssistantCalendar.id == calendar_id, + models.HomeAssistantAccount.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + account = cal.account + token = _get_valid_token(account, db) + try: + _ha_update_event( + account.url, token, cal.entity_id, uid, + data.model_dump(exclude_none=True), + ) + return {"ok": True} + except Exception as exc: + raise HTTPException(500, f"HA event update failed: {exc}") + + +@router.delete("/events/{calendar_id}/{uid}") +def delete_event( + calendar_id: int, + uid: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.HomeAssistantCalendar) + .join(models.HomeAssistantAccount) + .filter( + models.HomeAssistantCalendar.id == calendar_id, + models.HomeAssistantAccount.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + account = cal.account + token = _get_valid_token(account, db) + try: + _ha_delete_event(account.url, token, cal.entity_id, uid) + return {"ok": True} + except Exception as exc: + raise HTTPException(500, f"HA event delete failed: {exc}") diff --git a/frontend/css/app.css b/frontend/css/app.css index 0bd114d..c109e3e 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -487,7 +487,8 @@ a { color: var(--primary); text-decoration: none; } .month-col:hover { background: var(--bg-hover); } .month-col.today { background: rgba(66,133,244,.08); } .month-col.month-selected { background: var(--primary-dim); } -.month-col.month-selected .cell-day { background: var(--primary); color: #fff; font-weight: 700; } +.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; } +.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; } .month-col.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index b2f8caf..f7bef0b 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -858,7 +858,7 @@ function bindSidebar() { // ── Day Context Menu (month view) ──────────────────────── // ── Delete logic ────────────────────────────────────────── async function deleteEventByScope(ev, scope) { - if (scope === 'all' || !ev.rrule) { + if (scope === 'all' || !(ev.rrule || ev.recurring)) { // Delete the entire event (or non-recurring) if (ev.source === 'google') { const accId = ev.calendar_id.replace('google-', ''); @@ -868,6 +868,9 @@ async function deleteEventByScope(ev, scope) { } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); + } else if (ev.source === 'homeassistant') { + const haCalId = ev.calendar_id.replace('homeassistant-', ''); + await api.delete(`/homeassistant/events/${haCalId}/${encodeURIComponent(ev.id)}`); } else { await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } @@ -894,7 +897,7 @@ async function deleteEventByScope(ev, scope) { function showDeleteConfirm(ev) { return new Promise(resolve => { const modal = document.getElementById('modal-delete-confirm'); - const isRecurring = !!(ev.rrule); + const isRecurring = !!(ev.rrule || ev.recurring); document.getElementById('delete-confirm-title').textContent = t('confirm_delete_title'); document.getElementById('delete-confirm-text').textContent = t('confirm_delete_event', { title: ev.title }); @@ -994,8 +997,8 @@ function showEventPopup(ev, anchor) { popup.style.left = Math.max(8, left) + 'px'; popup.style.top = Math.max(8, top) + 'px'; - // Hide edit/delete for read-only sources (iCal subscriptions, Home Assistant) - const isReadOnly = (ev.source === 'ical' || ev.source === 'homeassistant'); + // Hide edit/delete for read-only iCal subscription events + const isReadOnly = (ev.source === 'ical'); document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; @@ -1134,7 +1137,7 @@ function openNewEventModal(date) { } function openEditEventModal(ev) { - if (ev.source === 'ical' || ev.source === 'homeassistant') { showToast(t('event_readonly'), true); return; } + if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } state.editingEvent = ev; state.selectedEventColor = ev.color || ''; @@ -1426,9 +1429,14 @@ function bindEventModal() { await api.put(`/local/events/${encodeURIComponent(ev.id)}`, { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); - } else if (ev.source === 'ical' || ev.source === 'homeassistant') { + } else if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; + } else if (ev.source === 'homeassistant') { + const haCalId = ev.calendar_id.replace('homeassistant-', ''); + await api.put(`/homeassistant/events/${haCalId}/${encodeURIComponent(ev.id)}`, + { title, start, end, allDay, location: loc, description: desc } + ); } else { await api.put( `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,