agenda/web_view.py

655 lines
19 KiB
Python
Raw Normal View History

2023-10-02 20:35:30 +01:00
#!/usr/bin/python3
"""Web page to show upcoming events."""
import decimal
2023-10-06 23:29:22 +01:00
import inspect
2023-12-04 21:53:33 +00:00
import operator
2023-11-20 19:48:42 +00:00
import os.path
2023-10-06 23:29:22 +01:00
import sys
2024-08-04 05:01:54 +01:00
import time
2023-10-06 23:29:22 +01:00
import traceback
from collections import defaultdict
from datetime import date, datetime, timedelta
2023-10-02 20:35:30 +01:00
2023-10-06 23:29:22 +01:00
import flask
2024-01-23 10:49:58 +00:00
import UniAuth.auth
2023-10-06 23:29:22 +01:00
import werkzeug
import werkzeug.debug.tbtools
2023-11-20 19:48:42 +00:00
import yaml
2023-10-02 20:35:30 +01:00
2023-11-07 15:55:05 +00:00
import agenda.data
2023-12-07 17:15:58 +00:00
import agenda.error_mail
import agenda.fx
import agenda.holidays
import agenda.thespacedevs
2024-01-14 10:14:05 +00:00
import agenda.trip
2024-07-07 11:32:03 +01:00
import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip
2023-10-02 20:35:30 +01:00
2023-10-06 23:29:22 +01:00
app = flask.Flask(__name__)
2023-11-07 15:55:05 +00:00
app.debug = False
app.config.from_object("config.default")
2023-10-02 20:35:30 +01:00
2023-12-07 17:15:58 +00:00
agenda.error_mail.setup_error_mail(app)
2023-10-02 20:35:30 +01:00
2024-02-25 09:08:19 +00:00
@app.before_request
def handle_auth() -> None:
2024-07-01 18:50:08 +01:00
"""Handle authentication and set global user."""
2024-02-25 09:08:19 +00:00
flask.g.user = UniAuth.auth.get_current_user()
2023-10-06 23:29:22 +01:00
@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,
)
2024-08-11 11:50:58 +01:00
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
2023-10-02 20:35:30 +01:00
@app.route("/")
async def index() -> str:
2023-10-02 20:35:30 +01:00
"""Index page."""
2024-08-04 05:01:54 +01:00
t0 = time.time()
2023-10-02 20:35:30 +01:00
now = datetime.now()
data = await agenda.data.get_data(now, app.config)
2023-10-02 20:35:30 +01:00
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,
2024-08-11 11:50:58 +01:00
current_trip=get_current_trip(now.date()),
fullcalendar_events=calendar.build_events(events),
start_event_list=date.today() - timedelta(days=1),
end_event_list=date.today() + timedelta(days=365 * 2),
2024-08-04 05:01:54 +01:00
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."""
2024-08-07 06:26:42 +01:00
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(),
2024-08-07 06:26:42 +01:00
render_time=(time.time() - t0),
**data,
)
2023-10-02 20:35:30 +01:00
@app.route("/launches")
2024-01-21 15:56:18 +00:00
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")
2024-07-01 20:27:19 +01:00
launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
2024-08-05 13:03:37 +01:00
assert launches
2024-07-01 20:27:19 +01:00
mission_type_filter = flask.request.args.get("type")
rocket_filter = flask.request.args.get("rocket")
2024-08-05 13:03:37 +01:00
orbit_filter = flask.request.args.get("orbit")
2024-07-01 20:27:19 +01:00
mission_types = {
launch["mission"]["type"] for launch in launches if launch["mission"]
}
2024-08-05 13:03:37 +01:00
orbits = {
(launch["orbit"]["name"], launch["orbit"]["abbrev"])
for launch in launches
if launch.get("orbit")
}
2024-07-01 20:27:19 +01:00
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)
2024-08-05 13:03:37 +01:00
and (
not orbit_filter
or (launch.get("orbit") and launch["orbit"]["abbrev"] == orbit_filter)
)
2024-07-01 20:27:19 +01:00
]
2024-01-19 20:35:52 +00:00
return flask.render_template(
2024-07-01 20:27:19 +01:00
"launches.html",
launches=launches,
rockets=rockets,
now=now,
get_country=agenda.get_country,
mission_types=mission_types,
2024-08-05 13:03:37 +01:00
orbits=orbits,
2024-01-19 20:35:52 +00:00
)
2023-12-27 08:22:53 +00:00
@app.route("/gaps")
async def gaps_page() -> str:
"""List of available gaps."""
now = datetime.now()
trip_list = agenda.trip.build_trip_list()
2024-05-16 15:23:46 +01:00
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)
2023-12-27 08:22:53 +00:00
@app.route("/weekends")
async def weekends() -> str:
"""List of available gaps."""
now = datetime.now()
trip_list = agenda.trip.build_trip_list()
2024-05-16 15:23:46 +01:00
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list)
weekends = agenda.busy.weekends(busy_events)
return flask.render_template("weekends.html", today=now.date(), items=weekends)
@app.route("/travel")
2023-11-20 19:48:42 +00:00
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)
]
2024-04-05 14:58:44 +01:00
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),
)
2023-11-20 19:48:42 +00:00
def build_conference_list() -> list[StrDict]:
"""Build conference list."""
data_dir = app.config["PERSONAL_DATA"]
2023-11-20 19:48:42 +00:00
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
2024-04-08 08:11:22 +01:00
for conf in items:
2024-08-10 09:50:32 +01:00
conf["start_date"] = agenda.utils.as_date(conf["start"])
conf["end_date"] = agenda.utils.as_date(conf["end"])
2023-11-20 19:48:42 +00:00
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
2024-04-08 08:11:22 +01:00
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()
2023-11-20 19:48:42 +00:00
current = [
conf
2024-04-08 08:11:22 +01:00
for conf in items
if conf["start_date"] <= today and conf["end_date"] >= today
]
2024-04-08 08:11:22 +01:00
future = [conf for conf in items if conf["start_date"] > today]
2023-12-04 21:53:33 +00:00
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),
2023-12-04 21:53:33 +00:00
)
@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),
)
2024-01-01 21:26:39 +00:00
@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)
2024-01-01 21:26:39 +00:00
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]
2024-01-01 21:26:39 +00:00
return flask.render_template(
"accommodation.html",
past=past,
current=current,
future=future,
2024-01-01 21:26:39 +00:00
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),
)
2024-04-17 14:33:23 +01:00
def get_trip_list(
route_distances: agenda.travel.RouteDistances | None = None,
) -> list[Trip]:
"""Get list of trips respecting current authentication status."""
return [
2024-03-26 14:54:02 +00:00
trip
2024-04-05 19:17:19 +01:00
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
2024-03-26 14:54:02 +00:00
if flask.g.user.is_authenticated or not trip.private
]
2024-04-17 14:33:23 +01:00
2024-05-18 11:46:32 +01:00
@app.route("/trip/old")
def trip_old_list() -> str:
2024-04-17 14:33:23 +01:00
"""Page showing a list of trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
2024-04-17 14:33:23 +01:00
current = [
item
for item in trip_list
if item.start <= today and (item.end or item.start) >= today
]
past = [item for item in trip_list if (item.end or item.start) < today]
future = [item for item in trip_list if item.start > today]
2024-01-14 12:17:22 +00:00
future_coordinates, future_routes = agenda.trip.get_coordinates_and_routes(future)
past_coordinates, past_routes = agenda.trip.get_coordinates_and_routes(past)
return flask.render_template(
"trip_list.html",
current=current,
past=past,
future=future,
future_coordinates=future_coordinates,
future_routes=future_routes,
2024-01-14 12:17:22 +00:00
past_coordinates=past_coordinates,
past_routes=past_routes,
today=today,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
fx_rate=agenda.fx.get_rates(app.config),
2024-01-01 21:26:39 +00:00
)
2024-05-18 11:46:32 +01:00
@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:
dist = item.total_distance()
if dist:
total += dist
return total
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),
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()
2024-05-18 11:14:34 +01:00
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]
2024-05-18 11:42:41 +01:00
coordinates, routes = agenda.trip.get_coordinates_and_routes(current + future)
return flask.render_template(
"trip/list.html",
heading="Future trips",
2024-05-18 11:14:34 +01:00
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),
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."""
2024-04-17 14:33:23 +01:00
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,
)
2024-04-16 12:08:14 +01:00
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
for trip in trip_iter:
if trip.start.isoformat() == start:
break
prev_trip = trip
next_trip = next(trip_iter, None)
2024-04-16 12:08:14 +01:00
return (prev_trip, trip, next_trip)
@app.route("/trip/<start>")
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)
2024-04-16 12:08:14 +01:00
today = date.today()
2024-01-14 10:14:05 +00:00
coordinates = agenda.trip.collect_trip_coordinates(trip)
routes = agenda.trip.get_trip_routes(trip)
2024-04-16 12:08:14 +01:00
agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates)
for route in routes:
if "geojson_filename" in route:
2024-04-05 14:58:44 +01:00
route["geojson"] = agenda.trip.read_geojson(
app.config["PERSONAL_DATA"], route.pop("geojson_filename")
)
2024-01-12 15:04:08 +00:00
if trip.end:
countries = {c.alpha_2 for c in trip.countries}
holidays = [
hol
for hol in agenda.holidays.get_all(
trip.start, trip.end, app.config["DATA_DIR"]
)
if hol.country.upper() in countries
]
holidays.sort(key=lambda item: (item.date, item.country))
else:
holidays = []
return flask.render_template(
"trip_page.html",
trip=trip,
prev_trip=prev_trip,
next_trip=next_trip,
today=today,
coordinates=coordinates,
routes=routes,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
holidays=holidays,
2024-07-07 19:20:54 +01:00
human_readable_delta=agenda.utils.human_readable_delta,
)
@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
)
2024-06-03 19:30:14 +01:00
@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)
# today = date.today()
# past = [item for item in trip_list if (item.end or item.start) < today]
conferences = sum(len(item.conferences) for item in trip_list)
yearly_stats = agenda.trip.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,
)
2024-02-18 22:07:38 +00:00
@app.route("/callback")
def auth_callback() -> tuple[str, int] | werkzeug.Response:
"""Process the authentication callback."""
return UniAuth.auth.auth_callback()
2024-02-25 09:08:19 +00:00
@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"])
2023-10-02 20:35:30 +01:00
if __name__ == "__main__":
app.run(host="0.0.0.0")