Files
turmlibar-calendar/main.py
2025-10-30 13:33:08 +01:00

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)