added events calendar

This commit is contained in:
2026-01-23 11:57:29 +01:00
parent e371ced1db
commit 59df30f672
2 changed files with 330 additions and 1 deletions
+215 -1
View File
@@ -925,6 +925,217 @@ data:
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
# ================================
# Google Calendar iCal Integration
# ================================
from icalendar import Calendar as ICalCalendar
from dateutil.rrule import rrulestr
from dateutil import parser as dateutil_parser
# Calendar configuration - loaded from environment
CALENDAR_ICAL_URLS = os.getenv("CALENDAR_ICAL_URLS", "") # JSON: {"name": "url", ...}
def _parse_ical_urls() -> dict[str, str]:
"""Parse CALENDAR_ICAL_URLS environment variable."""
if not CALENDAR_ICAL_URLS:
return {}
try:
return json.loads(CALENDAR_ICAL_URLS)
except Exception as e:
print(f"Error parsing CALENDAR_ICAL_URLS: {e}")
return {}
def _fetch_ical(url: str, timeout: int = 15) -> str:
"""Fetch iCal content from URL."""
try:
r = requests.get(url, timeout=timeout, headers={"User-Agent": UA})
r.raise_for_status()
return r.text
except Exception as e:
print(f"Error fetching iCal from {url}: {e}")
return ""
def _parse_ical_events(ical_text: str, calendar_name: str, days_ahead: int = 30) -> list[dict]:
"""
Parse iCal text and return upcoming events.
Handles both single events and recurring events.
"""
events = []
if not ical_text:
return events
try:
tz = ZoneInfo("Europe/Budapest")
except Exception:
tz = timezone.utc
now = datetime.now(tz)
cutoff = now + timedelta(days=days_ahead)
try:
cal = ICalCalendar.from_ical(ical_text)
for component in cal.walk():
if component.name != "VEVENT":
continue
summary = str(component.get("SUMMARY", "No title"))
description = str(component.get("DESCRIPTION", "")) if component.get("DESCRIPTION") else ""
location = str(component.get("LOCATION", "")) if component.get("LOCATION") else ""
uid = str(component.get("UID", ""))
# Get start and end times
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
if not dtstart:
continue
dtstart_val = dtstart.dt
dtend_val = dtend.dt if dtend else None
# Handle all-day events (date vs datetime)
is_all_day = not isinstance(dtstart_val, datetime)
if is_all_day:
# All-day event - convert date to datetime
dtstart_val = datetime.combine(dtstart_val, datetime.min.time(), tzinfo=tz)
if dtend_val and not isinstance(dtend_val, datetime):
dtend_val = datetime.combine(dtend_val, datetime.min.time(), tzinfo=tz)
else:
# Make sure datetime is timezone-aware
if dtstart_val.tzinfo is None:
dtstart_val = dtstart_val.replace(tzinfo=tz)
else:
dtstart_val = dtstart_val.astimezone(tz)
if dtend_val:
if dtend_val.tzinfo is None:
dtend_val = dtend_val.replace(tzinfo=tz)
else:
dtend_val = dtend_val.astimezone(tz)
# Check for recurring events
rrule = component.get("RRULE")
if rrule:
# Handle recurring events
try:
rrule_str = rrule.to_ical().decode("utf-8")
rule = rrulestr(rrule_str, dtstart=dtstart_val)
# Get occurrences within our window
occurrences = list(rule.between(now, cutoff, inc=True))[:10] # Limit to 10 occurrences
for occ in occurrences:
if occ.tzinfo is None:
occ = occ.replace(tzinfo=tz)
# Calculate end time for this occurrence
if dtend_val and dtstart_val:
duration = dtend_val - dtstart_val
occ_end = occ + duration
else:
occ_end = None
events.append({
"title": summary,
"description": description,
"location": location,
"calendar": calendar_name,
"start": occ.isoformat(),
"start_unix": int(occ.timestamp()),
"end": occ_end.isoformat() if occ_end else None,
"end_unix": int(occ_end.timestamp()) if occ_end else None,
"is_all_day": is_all_day,
"uid": uid
})
except Exception as e:
print(f"Error parsing RRULE for event '{summary}': {e}")
# Fall back to single event
if now <= dtstart_val <= cutoff:
events.append({
"title": summary,
"description": description,
"location": location,
"calendar": calendar_name,
"start": dtstart_val.isoformat(),
"start_unix": int(dtstart_val.timestamp()),
"end": dtend_val.isoformat() if dtend_val else None,
"end_unix": int(dtend_val.timestamp()) if dtend_val else None,
"is_all_day": is_all_day,
"uid": uid
})
else:
# Single event
if now <= dtstart_val <= cutoff:
events.append({
"title": summary,
"description": description,
"location": location,
"calendar": calendar_name,
"start": dtstart_val.isoformat(),
"start_unix": int(dtstart_val.timestamp()),
"end": dtend_val.isoformat() if dtend_val else None,
"end_unix": int(dtend_val.timestamp()) if dtend_val else None,
"is_all_day": is_all_day,
"uid": uid
})
except Exception as e:
print(f"Error parsing iCal for {calendar_name}: {e}")
return events
@APP.get("/calendar/events")
def calendar_events_api(
count: int = Query(default=5, ge=1, le=50, description="Number of events to return"),
days: int = Query(default=30, ge=1, le=365, description="Days to look ahead")
):
"""
Returns upcoming calendar events from configured iCal feeds.
Merges multiple calendars and sorts by start time.
"""
calendars = _parse_ical_urls()
if not calendars:
return Response(
content=json.dumps({
"error": "No calendars configured",
"events": [],
"count": 0,
"fetched_at_unix": int(time.time())
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
all_events = []
calendar_status = {}
for name, url in calendars.items():
ical_text = _fetch_ical(url)
if ical_text:
events = _parse_ical_events(ical_text, name, days)
all_events.extend(events)
calendar_status[name] = {"status": "ok", "events": len(events)}
else:
calendar_status[name] = {"status": "error", "events": 0}
# Sort by start time and limit
all_events.sort(key=lambda e: e["start_unix"])
limited_events = all_events[:count]
return Response(
content=json.dumps({
"events": limited_events,
"count": len(limited_events),
"total_available": len(all_events),
"calendars": calendar_status,
"fetched_at_unix": int(time.time())
}, ensure_ascii=False),
media_type="application/json; charset=utf-8"
)
---
apiVersion: apps/v1
kind: Deployment
@@ -957,7 +1168,7 @@ spec:
apt-get update;
apt-get install -y --no-install-recommends curl ca-certificates iputils-ping dnsutils tzdata net-tools;
rm -rf /var/lib/apt/lists/*;
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client;
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client icalendar python-dateutil;
python -c "import uvicorn; uvicorn.run('app:APP', host='0.0.0.0', port=8000)"
command:
- /bin/sh
@@ -986,6 +1197,9 @@ spec:
value: http://version-checker.version-checker-system.svc.cluster.local:8080/metrics
- name: VERSION_CHECKER_EXCLUDE_IMAGES
value: "(^|.*/)(busybox|redis|alpine|nginx|mariadb|mysql|valkey)$|^longhornio.*$|(^|.*/)postgres.*$|^registry\\.k8s\\.io/ingress-nginx/.*$"
# Calendar iCal URLs (JSON object: {"name": "url", ...})
- name: CALENDAR_ICAL_URLS
value: '{"Órák": "https://calendar.google.com/calendar/ical/b2884faf3db792ac082a6206057552c79080716efd5f966e169a41fc500e1c1c%40group.calendar.google.com/public/basic.ics", "Családi": "https://calendar.google.com/calendar/ical/family05271575732400990671%40group.calendar.google.com/public/basic.ics"}'
image: python:3.12-bookworm
imagePullPolicy: IfNotPresent
name: glance-helper