Commit All
This commit is contained in:
		
							
								
								
									
										747
									
								
								main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										747
									
								
								main.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,747 @@
 | 
			
		||||
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)
 | 
			
		||||
		Reference in New Issue
	
	Block a user