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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user