feat: Datum-Validierung, Monatsauswahl, CalDAV-Fix, wiederkehrende Termine

- End-Datum passt sich automatisch an wenn Start geändert wird (Duration bleibt erhalten)
- Erstellen-Button nutzt den aktuell angesehenen Tag statt immer heute
- Monatsansicht: Einzelklick = Tag auswählen, Doppelklick = Tagesansicht, Rechtsklick = Kontextmenü
- CalDAV URL-Matching robuster (Normalisierung, Path-Fallback, calendar_id Parameter)
- iCal-Abo-Termine sind nicht mehr bearbeitbar (Read-Only-Schutz)
- Wiederkehrende Termine mit RRULE-Support (täglich/wöchentlich/monatlich/jährlich/benutzerdefiniert)
This commit is contained in:
Scarriffle
2026-04-29 17:49:03 +02:00
parent 58c7cbc38c
commit e3984eb5cf
11 changed files with 564 additions and 46 deletions

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
import caldav
from icalendar import Calendar, Event
from icalendar import Calendar, Event, vRecur
logger = logging.getLogger(__name__)
@@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
location = str(component.get("LOCATION", "") or "")
description = str(component.get("DESCRIPTION", "") or "")
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
dtstart_prop = component.get("DTSTART")
dtend_prop = component.get("DTEND")
@@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
"location": location,
"description": description,
"color": color or None,
"rrule": rrule_str,
}
)
except Exception as exc:
@@ -201,6 +204,8 @@ def create_event(
event.add("description", data["description"])
if data.get("color"):
event.add("x-calendarr-color", data["color"])
if data.get("rrule"):
event.add("rrule", _parse_rrule_str(data["rrule"]))
cal.add_component(event)
cal_obj.save_event(cal.to_ical().decode("utf-8"))
@@ -247,6 +252,11 @@ def update_event(
component["DESCRIPTION"] = data["description"]
if "color" in data:
component["X-CALENDARR-COLOR"] = data["color"]
if "rrule" in data:
if data["rrule"]:
component["RRULE"] = _parse_rrule_str(data["rrule"])
elif "RRULE" in component:
del component["RRULE"]
new_cal.add_component(component)
@@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str):
resource.delete()
def _parse_rrule_str(rrule_str: str) -> vRecur:
"""Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur."""
params = {}
for part in rrule_str.split(";"):
if "=" not in part:
continue
key, val = part.split("=", 1)
if "," in val:
params[key] = val.split(",")
else:
params[key] = val
return vRecur(params)
def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)

View File

@@ -83,6 +83,12 @@ def _migrate():
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
conn.commit()
logging.info("Migration: added rrule to local_events")
except Exception:
pass
_migrate()

View File

@@ -112,6 +112,7 @@ class LocalEvent(Base):
location = Column(String(500), nullable=True)
description = Column(Text, nullable=True)
color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events")

View File

