added events calendar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -560,6 +560,121 @@ data:
|
||||
# ---------- RIGHT COLUMN ----------
|
||||
- size: small
|
||||
widgets:
|
||||
# Calendar Events Widget (Órák & Családi)
|
||||
- type: custom-api
|
||||
title: Next Events
|
||||
cache: 5m
|
||||
url: http://glance-helper.glance-system.svc.cluster.local:8000/calendar/events
|
||||
parameters:
|
||||
count: 10
|
||||
days: 14
|
||||
template: |
|
||||
{{ $events := .JSON.Array "events" }}
|
||||
{{ $count := len $events }}
|
||||
{{ $showCount := 5 }}
|
||||
{{ $hasMore := gt $count $showCount }}
|
||||
|
||||
<style>
|
||||
.cal-widget { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cal-meta { opacity: .65; font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.cal-empty { opacity: 0.5; font-size: 13px; padding: 8px 0; }
|
||||
.cal-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cal-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
align-items: start;
|
||||
}
|
||||
.cal-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.cal-title {
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cal-source {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
margin-top: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.cal-time {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cal-date { font-weight: 600; }
|
||||
.cal-hour { opacity: 0.8; }
|
||||
.cal-allday {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Collapse/expand styling */
|
||||
.cal-hidden { display: none; }
|
||||
.cal-toggle {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cal-toggle:hover { opacity: 0.9; background: rgba(255,255,255,0.05); }
|
||||
#cal-expand:checked ~ .cal-list .cal-extra { display: grid; }
|
||||
#cal-expand:checked ~ .cal-toggle-more { display: none; }
|
||||
#cal-expand:not(:checked) ~ .cal-toggle-less { display: none; }
|
||||
</style>
|
||||
|
||||
<div class="cal-widget">
|
||||
<div class="cal-meta">
|
||||
<span>Next events ({{ $count }})</span>
|
||||
</div>
|
||||
|
||||
{{ if lt $count 1 }}
|
||||
<div class="cal-empty">No event in near future.</div>
|
||||
{{ else }}
|
||||
{{ if $hasMore }}<input type="checkbox" id="cal-expand" style="display:none;">{{ end }}
|
||||
<div class="cal-list">
|
||||
{{ range $i, $e := $events }}
|
||||
{{ $isExtra := ge $i $showCount }}
|
||||
{{ $start := $e.String "start" }}
|
||||
{{ $isAllDay := $e.Bool "is_all_day" }}
|
||||
{{ $calendar := $e.String "calendar" }}
|
||||
{{ $title := $e.String "title" }}
|
||||
|
||||
<div class="cal-item{{ if $isExtra }} cal-extra cal-hidden{{ end }}">
|
||||
<div>
|
||||
<div class="cal-title">{{ $title }}</div>
|
||||
<div class="cal-source">{{ $calendar }}</div>
|
||||
</div>
|
||||
<div class="cal-time">
|
||||
<div class="cal-date">{{ formatTime $start "Jan 2" }}</div>
|
||||
{{ if $isAllDay }}
|
||||
<div class="cal-allday">Whole Day</div>
|
||||
{{ else }}
|
||||
<div class="cal-hour">{{ formatTime $start "15:04" }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if $hasMore }}
|
||||
<label class="cal-toggle cal-toggle-more" for="cal-expand">Show More ({{ sub $count $showCount }} további) ▼</label>
|
||||
<label class="cal-toggle cal-toggle-less" for="cal-expand">Show Less ▲</label>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
- type: rss
|
||||
title: News & Feeds
|
||||
limit: 15
|
||||
|
||||
Reference in New Issue
Block a user