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() # Group all 3-hour slots by date so we can find the true daily min/max from collections import defaultdict slots_by_date = defaultdict(list) for item in forecast_data['list']: d = datetime.fromtimestamp(item['dt']).date() slots_by_date[d].append(item) today_date = datetime.now().date() future_dates = sorted(d for d in slots_by_date if d > today_date)[:3] daily_forecast = [] for d in future_dates: slots = slots_by_date[d] # True daily min/max across all 3-hour periods temp_max = round(max(s['main']['temp_max'] for s in slots)) temp_min = round(min(s['main']['temp_min'] for s in slots)) # Use the midday slot for icon/description midday = min(slots, key=lambda s: abs(datetime.fromtimestamp(s['dt']).hour - 12)) daily_forecast.append({ 'date': d.strftime('%a'), 'temp_max': temp_max, 'temp_min': temp_min, 'description': midday['weather'][0]['description'], 'icon': midday['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): """Identify a family member from an event title. Checks in order: 1. [Alias] prefix (strips the prefix from the displayed title) 2. Alias word appearing anywhere in the title (title kept as-is) """ match = MEMBER_PREFIX_RE.match(summary) if match: 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 summary_lower = summary.lower() for member in family_members: for alias in member['aliases']: if re.search(r'\b' + re.escape(alias) + r'\b', summary_lower): return member, summary return None, summary NUM_DAYS = 6 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) # Fetch from 7 days back so multi-day events that started before # today (e.g. "Bins - Daniel's Week" starting last Sunday) are included. fetch_start = nz_tz.localize(datetime.combine(start_date - timedelta(days=7), datetime.min.time())) fetch_end = nz_tz.localize(datetime.combine(start_date + timedelta(days=NUM_DAYS), datetime.min.time())) recurring_events = recurring_ical_events.of(cal).between(fetch_start, fetch_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']) def event_covers_day(e, d): e_start = datetime.fromisoformat(e['start']) e_end = datetime.fromisoformat(e['end']) is_all_day = (e_start.hour == 0 and e_start.minute == 0 and e_end.hour == 0 and e_end.minute == 0) if is_all_day: # iCal all-day end is exclusive (end = day after last day) return e_start.date() <= d < e_end.date() else: return e_start.date() == d days = [] for i in range(NUM_DAYS): d = start_date + timedelta(days=i) day_events = [e for e in events if event_covers_day(e, 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)