@@ -1,9 +1,13 @@
import logging
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional
from urllib.parse import urlparse
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import or_
import caldav_client
import models
@@ -41,6 +45,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel):
@@ -51,6 +56,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict:
@@ -75,16 +81,124 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
}
def _expand_recurring_local(ev, local_cal, range_start, range_end):
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
results = []
try:
ev_start_str = ev.start.replace("Z", "+00:00")
ev_end_str = ev.end.replace("Z", "+00:00")
if ev.all_day:
ev_start = dt_date.fromisoformat(ev_start_str[:10])
ev_end = dt_date.fromisoformat(ev_end_str[:10])
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time()))
r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time())
r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time())
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_start = occ.date()
occ_end = occ_start + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ_start.isoformat(),
"end": occ_end.isoformat(),
"allDay": True,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
else:
ev_start = dt_datetime.fromisoformat(ev_start_str)
ev_end = dt_datetime.fromisoformat(ev_end_str)
if ev_start.tzinfo is None:
ev_start = ev_start.replace(tzinfo=dt_timezone.utc)
if ev_end.tzinfo is None:
ev_end = ev_end.replace(tzinfo=dt_timezone.utc)
duration = ev_end - ev_start
rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start)
r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc)
if r_start.tzinfo is None:
r_start = r_start.replace(tzinfo=dt_timezone.utc)
if r_end.tzinfo is None:
r_end = r_end.replace(tzinfo=dt_timezone.utc)
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
for occ in occurrences:
occ_end = occ + duration
results.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": occ.isoformat(),
"end": occ_end.isoformat(),
"allDay": False,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to single event
results.append({
"id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title,
"start": ev.start, "end": ev.end, "allDay": ev.all_day,
"location": ev.location or "", "description": ev.description or "",
"color": ev.color, "rrule": ev.rrule,
"calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name,
"calendarColor": local_cal.color, "source": "local",
})
return results
def _normalize_url(url: str) -> str:
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
host = (parsed.hostname or '').lower()
port = parsed.port
if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
port = None
netloc = f"{host}:{port}" if port else host
path = parsed.path.rstrip('/')
return f"{scheme}://{netloc}{path}"
def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]:
norm_event = _normalize_url(event_url)
# Primary: match against normalized account URL
for acc in accounts:
if event_url.startswith(acc.url):
if norm_event.startswith(_normalize_url(acc.url)):
return acc
# fallback: check calendar urls
# Fallback: match against normalized calendar URLs
for acc in accounts:
for cal in acc.calendars:
if event_url.startswith(cal.cal_id):
if norm_event.startswith(_normalize_url(cal.cal_id)):
return acc
# Second fallback: path-only matching
event_path = urlparse(event_url).path.rstrip('/')
for acc in accounts:
acc_path = urlparse(acc.url).path.rstrip('/')
if acc_path and event_path.startswith(acc_path):
return acc
for acc in accounts:
for cal in acc.calendars:
cal_path = urlparse(cal.cal_id).path.rstrip('/')
if cal_path and event_path.startswith(cal_path):
return acc
return None
@@ -302,27 +416,35 @@ def get_events(
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == local_cal.id,
models.LocalEvent.start < end,
models.LocalEvent.end > start,
or_(
# Non-recurring events in range
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
# Recurring events: always include so we can expand
models.LocalEvent.rrule != None,
),
)
.all()
)
for ev in local_events:
all_events.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start,
"end": ev.end,
"allDay": ev.all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
if ev.rrule:
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
else:
all_events.append({
"id": ev.uid,
"url": f"local://{ev.uid}",
"title": ev.title,
"start": ev.start,
"end": ev.end,
"allDay": ev.all_day,
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": None,
"calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ──────────────────────────
ical_subs = (
@@ -403,6 +525,7 @@ def create_event(
"location": data.location,
"description": data.description,
"color": data.color,
"rrule": data.rrule,
},
)
return {"uid": uid, "calendar_id": data.calendar_id}
@@ -414,6 +537,7 @@ def create_event(
def update_event(
event_id: str,
event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
data: EventUpdate = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
@@ -423,7 +547,18 @@ def update_event(
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = _find_account_for_event_url(event_url, accounts)
account = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
if not account:
account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
@@ -443,6 +578,7 @@ def update_event(
def delete_event(
event_id: str,
event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
@@ -451,7 +587,18 @@ def delete_event(
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = _find_account_for_event_url(event_url, accounts)
account = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
if not account:
account = _find_account_for_event_url(event_url, accounts)
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:

View File

@@ -32,6 +32,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel):
@@ -42,6 +43,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict:
@@ -64,6 +66,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
"location": ev.location or "",
"description": ev.description or "",
"color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{cal.id}",
"calendar_name": cal.name,
"calendarColor": cal.color,
@@ -180,6 +183,7 @@ def create_event(
location=data.location,
description=data.description,
color=data.color,
rrule=data.rrule,
)
db.add(ev)
db.commit()
@@ -219,6 +223,8 @@ def update_event(
ev.description = data.description
if data.color is not None:
ev.color = data.color
if data.rrule is not None:
ev.rrule = data.rrule if data.rrule else None
db.commit()
return {"ok": True}