#!/usr/bin/python3 """Web page to show upcoming events.""" import decimal import inspect import json import operator import os.path import sys import time import traceback from collections import defaultdict from datetime import date, datetime, timedelta import flask import UniAuth.auth import werkzeug import werkzeug.debug.tbtools import yaml import agenda.data import agenda.error_mail import agenda.fx import agenda.holidays import agenda.stats import agenda.thespacedevs import agenda.trip import agenda.trip_schengen import agenda.utils from agenda import calendar, format_list_with_ampersand, travel, uk_tz from agenda.types import StrDict, Trip app = flask.Flask(__name__) app.debug = False app.config.from_object("config.default") agenda.error_mail.setup_error_mail(app) @app.before_request def handle_auth() -> None: """Handle authentication and set global user.""" flask.g.user = UniAuth.auth.get_current_user() @app.errorhandler(werkzeug.exceptions.InternalServerError) def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, int]: """Handle exception.""" exec_type, exc_value, current_traceback = sys.exc_info() assert exc_value tb = werkzeug.debug.tbtools.DebugTraceback(exc_value) summary = tb.render_traceback_html(include_title=False) exc_lines = "".join(tb._te.format_exception_only()) last_frame = list(traceback.walk_tb(current_traceback))[-1][0] last_frame_args = inspect.getargs(last_frame.f_code) return ( flask.render_template( "show_error.html", plaintext=tb.render_traceback_text(), exception=exc_lines, exception_type=tb._te.exc_type.__name__, summary=summary, last_frame=last_frame, last_frame_args=last_frame_args, ), 500, ) def get_current_trip(today: date) -> Trip | None: """Get current trip.""" trip_list = get_trip_list(route_distances=None) current = [ item for item in trip_list if item.start <= today and (item.end or item.start) >= today ] assert len(current) < 2 return current[0] if current else None @app.route("/") async def index() -> str: """Index page.""" t0 = time.time() now = datetime.now() data = await agenda.data.get_data(now, app.config) events = data.pop("events") today = now.date() markets_arg = flask.request.args.get("markets") if markets_arg == "hide": events = [e for e in events if e.name != "market"] if markets_arg != "show": agenda.data.hide_markets_while_away(events, data["accommodation_events"]) return flask.render_template( "event_list.html", today=today, events=events, get_country=agenda.get_country, current_trip=get_current_trip(today), fullcalendar_events=calendar.build_events(events), start_event_list=date.today() - timedelta(days=1), end_event_list=date.today() + timedelta(days=365 * 2), render_time=(time.time() - t0), **data, ) @app.route("/calendar") async def calendar_page() -> str: """Index page.""" now = datetime.now() data = await agenda.data.get_data(now, app.config) events = data.pop("events") markets_arg = flask.request.args.get("markets") if markets_arg == "hide": events = [e for e in events if e.name != "market"] if markets_arg != "show": agenda.data.hide_markets_while_away(events, data["accommodation_events"]) return flask.render_template( "calendar.html", today=now.date(), events=events, fullcalendar_events=calendar.build_events(events), **data, ) @app.route("/recent") async def recent() -> str: """Index page.""" t0 = time.time() now = datetime.now() data = await agenda.data.get_data(now, app.config) events = data.pop("events") markets_arg = flask.request.args.get("markets") if markets_arg == "hide": events = [e for e in events if e.name != "market"] if markets_arg != "show": agenda.data.hide_markets_while_away(events, data["accommodation_events"]) return flask.render_template( "event_list.html", today=now.date(), events=events, fullcalendar_events=calendar.build_events(events), start_event_list=date.today() - timedelta(days=14), end_event_list=date.today(), render_time=(time.time() - t0), **data, ) @app.route("/launches") def launch_list() -> str: """Web page showing List of space launches.""" now = datetime.now() data_dir = app.config["DATA_DIR"] rocket_dir = os.path.join(data_dir, "thespacedevs") launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100) assert launches mission_type_filter = flask.request.args.get("type") rocket_filter = flask.request.args.get("rocket") orbit_filter = flask.request.args.get("orbit") mission_types = { launch["mission"]["type"] for launch in launches if launch["mission"] } orbits = { (launch["orbit"]["name"], launch["orbit"]["abbrev"]) for launch in launches if launch.get("orbit") } rockets = {launch["rocket"]["full_name"] for launch in launches} launches = [ launch for launch in launches if ( not mission_type_filter or (launch["mission"] and launch["mission"]["type"] == mission_type_filter) ) and (not rocket_filter or launch["rocket"]["full_name"] == rocket_filter) and ( not orbit_filter or (launch.get("orbit") and launch["orbit"]["abbrev"] == orbit_filter) ) ] return flask.render_template( "launches.html", launches=launches, rockets=rockets, now=now, get_country=agenda.get_country, mission_types=mission_types, orbits=orbits, ) @app.route("/meteors") def meteor_list() -> str: """Web page showing meteor shower information.""" meteors = [ { "name": "Quadrantids", "peak": "January 3-4", "active": "December 28 - January 12", "rate": "50-100 meteors per hour", "radiant": "Boötes", "moon_phase": "New Moon (excellent viewing)", "visibility": "Northern Hemisphere", "description": "The year kicks off with the Quadrantids, known for their brief but intense peak lasting only about 4 hours.", }, { "name": "Lyrids", "peak": "April 21-22", "active": "April 14 - April 30", "rate": "10-15 meteors per hour", "radiant": "Lyra", "moon_phase": "Waning Crescent (good viewing)", "visibility": "Both hemispheres", "description": "The Lyrids are one of the oldest recorded meteor showers, with observations dating back 2,700 years.", }, { "name": "Eta Aquariids", "peak": "May 5-6", "active": "April 15 - May 27", "rate": "30-60 meteors per hour", "radiant": "Aquarius", "moon_phase": "First Quarter (moderate viewing)", "visibility": "Southern Hemisphere (best)", "description": "Created by debris from Halley's Comet, these meteors are fast and often leave glowing trails.", }, { "name": "Perseids", "peak": "August 12-13", "active": "July 17 - August 24", "rate": "50-100 meteors per hour", "radiant": "Perseus", "moon_phase": "Full Moon (poor viewing)", "visibility": "Northern Hemisphere", "description": "One of the most popular meteor showers, though 2025 viewing will be hampered by bright moonlight.", }, { "name": "Orionids", "peak": "October 21-22", "active": "October 2 - November 7", "rate": "15-25 meteors per hour", "radiant": "Orion", "moon_phase": "Waning Crescent (good viewing)", "visibility": "Both hemispheres", "description": "Another shower created by Halley's Comet debris, known for their speed and brightness.", }, { "name": "Geminids", "peak": "December 13-14", "active": "December 4 - December 20", "rate": "60-120 meteors per hour", "radiant": "Gemini", "moon_phase": "Waxing Gibbous (moderate viewing)", "visibility": "Both hemispheres", "description": "The best shower of 2025 with the highest rates. Unusual for being caused by an asteroid (3200 Phaethon) rather than a comet.", }, { "name": "Ursids", "peak": "December 22-23", "active": "December 17 - December 26", "rate": "5-10 meteors per hour", "radiant": "Ursa Minor", "moon_phase": "New Moon (excellent viewing)", "visibility": "Northern Hemisphere", "description": "A minor shower that closes out the year, best viewed from dark locations away from city lights.", }, ] return flask.render_template( "meteors.html", meteors=meteors, today=date.today(), ) @app.route("/gaps") async def gaps_page() -> str: """List of available gaps.""" now = datetime.now() trip_list = agenda.trip.build_trip_list() busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list) gaps = agenda.busy.find_gaps(busy_events) return flask.render_template("gaps.html", today=now.date(), gaps=gaps) @app.route("/weekends") async def weekends() -> str: """List of available weekends using an optional date, week, or year parameter.""" today = datetime.now().date() date_str = flask.request.args.get("date") week_str = flask.request.args.get("week") year_str = flask.request.args.get("year") if date_str: try: start = datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: return flask.abort(400, description="Invalid date format. Use YYYY-MM-DD.") elif week_str: try: week = int(week_str) year = int(year_str) if year_str else today.year if week < 1 or week > 53: return flask.abort( 400, description="Week number must be between 1 and 53." ) # Calculate the date of the first day of the given week jan_1 = date(year, 1, 1) week_1_start = jan_1 - timedelta(days=jan_1.weekday()) start = week_1_start + timedelta(weeks=week - 1) except ValueError: return flask.abort( 400, description="Invalid week or year format. Use integers." ) else: start = date(today.year, 1, 1) current_week_number = today.isocalendar().week trip_list = agenda.trip.build_trip_list() busy_events = agenda.busy.get_busy_events(start, app.config, trip_list) weekends = agenda.busy.weekends( start, busy_events, trip_list, app.config["PERSONAL_DATA"] ) return flask.render_template( "weekends.html", items=weekends, current_week_number=current_week_number, today=today, ) @app.route("/travel") def travel_list() -> str: """Page showing a list of upcoming travel.""" data_dir = app.config["PERSONAL_DATA"] flights = agenda.trip.load_flight_bookings(data_dir) trains = [ item for item in travel.parse_yaml("trains", data_dir) if isinstance(item["depart"], datetime) ] route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) for train in trains: for leg in train["legs"]: agenda.travel.add_leg_route_distance(leg, route_distances) if all("distance" in leg for leg in train["legs"]): train["distance"] = sum(leg["distance"] for leg in train["legs"]) return flask.render_template( "travel.html", flights=flights, trains=trains, fx_rate=agenda.fx.get_rates(app.config), ) def build_conference_list() -> list[StrDict]: """Build conference list.""" data_dir = app.config["PERSONAL_DATA"] filepath = os.path.join(data_dir, "conferences.yaml") items: list[StrDict] = yaml.safe_load(open(filepath)) conference_trip_lookup = {} for trip in agenda.trip.build_trip_list(): for trip_conf in trip.conferences: key = (trip_conf["start"], trip_conf["name"]) conference_trip_lookup[key] = trip for conf in items: conf["start_date"] = agenda.utils.as_date(conf["start"]) conf["end_date"] = agenda.utils.as_date(conf["end"]) price = conf.get("price") if price: conf["price"] = decimal.Decimal(price) key = (conf["start"], conf["name"]) if this_trip := conference_trip_lookup.get(key): conf["linked_trip"] = this_trip items.sort(key=operator.itemgetter("start_date")) return items @app.route("/conference") def conference_list() -> str: """Page showing a list of conferences.""" today = date.today() items = build_conference_list() current = [ conf for conf in items if conf["start_date"] <= today and conf["end_date"] >= today ] future = [conf for conf in items if conf["start_date"] > today] return flask.render_template( "conference_list.html", current=current, future=future, today=today, get_country=agenda.get_country, fx_rate=agenda.fx.get_rates(app.config), ) @app.route("/conference/past") def past_conference_list() -> str: """Page showing a list of conferences.""" today = date.today() return flask.render_template( "conference_list.html", past=[conf for conf in build_conference_list() if conf["end_date"] < today], today=today, get_country=agenda.get_country, fx_rate=agenda.fx.get_rates(app.config), ) @app.route("/accommodation") def accommodation_list() -> str: """Page showing a list of past, present and future accommodation.""" data_dir = app.config["PERSONAL_DATA"] items = travel.parse_yaml("accommodation", data_dir) stays_in_2024 = [item for item in items if item["from"].year == 2024] total_nights_2024 = sum( (stay["to"].date() - stay["from"].date()).days for stay in stays_in_2024 ) nights_abroad_2024 = sum( (stay["to"].date() - stay["from"].date()).days for stay in stays_in_2024 if stay["country"] != "gb" ) trip_lookup = {} for trip in agenda.trip.build_trip_list(): for trip_stay in trip.accommodation: key = (trip_stay["from"], trip_stay["name"]) trip_lookup[key] = trip for item in items: key = (item["from"], item["name"]) if this_trip := trip_lookup.get(key): item["linked_trip"] = this_trip now = uk_tz.localize(datetime.now()) past = [conf for conf in items if conf["to"] < now] current = [conf for conf in items if conf["from"] <= now and conf["to"] >= now] future = [conf for conf in items if conf["from"] > now] return flask.render_template( "accommodation.html", past=past, current=current, future=future, total_nights_2024=total_nights_2024, nights_abroad_2024=nights_abroad_2024, get_country=agenda.get_country, fx_rate=agenda.fx.get_rates(app.config), ) def get_trip_list( route_distances: agenda.travel.RouteDistances | None = None, ) -> list[Trip]: """Get list of trips respecting current authentication status.""" trips = [ trip for trip in agenda.trip.build_trip_list(route_distances=route_distances) if flask.g.user.is_authenticated or not trip.private ] # Add Schengen compliance information to each trip for trip in trips: agenda.trip_schengen.add_schengen_compliance_to_trip(trip) return trips @app.route("/trip") def trip_list() -> werkzeug.Response: """Trip list to redirect to future trip list.""" return flask.redirect(flask.url_for("trip_future_list")) def calc_total_distance(trips: list[Trip]) -> float: """Total distance for trips.""" total = 0.0 for item in trips: if dist := item.total_distance(): total += dist return total def calc_total_co2_kg(trips: list[Trip]) -> float: """Total CO₂ for trips.""" return sum(item.total_co2_kg() for item in trips) def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float]]: """Sum distances by transport type.""" distances_by_transport_type: defaultdict[str, float] = defaultdict(float) for trip in trips: for transport_type, dist in trip.distances_by_transport_type(): distances_by_transport_type[transport_type] += dist return list(distances_by_transport_type.items()) @app.route("/trip/past") def trip_past_list() -> str: """Page showing a list of past trips.""" route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances) today = date.today() past = [item for item in trip_list if (item.end or item.start) < today] coordinates, routes = agenda.trip.get_coordinates_and_routes(past) return flask.render_template( "trip/list.html", heading="Past trips", trips=reversed(past), coordinates=coordinates, routes=routes, today=today, get_country=agenda.get_country, format_list_with_ampersand=format_list_with_ampersand, fx_rate=agenda.fx.get_rates(app.config), total_distance=calc_total_distance(past), total_co2_kg=calc_total_co2_kg(past), distances_by_transport_type=sum_distances_by_transport_type(past), ) @app.route("/trip/future") def trip_future_list() -> str: """Page showing a list of future trips.""" route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances) today = date.today() current = [ item for item in trip_list if item.start <= today and (item.end or item.start) >= today ] future = [item for item in trip_list if item.start > today] coordinates, routes = agenda.trip.get_coordinates_and_routes(current + future) return flask.render_template( "trip/list.html", heading="Future trips", trips=current + future, coordinates=coordinates, routes=routes, today=today, get_country=agenda.get_country, format_list_with_ampersand=format_list_with_ampersand, fx_rate=agenda.fx.get_rates(app.config), total_distance=calc_total_distance(current + future), total_co2_kg=calc_total_co2_kg(current + future), distances_by_transport_type=sum_distances_by_transport_type(current + future), ) @app.route("/trip/text") def trip_list_text() -> str: """Page showing a list of trips.""" trip_list = get_trip_list() today = date.today() future = [item for item in trip_list if item.start > today] return flask.render_template( "trip_list_text.html", future=future, today=today, get_country=agenda.get_country, format_list_with_ampersand=format_list_with_ampersand, ) def get_prev_current_and_next_trip( start: str, trip_list: list[Trip] ) -> tuple[Trip | None, Trip | None, Trip | None]: """Get previous trip, this trip and next trip.""" trip_iter = iter(trip_list) prev_trip = None current_trip = None for trip in trip_iter: if trip.start.isoformat() == start: current_trip = trip break prev_trip = trip next_trip = next(trip_iter, None) return (prev_trip, current_trip, next_trip) @app.route("/trip/") def trip_page(start: str) -> str: """Individual trip page.""" route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances) prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) if not trip: flask.abort(404) # Add Schengen compliance information trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip) coordinates = agenda.trip.collect_trip_coordinates(trip) routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"]) agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates) for route in routes: if "geojson_filename" in route: route["geojson"] = agenda.trip.read_geojson( app.config["PERSONAL_DATA"], route.pop("geojson_filename") ) return flask.render_template( "trip_page.html", trip=trip, prev_trip=prev_trip, next_trip=next_trip, today=date.today(), coordinates=coordinates, routes=routes, get_country=agenda.get_country, format_list_with_ampersand=format_list_with_ampersand, holidays=agenda.holidays.get_trip_holidays(trip), human_readable_delta=agenda.utils.human_readable_delta, ) @app.route("/trip//debug") def trip_debug_page(start: str) -> str: """Trip debug page showing raw trip object data.""" if not flask.g.user.is_authenticated: flask.abort(401) route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances) prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) if not trip: flask.abort(404) # Add Schengen compliance information trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip) # Convert trip object to dictionary for display trip_dict = { "start": trip.start.isoformat(), "name": trip.name, "private": trip.private, "travel": trip.travel, "accommodation": trip.accommodation, "conferences": trip.conferences, "events": trip.events, "flight_bookings": trip.flight_bookings, "computed_properties": { "title": trip.title, "end": trip.end.isoformat() if trip.end else None, "countries": [ {"name": c.name, "alpha_2": c.alpha_2, "flag": c.flag} for c in trip.countries ], "locations": [ { "location": loc, "country": {"name": country.name, "alpha_2": country.alpha_2}, } for loc, country in trip.locations() ], "total_distance": trip.total_distance(), "total_co2_kg": trip.total_co2_kg(), "distances_by_transport_type": trip.distances_by_transport_type(), "co2_by_transport_type": trip.co2_by_transport_type(), }, "schengen_compliance": ( { "total_days_used": trip.schengen_compliance.total_days_used, "days_remaining": trip.schengen_compliance.days_remaining, "is_compliant": trip.schengen_compliance.is_compliant, "current_180_day_period": [ trip.schengen_compliance.current_180_day_period[0].isoformat(), trip.schengen_compliance.current_180_day_period[1].isoformat(), ], "days_over_limit": trip.schengen_compliance.days_over_limit, } if trip.schengen_compliance else None ), } # Convert to JSON for pretty printing trip_json = json.dumps(trip_dict, indent=2, default=str) return flask.render_template( "trip_debug.html", trip=trip, trip_json=trip_json, start=start, ) @app.route("/holidays") def holiday_list() -> str: """List of holidays.""" today = date.today() data_dir = app.config["DATA_DIR"] next_year = today + timedelta(days=1 * 365) items = agenda.holidays.get_all(today - timedelta(days=2), next_year, data_dir) items.sort(key=lambda item: (item.date, item.country)) return flask.render_template( "holiday_list.html", items=items, get_country=agenda.get_country, today=today ) @app.route("/birthdays") def birthday_list() -> str: """List of birthdays.""" today = date.today() if not flask.g.user.is_authenticated: flask.abort(401) data_dir = app.config["PERSONAL_DATA"] entities_file = os.path.join(data_dir, "entities.yaml") items = agenda.birthday.get_birthdays(today - timedelta(days=2), entities_file) items.sort(key=lambda item: item.date) return flask.render_template("birthday_list.html", items=items, today=today) @app.route("/trip/stats") def trip_stats() -> str: """Travel stats: distance and price by year and travel type.""" route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances) conferences = sum(len(item.conferences) for item in trip_list) yearly_stats = agenda.stats.calculate_yearly_stats(trip_list) return flask.render_template( "trip/stats.html", count=len(trip_list), total_distance=calc_total_distance(trip_list), distances_by_transport_type=sum_distances_by_transport_type(trip_list), yearly_stats=yearly_stats, conferences=conferences, previously_visited=app.config.get("PREVIOUSLY_VISITED", set), ) @app.route("/schengen") def schengen_report() -> str: """Schengen compliance report.""" return agenda.trip_schengen.flask_route_schengen_report() @app.route("/callback") def auth_callback() -> tuple[str, int] | werkzeug.Response: """Process the authentication callback.""" return UniAuth.auth.auth_callback() @app.route("/login") def login() -> werkzeug.Response: """Login.""" next_url = flask.request.args["next"] return UniAuth.auth.redirect_to_login(next_url) @app.route("/logout") def logout() -> werkzeug.Response: """Logout.""" return UniAuth.auth.redirect_to_logout(flask.request.args["next"]) if __name__ == "__main__": app.run(host="0.0.0.0")