from flask import Flask, render_template, jsonify, request import requests import os import random import re from datetime import datetime, timedelta, date from icalendar import Calendar from config import Config import pytz import recurring_ical_events app = Flask(__name__) app.config.from_object(Config) # Cache for API responses to avoid rate limiting weather_cache = {'data': None, 'timestamp': None} calendar_cache = {'data': None, 'timestamp': None, 'key': None} joke_cache = {'data': None, 'timestamp': None} @app.route('/') def index(): """Render the main display page.""" return render_template('index.html') @app.route('/api/weather') def get_weather(): """Fetch weather data from OpenWeatherMap API.""" global weather_cache # Check cache now = datetime.now() if (weather_cache['data'] and weather_cache['timestamp'] and (now - weather_cache['timestamp']).total_seconds() < app.config['WEATHER_UPDATE_INTERVAL']): return jsonify(weather_cache['data']) try: # Fetch current weather current_url = f"https://api.openweathermap.org/data/2.5/weather" params = { 'lat': app.config['WEATHER_LAT'], 'lon': app.config['WEATHER_LON'], 'appid': app.config['OPENWEATHER_API_KEY'], 'units': app.config['WEATHER_UNITS'] } current_response = requests.get(current_url, params=params, timeout=10) current_response.raise_for_status() current_data = current_response.json() # Fetch 3-day forecast forecast_url = f"https://api.openweathermap.org/data/2.5/forecast" forecast_response = requests.get(forecast_url, params=params, timeout=10) forecast_response.raise_for_status() forecast_data = forecast_response.json() # Process forecast to get daily summaries daily_forecast = [] seen_dates = set() for item in forecast_data['list'][:40]: # Next 3 days (8 forecasts per day) date = datetime.fromtimestamp(item['dt']).date() if date not in seen_dates and len(daily_forecast) < 3: seen_dates.add(date) daily_forecast.append({ 'date': date.strftime('%a'), 'temp_max': round(item['main']['temp_max']), 'temp_min': round(item['main']['temp_min']), 'description': item['weather'][0]['description'], 'icon': item['weather'][0]['icon'] }) weather_data = { 'current': { 'temp': round(current_data['main']['temp']), 'feels_like': round(current_data['main']['feels_like']), 'description': current_data['weather'][0]['description'], 'icon': current_data['weather'][0]['icon'], 'humidity': current_data['main']['humidity'], 'wind_speed': round(current_data['wind']['speed'] * 3.6, 1) # Convert m/s to km/h }, 'forecast': daily_forecast } # Update cache weather_cache = {'data': weather_data, 'timestamp': now} return jsonify(weather_data) except Exception as e: app.logger.error(f"Error fetching weather: {str(e)}") # Return cached data if available, otherwise return error if weather_cache['data']: return jsonify(weather_cache['data']) return jsonify({'error': 'Unable to fetch weather data'}), 500 MEMBER_PREFIX_RE = re.compile(r'^\s*\[([^\]]+)\]\s*(.*)') def parse_member_and_title(summary, family_members): """Split a "[Alias] Title" event summary into (member_dict, clean_title).""" match = MEMBER_PREFIX_RE.match(summary) if not match: return None, summary alias = match.group(1).strip().lower() title = match.group(2).strip() or summary for member in family_members: if alias in member['aliases']: return member, title return None, title NUM_DAYS = 5 def empty_days_payload(start_date, members): days = [] for i in range(NUM_DAYS): d = start_date + timedelta(days=i) days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': []}) return { 'start': start_date.isoformat(), 'end': (start_date + timedelta(days=NUM_DAYS - 1)).isoformat(), 'days': days, 'members': members, } @app.route('/api/calendar') def get_calendar(): """Fetch Google Calendar events — today plus the next 4 days.""" global calendar_cache nz_tz = pytz.timezone('Pacific/Auckland') now = datetime.now(nz_tz) start_param = request.args.get('start') if start_param: try: start_date = datetime.strptime(start_param, '%Y-%m-%d').date() except ValueError: start_date = now.date() else: start_date = now.date() family_members = app.config['FAMILY_MEMBERS'] default_member = app.config['DEFAULT_MEMBER'] members_for_filter = family_members + [default_member] cache_key = start_date.isoformat() if (calendar_cache.get('key') == cache_key and calendar_cache['data'] and calendar_cache['timestamp'] and (now - calendar_cache['timestamp']).total_seconds() < app.config['CALENDAR_UPDATE_INTERVAL']): return jsonify(calendar_cache['data']) try: ical_url = app.config.get('GOOGLE_CALENDAR_ICAL_URL') if not ical_url: return jsonify(empty_days_payload(start_date, members_for_filter)) response = requests.get(ical_url, timeout=10) response.raise_for_status() cal = Calendar.from_ical(response.content) range_start = nz_tz.localize(datetime.combine(start_date, datetime.min.time())) range_end = range_start + timedelta(days=NUM_DAYS) recurring_events = recurring_ical_events.of(cal).between(range_start, range_end) events = [] for component in recurring_events: dtstart = component.get('dtstart') dtend = component.get('dtend') summary = str(component.get('summary', 'No Title')) if dtstart and dtstart.dt: if isinstance(dtstart.dt, datetime): event_start = dtstart.dt if event_start.tzinfo is None: event_start = nz_tz.localize(event_start) elif isinstance(dtstart.dt, date): event_start = nz_tz.localize(datetime.combine(dtstart.dt, datetime.min.time())) else: continue if dtend and dtend.dt: if isinstance(dtend.dt, datetime): event_end = dtend.dt if event_end.tzinfo is None: event_end = nz_tz.localize(event_end) elif isinstance(dtend.dt, date): event_end = nz_tz.localize(datetime.combine(dtend.dt, datetime.min.time())) else: event_end = event_start + timedelta(hours=1) else: event_end = event_start + timedelta(hours=1) member, title = parse_member_and_title(summary, family_members) member_info = member or default_member events.append({ 'title': title, 'start': event_start.isoformat(), 'end': event_end.isoformat(), 'location': str(component.get('location', '')), 'member': member_info['key'], 'member_name': member_info['name'], 'color': member_info['color'], 'bg': member_info['bg'], }) events.sort(key=lambda x: x['start']) days = [] for i in range(NUM_DAYS): d = start_date + timedelta(days=i) day_events = [e for e in events if datetime.fromisoformat(e['start']).date() == d] days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': day_events}) result = { 'start': start_date.isoformat(), 'end': (start_date + timedelta(days=NUM_DAYS - 1)).isoformat(), 'days': days, 'members': members_for_filter, } calendar_cache = {'data': result, 'timestamp': now, 'key': cache_key} return jsonify(result) except Exception as e: app.logger.error(f"Error fetching calendar: {str(e)}") if calendar_cache.get('data') and calendar_cache.get('key') == cache_key: return jsonify(calendar_cache['data']) return jsonify(empty_week_payload(week_start_date, members_for_filter)) @app.route('/api/photos') def get_photos(): """Return a shuffled list of all image URLs from the photos directory.""" photos_dir = app.config['PHOTOS_DIR'] if not os.path.exists(photos_dir): return jsonify([]) images = [ f'/static/photos/{f}' for f in os.listdir(photos_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')) ] random.shuffle(images) return jsonify(images) @app.route('/api/background') def get_background(): """Get a random background image.""" try: backgrounds_dir = app.config['BACKGROUNDS_DIR'] # Get list of image files if os.path.exists(backgrounds_dir): image_files = [f for f in os.listdir(backgrounds_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp'))] if image_files: random_image = random.choice(image_files) return jsonify({'image': f'/static/backgrounds/{random_image}'}) # Return a default color if no images return jsonify({'image': None, 'color': '#1a1a2e'}) except Exception as e: app.logger.error(f"Error getting background: {str(e)}") return jsonify({'image': None, 'color': '#1a1a2e'}) @app.route('/api/joke') def get_joke(): """Fetch a dad joke.""" global joke_cache # Check cache now = datetime.now() if (joke_cache['data'] and joke_cache['timestamp'] and (now - joke_cache['timestamp']).total_seconds() < app.config['JOKE_UPDATE_INTERVAL']): return jsonify(joke_cache['data']) try: response = requests.get( 'https://icanhazdadjoke.com/', headers={'Accept': 'application/json'}, timeout=10 ) response.raise_for_status() joke_data = response.json() joke_cache = {'data': {'joke': joke_data['joke']}, 'timestamp': now} return jsonify(joke_cache['data']) except Exception as e: app.logger.error(f"Error fetching joke: {str(e)}") if joke_cache['data']: return jsonify(joke_cache['data']) return jsonify({'joke': 'Why did the developer go broke? Because he used up all his cache!'}) if __name__ == '__main__': os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True) os.makedirs(app.config['PHOTOS_DIR'], exist_ok=True) os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True) # Run the app app.run(host='0.0.0.0', port=5002, debug=False, use_reloader=False)