747 lines
24 KiB
Python
747 lines
24 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
import json
|
|
import os
|
|
|
|
import httpx
|
|
import pytz
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from icalendar import Calendar, Event
|
|
from jinja2 import Template
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Global variables
|
|
CALENDAR_URL = "https://outlook.live.com/owa/calendar/ef9138c2-c803-4689-a53e-fe7d0cb90124/d12c4ed3-dfa2-461f-bcd8-9442bea1903b/cid-CD3289D19EBD3DA4/calendar.ics"
|
|
CACHE_FILE = Path("calendar_cache.json")
|
|
calendar_data = []
|
|
last_fetch_time = None
|
|
scheduler = AsyncIOScheduler()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Manage application lifespan events."""
|
|
# Startup
|
|
logger.info("Starting up...")
|
|
|
|
# Fetch calendar immediately
|
|
await fetch_calendar()
|
|
|
|
# Schedule daily updates at 2 AM
|
|
scheduler.add_job(
|
|
fetch_calendar,
|
|
'cron',
|
|
hour=2,
|
|
minute=0,
|
|
id='daily_calendar_fetch'
|
|
)
|
|
|
|
# Also fetch every 4 hours for more frequent updates
|
|
scheduler.add_job(
|
|
fetch_calendar,
|
|
'interval',
|
|
hours=4,
|
|
id='periodic_calendar_fetch'
|
|
)
|
|
|
|
scheduler.start()
|
|
logger.info("Scheduler started")
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
logger.info("Shutting down...")
|
|
scheduler.shutdown()
|
|
logger.info("Scheduler stopped")
|
|
|
|
|
|
# Initialize FastAPI app with lifespan
|
|
app = FastAPI(title="Calendar Viewer", version="1.0.0", lifespan=lifespan)
|
|
|
|
# Mount static files directory if it exists
|
|
static_dir = Path("static")
|
|
if not static_dir.exists():
|
|
static_dir.mkdir(exist_ok=True)
|
|
logger.info("Created static directory")
|
|
|
|
# Ensure logo file exists in static directory
|
|
logo_source = Path("Vektor-Logo.svg")
|
|
logo_dest = static_dir / "logo.svg"
|
|
if logo_source.exists() and not logo_dest.exists():
|
|
import shutil
|
|
shutil.copy2(logo_source, logo_dest)
|
|
logger.info("Copied logo to static directory")
|
|
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
# HTML template for the calendar view
|
|
HTML_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>Turmli Bar - Calendar Events</title>
|
|
<meta name="description" content="View upcoming events at Turmli Bar">
|
|
<meta name="theme-color" content="#667eea">
|
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
|
<link rel="apple-touch-icon" href="/static/logo.svg">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #2c1810 0%, #3d2817 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
color: #f4e4d4;
|
|
margin-bottom: 30px;
|
|
padding: 20px;
|
|
background: rgba(139, 69, 19, 0.3);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(139, 69, 19, 0.2);
|
|
border-radius: 10px;
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.logo-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
width: 150px;
|
|
height: 150px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
background: rgba(244, 228, 212, 0.1);
|
|
border-radius: 10px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.logo {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
filter: sepia(20%) saturate(0.8);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.8em;
|
|
margin-bottom: 10px;
|
|
margin-top: 0;
|
|
color: #f4e4d4;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.last-updated {
|
|
font-size: 0.9em;
|
|
color: #d4a574;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.refresh-btn {
|
|
background: #8b4513;
|
|
border: 1px solid #a0522d;
|
|
color: #f4e4d4;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
margin-top: 15px;
|
|
font-weight: 500;
|
|
transition: all 0.2s ease;
|
|
min-width: 120px;
|
|
position: relative;
|
|
}
|
|
|
|
.refresh-btn:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 2px solid #f4e4d4;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
margin-right: 8px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.refresh-btn:hover {
|
|
background: #a0522d;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.events-container {
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
|
|
.date-group {
|
|
background: rgba(244, 228, 212, 0.95);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
|
border: 1px solid rgba(139, 69, 19, 0.1);
|
|
}
|
|
|
|
.date-header {
|
|
font-size: 1.2em;
|
|
font-weight: 600;
|
|
color: #5d4e37;
|
|
margin-bottom: 15px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid #d4a574;
|
|
}
|
|
|
|
.event-card {
|
|
background: rgba(255, 248, 240, 0.9);
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
margin-bottom: 15px;
|
|
border-left: 4px solid #cd853f;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.event-card:hover {
|
|
background: rgba(255, 248, 240, 1);
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.event-card:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.event-time {
|
|
font-size: 0.9em;
|
|
color: #8b6914;
|
|
font-weight: 500;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.event-title {
|
|
font-size: 1.1em;
|
|
color: #3e2817;
|
|
font-weight: bold;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.event-location {
|
|
font-size: 0.9em;
|
|
color: #8b7355;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.all-day-badge {
|
|
display: inline-block;
|
|
background: #cd853f;
|
|
color: #fff8f0;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.no-events {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #5d4e37;
|
|
background: rgba(244, 228, 212, 0.95);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.error {
|
|
background: rgba(139, 69, 19, 0.2);
|
|
color: #ff6b6b;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
border: 1px solid rgba(139, 69, 19, 0.3);
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
color: #d4a574;
|
|
font-size: 1.1em;
|
|
padding: 40px;
|
|
}
|
|
|
|
.auto-refresh-indicator {
|
|
font-size: 0.85em;
|
|
color: #8b7355;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
body {
|
|
padding: 10px;
|
|
background: #2c1810;
|
|
}
|
|
|
|
.header {
|
|
background: rgba(139, 69, 19, 0.4);
|
|
padding: 15px;
|
|
}
|
|
|
|
.logo-container {
|
|
width: 100px;
|
|
height: 100px;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.date-group {
|
|
background: rgba(244, 228, 212, 0.98);
|
|
}
|
|
|
|
.date-header {
|
|
font-size: 1.1em;
|
|
color: #3e2817;
|
|
}
|
|
|
|
.event-title {
|
|
font-size: 1em;
|
|
}
|
|
|
|
.event-card {
|
|
padding: 12px;
|
|
background: rgba(255, 248, 240, 0.95);
|
|
}
|
|
|
|
.refresh-btn {
|
|
padding: 8px 16px;
|
|
font-size: 0.95em;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="logo-container">
|
|
<img src="/static/logo.svg" alt="Turmli Bar Logo" class="logo" onerror="this.style.display='none'">
|
|
</div>
|
|
<h1>Turmli Bar Calendar</h1>
|
|
<div class="last-updated" id="lastUpdated">
|
|
{% if last_updated %}
|
|
Last updated: {{ last_updated }}
|
|
{% endif %}
|
|
</div>
|
|
<div class="auto-refresh-indicator" id="autoRefreshIndicator">Auto-refresh in <span id="countdown">5:00</span></div>
|
|
<button class="refresh-btn" id="refreshBtn" onclick="refreshCalendar()">Refresh Calendar</button>
|
|
<div id="statusMessage" style="margin-top: 10px; font-size: 0.9em; color: #d4a574; min-height: 20px;"></div>
|
|
</div>
|
|
|
|
<div class="events-container">
|
|
{% if error %}
|
|
<div class="error">
|
|
<h2>Error</h2>
|
|
<p>{{ error }}</p>
|
|
</div>
|
|
{% elif not events %}
|
|
<div class="no-events">
|
|
<h2>No Upcoming Events</h2>
|
|
<p>There are no events scheduled for the next 30 days.</p>
|
|
</div>
|
|
{% else %}
|
|
{% for date, day_events in events_by_date.items() %}
|
|
<div class="date-group">
|
|
<div class="date-header">{{ date }}</div>
|
|
{% for event in day_events %}
|
|
<div class="event-card">
|
|
{% if event.all_day %}
|
|
<div class="event-time">
|
|
<span class="all-day-badge">All Day</span>
|
|
</div>
|
|
{% else %}
|
|
<div class="event-time">{{ event.time }}</div>
|
|
{% endif %}
|
|
<div class="event-title">{{ event.title }}</div>
|
|
{% if event.location %}
|
|
<div class="event-location">📍 {{ event.location }}</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Auto-refresh countdown timer
|
|
let refreshInterval = 5 * 60; // 5 minutes in seconds
|
|
let timeRemaining = refreshInterval;
|
|
|
|
function updateCountdown() {
|
|
const minutes = Math.floor(timeRemaining / 60);
|
|
const seconds = timeRemaining % 60;
|
|
const countdownEl = document.getElementById('countdown');
|
|
if (countdownEl) {
|
|
countdownEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
timeRemaining--;
|
|
|
|
if (timeRemaining < 0) {
|
|
// Auto-refresh
|
|
refreshCalendar();
|
|
timeRemaining = refreshInterval; // Reset countdown
|
|
}
|
|
}
|
|
|
|
// Update countdown every second
|
|
setInterval(updateCountdown, 1000);
|
|
|
|
// Function to refresh calendar data
|
|
async function refreshCalendar() {
|
|
const btn = document.getElementById('refreshBtn');
|
|
const statusMsg = document.getElementById('statusMessage');
|
|
const lastUpdated = document.getElementById('lastUpdated');
|
|
const originalText = btn.textContent;
|
|
|
|
try {
|
|
// Update button to show loading state with spinner
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span>Fetching...';
|
|
statusMsg.textContent = 'Connecting to calendar server...';
|
|
statusMsg.style.color = '#d4a574';
|
|
|
|
// Reset countdown timer when manually refreshing
|
|
timeRemaining = refreshInterval;
|
|
|
|
// Call the refresh API endpoint
|
|
const response = await fetch('/api/refresh', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Calendar refreshed:', data);
|
|
|
|
// Show success
|
|
btn.innerHTML = '✓ Success!';
|
|
btn.style.background = '#4a7c59';
|
|
btn.style.borderColor = '#5a8c69';
|
|
|
|
const eventCount = data.events_count || 0;
|
|
const changeText = data.data_changed ? ' (data updated)' : ' (no changes)';
|
|
statusMsg.textContent = `Found ${eventCount} event${eventCount !== 1 ? 's' : ''}${changeText}. Reloading...`;
|
|
statusMsg.style.color = '#90c9a4';
|
|
|
|
// Update the last updated time
|
|
const now = new Date();
|
|
const timeStr = now.toLocaleString('en-US', {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
lastUpdated.textContent = `Last updated: ${timeStr}`;
|
|
|
|
// Reload page after showing success
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 1500);
|
|
} else {
|
|
throw new Error(`Server returned ${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error refreshing calendar:', error);
|
|
|
|
// Show error
|
|
btn.innerHTML = '✗ Failed';
|
|
btn.style.background = '#c44536';
|
|
btn.style.borderColor = '#d45546';
|
|
statusMsg.textContent = 'Could not refresh calendar. Please try again.';
|
|
statusMsg.style.color = '#ff6b6b';
|
|
|
|
// Reset button after 3 seconds
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalText;
|
|
btn.style.background = '#8b4513';
|
|
btn.style.borderColor = '#a0522d';
|
|
statusMsg.textContent = '';
|
|
}, 3000);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
async def fetch_calendar():
|
|
"""Fetch and parse the ICS calendar file."""
|
|
global calendar_data, last_fetch_time
|
|
|
|
try:
|
|
logger.info(f"Fetching calendar from {CALENDAR_URL}")
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
response = await client.get(CALENDAR_URL)
|
|
response.raise_for_status()
|
|
|
|
# Parse the ICS file
|
|
cal = Calendar.from_ical(response.content)
|
|
events = []
|
|
|
|
# Get current time and timezone
|
|
tz = pytz.timezone('Europe/Berlin') # Adjust timezone as needed
|
|
now = datetime.now(tz)
|
|
cutoff = now + timedelta(days=30) # Show events for next 30 days
|
|
|
|
for component in cal.walk():
|
|
if component.name == "VEVENT":
|
|
event_data = {}
|
|
|
|
# Get event title
|
|
event_data['title'] = str(component.get('SUMMARY', 'Untitled Event'))
|
|
|
|
# Get event location
|
|
location = component.get('LOCATION')
|
|
event_data['location'] = str(location) if location else None
|
|
|
|
# Get event time
|
|
dtstart = component.get('DTSTART')
|
|
dtend = component.get('DTEND')
|
|
|
|
if dtstart:
|
|
# Handle both datetime and date objects
|
|
if hasattr(dtstart.dt, 'date'):
|
|
# It's a datetime
|
|
start_dt = dtstart.dt
|
|
if not start_dt.tzinfo:
|
|
start_dt = tz.localize(start_dt)
|
|
event_data['start'] = start_dt
|
|
event_data['all_day'] = False
|
|
else:
|
|
# It's a date (all-day event)
|
|
event_data['start'] = tz.localize(datetime.combine(dtstart.dt, datetime.min.time()))
|
|
event_data['all_day'] = True
|
|
|
|
# Only include future events within the cutoff
|
|
if event_data['start'] >= now and event_data['start'] <= cutoff:
|
|
if dtend:
|
|
if hasattr(dtend.dt, 'date'):
|
|
end_dt = dtend.dt
|
|
if not end_dt.tzinfo:
|
|
end_dt = tz.localize(end_dt)
|
|
event_data['end'] = end_dt
|
|
else:
|
|
event_data['end'] = tz.localize(datetime.combine(dtend.dt, datetime.min.time()))
|
|
|
|
events.append(event_data)
|
|
|
|
# Sort events by start time
|
|
events.sort(key=lambda x: x['start'])
|
|
|
|
calendar_data = events
|
|
last_fetch_time = datetime.now()
|
|
|
|
# Cache the data
|
|
cache_data = {
|
|
'events': [
|
|
{
|
|
'title': e['title'],
|
|
'location': e['location'],
|
|
'start': e['start'].isoformat(),
|
|
'end': e.get('end').isoformat() if e.get('end') else None,
|
|
'all_day': e.get('all_day', False)
|
|
}
|
|
for e in events
|
|
],
|
|
'last_fetch': last_fetch_time.isoformat()
|
|
}
|
|
|
|
with open(CACHE_FILE, 'w') as f:
|
|
json.dump(cache_data, f)
|
|
|
|
logger.info(f"Successfully fetched {len(events)} events")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching calendar: {e}")
|
|
# Try to load from cache
|
|
if CACHE_FILE.exists():
|
|
try:
|
|
with open(CACHE_FILE, 'r') as f:
|
|
cache_data = json.load(f)
|
|
|
|
tz = pytz.timezone('Europe/Berlin')
|
|
calendar_data = []
|
|
for e in cache_data['events']:
|
|
event = {
|
|
'title': e['title'],
|
|
'location': e['location'],
|
|
'start': datetime.fromisoformat(e['start']),
|
|
'all_day': e.get('all_day', False)
|
|
}
|
|
if e.get('end'):
|
|
event['end'] = datetime.fromisoformat(e['end'])
|
|
calendar_data.append(event)
|
|
|
|
last_fetch_time = datetime.fromisoformat(cache_data['last_fetch'])
|
|
logger.info("Loaded events from cache")
|
|
except Exception as cache_error:
|
|
logger.error(f"Error loading cache: {cache_error}")
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home(request: Request):
|
|
"""Display the calendar events."""
|
|
try:
|
|
# Group events by date
|
|
events_by_date = {}
|
|
|
|
for event in calendar_data:
|
|
date_key = event['start'].strftime('%A, %B %d, %Y')
|
|
|
|
if date_key not in events_by_date:
|
|
events_by_date[date_key] = []
|
|
|
|
# Format event for display
|
|
display_event = {
|
|
'title': event['title'],
|
|
'location': event['location'],
|
|
'all_day': event.get('all_day', False)
|
|
}
|
|
|
|
if not event.get('all_day'):
|
|
if event.get('end'):
|
|
display_event['time'] = f"{event['start'].strftime('%I:%M %p')} - {event['end'].strftime('%I:%M %p')}"
|
|
else:
|
|
display_event['time'] = event['start'].strftime('%I:%M %p')
|
|
|
|
events_by_date[date_key].append(display_event)
|
|
|
|
template = Template(HTML_TEMPLATE)
|
|
html_content = template.render(
|
|
events=calendar_data,
|
|
events_by_date=events_by_date,
|
|
last_updated=last_fetch_time.strftime('%B %d, %Y at %I:%M %p') if last_fetch_time else None,
|
|
error=None
|
|
)
|
|
|
|
return HTMLResponse(content=html_content)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error rendering page: {e}")
|
|
template = Template(HTML_TEMPLATE)
|
|
html_content = template.render(
|
|
events=[],
|
|
events_by_date={},
|
|
last_updated=None,
|
|
error=str(e)
|
|
)
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
@app.get("/api/events")
|
|
async def get_events():
|
|
"""API endpoint to get calendar events as JSON."""
|
|
return {
|
|
"events": [
|
|
{
|
|
"title": e['title'],
|
|
"location": e['location'],
|
|
"start": e['start'].isoformat(),
|
|
"end": e.get('end').isoformat() if e.get('end') else None,
|
|
"all_day": e.get('all_day', False)
|
|
}
|
|
for e in calendar_data
|
|
],
|
|
"last_updated": last_fetch_time.isoformat() if last_fetch_time else None
|
|
}
|
|
|
|
|
|
@app.post("/api/refresh")
|
|
async def refresh_calendar():
|
|
"""Manually refresh the calendar data."""
|
|
try:
|
|
# Store the previous count for comparison
|
|
previous_count = len(calendar_data)
|
|
|
|
# Fetch the latest calendar data
|
|
await fetch_calendar()
|
|
|
|
# Get the new count
|
|
new_count = len(calendar_data)
|
|
|
|
# Determine if data changed
|
|
data_changed = new_count != previous_count
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Calendar refreshed successfully",
|
|
"events_count": new_count,
|
|
"previous_count": previous_count,
|
|
"data_changed": data_changed,
|
|
"last_updated": last_fetch_time.isoformat() if last_fetch_time else None
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error during manual refresh: {e}")
|
|
# Try to return cached data info if available
|
|
return {
|
|
"status": "error",
|
|
"message": f"Failed to refresh calendar: {str(e)}",
|
|
"events_count": len(calendar_data),
|
|
"cached_data_available": len(calendar_data) > 0,
|
|
"last_successful_update": last_fetch_time.isoformat() if last_fetch_time else None
|
|
}
|
|
|
|
|
|
@app.get("/logo")
|
|
async def get_logo():
|
|
"""Serve the logo SVG file with proper content type."""
|
|
from fastapi.responses import FileResponse
|
|
logo_path = Path("static/logo.svg")
|
|
if not logo_path.exists():
|
|
logo_path = Path("Vektor-Logo.svg")
|
|
if logo_path.exists():
|
|
return FileResponse(logo_path, media_type="image/svg+xml")
|
|
return {"error": "Logo not found"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8000) |