added events calendar
This commit is contained in:
@@ -925,6 +925,217 @@ data:
|
|||||||
}, ensure_ascii=False),
|
}, ensure_ascii=False),
|
||||||
media_type="application/json; charset=utf-8"
|
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
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -957,7 +1168,7 @@ spec:
|
|||||||
apt-get update;
|
apt-get update;
|
||||||
apt-get install -y --no-install-recommends curl ca-certificates iputils-ping dnsutils tzdata net-tools;
|
apt-get install -y --no-install-recommends curl ca-certificates iputils-ping dnsutils tzdata net-tools;
|
||||||
rm -rf /var/lib/apt/lists/*;
|
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)"
|
python -c "import uvicorn; uvicorn.run('app:APP', host='0.0.0.0', port=8000)"
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
@@ -986,6 +1197,9 @@ spec:
|
|||||||
value: http://version-checker.version-checker-system.svc.cluster.local:8080/metrics
|
value: http://version-checker.version-checker-system.svc.cluster.local:8080/metrics
|
||||||
- name: VERSION_CHECKER_EXCLUDE_IMAGES
|
- name: VERSION_CHECKER_EXCLUDE_IMAGES
|
||||||
value: "(^|.*/)(busybox|redis|alpine|nginx|mariadb|mysql|valkey)$|^longhornio.*$|(^|.*/)postgres.*$|^registry\\.k8s\\.io/ingress-nginx/.*$"
|
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
|
image: python:3.12-bookworm
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
name: glance-helper
|
name: glance-helper
|
||||||
|
|||||||
@@ -560,6 +560,121 @@ data:
|
|||||||
# ---------- RIGHT COLUMN ----------
|
# ---------- RIGHT COLUMN ----------
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
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
|
- type: rss
|
||||||
title: News & Feeds
|
title: News & Feeds
|
||||||
limit: 15
|
limit: 15
|
||||||
|
|||||||
Reference in New Issue
Block a user