agenda/web_view.py

864 lines
27 KiB
Python
Executable file

#!/usr/bin/python3
"""Web page to show upcoming events."""
import inspect
import os.path
import sys
import time
import traceback
import typing
from datetime import date
from datetime import datetime, timedelta, timezone
import flask
import werkzeug
import werkzeug.debug.tbtools
from authlib.integrations.flask_client import OAuth
from werkzeug.middleware.proxy_fix import ProxyFix
import agenda.accommodation
import agenda.conference
import agenda.conference_ical
import agenda.conference_list
import agenda.data
import agenda.error_mail
import agenda.fx
import agenda.holidays
import agenda.meteors
import agenda.stats
import agenda.thespacedevs
import agenda.trip
import agenda.trip_debug
import agenda.trip_schengen
import agenda.trip_timezones
import agenda.uk_school_holiday
import agenda.utils
import agenda.weather
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")
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
agenda.error_mail.setup_error_mail(app)
oauth = OAuth(app) # type: ignore[no-untyped-call]
authentik_url = app.config["AUTHENTIK_URL"]
oauth.register( # type: ignore[no-untyped-call]
name="authentik",
client_id=app.config["AUTHENTIK_CLIENT_ID"],
client_secret=app.config["AUTHENTIK_CLIENT_SECRET"],
server_metadata_url=f"{authentik_url}/application/o/agenda/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
class User:
"""Simple user object for template compatibility."""
def __init__(self, is_authenticated: bool) -> None:
"""Init."""
self.is_authenticated = is_authenticated
@app.before_request
def handle_auth() -> None:
"""Set global user from session."""
flask.g.user = User(is_authenticated=bool(flask.session.get("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)
assert tb._te.exc_type
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,
)
@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=agenda.trip.get_current_trip(today),
start_event_list=date.today() - timedelta(days=1),
end_event_list=date.today() + timedelta(days=365 * 2),
render_time=(time.time() - t0),
home_weather=get_home_weather(),
**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"])
# Use the new function to build events and pass both calendars and events
return flask.render_template(
"calendar.html",
today=now.date(),
events=events,
toastui_events=calendar.build_toastui_events(events),
toastui_calendars=calendar.toastui_calendars,
**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,
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(timezone.utc)
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
active_crewed = agenda.thespacedevs.get_active_crewed_flights(rocket_dir) or []
active_crewed_slugs = {
launch["slug"]
for launch in active_crewed
if isinstance(launch.get("slug"), str)
}
for launch in launches:
launch_net = agenda.thespacedevs.parse_api_datetime(launch.get("net"))
launch["is_future"] = bool(launch_net and launch_net > now)
launch["is_active_crewed"] = launch.get("slug") in active_crewed_slugs
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 = agenda.meteors.get_meteor_data()
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()
min_year = 2020
max_year = today.year + 5
def validate_year(year: int) -> None:
"""Validate year parameter range for weekends page."""
if year < min_year or year > max_year:
flask.abort(
400, description=f"Year must be between {min_year} and {max_year}."
)
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()
validate_year(start.year)
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
validate_year(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."
)
elif year_str:
try:
year = int(year_str)
validate_year(year)
start = date(year, 1, 1)
except ValueError:
return flask.abort(400, description="Invalid year format. Use an integer.")
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"]
)
home_weather = get_home_weather()
home_weather_by_date = {f["date"]: f for f in home_weather}
for weekend in weekends:
saturday = weekend["date"]
sunday = saturday + timedelta(days=1)
weekend["saturday_weather"] = home_weather_by_date.get(saturday.isoformat())
weekend["sunday_weather"] = home_weather_by_date.get(sunday.isoformat())
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),
)
@app.route("/conference")
def conference_list() -> str:
"""Page showing a list of conferences."""
today = date.today()
items = agenda.conference_list.build_conference_list(
app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
)
country_filter = agenda.conference_list.normalize_country_filter(
flask.request.args.get("country")
)
country_options = agenda.conference_list.conference_country_options(
[conf for conf in items if conf["latest_date"] >= today]
)
items = agenda.conference_list.filter_conferences_by_country(items, country_filter)
current = [
conf
for conf in items
if conf["has_exact_dates"]
and conf["start_date"] <= today
and conf["end_date"] >= today
]
future = [
conf for conf in items if conf not in current and conf["latest_date"] >= today
]
timeline = agenda.conference_list.build_conference_timeline(current, future, today)
return flask.render_template(
"conference_list.html",
current=current,
future=future,
timeline=timeline,
today=today,
get_country=agenda.get_country,
selected_country=country_filter,
country_options=country_options,
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()
items = agenda.conference_list.build_conference_list(
app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
)
country_filter = agenda.conference_list.normalize_country_filter(
flask.request.args.get("country")
)
country_options = agenda.conference_list.conference_country_options(
[conf for conf in items if conf["latest_date"] < today]
)
items = agenda.conference_list.filter_conferences_by_country(items, country_filter)
return flask.render_template(
"conference_list.html",
past=[conf for conf in items if conf["latest_date"] < today],
today=today,
get_country=agenda.get_country,
selected_country=country_filter,
country_options=country_options,
fx_rate=agenda.fx.get_rates(app.config),
)
@app.route("/conference/series")
def conference_series_list() -> str:
"""Page showing conference series."""
return flask.render_template(
"conference_series_list.html",
series_list=agenda.conference_list.build_conference_series_list(
agenda.conference.load_series(app.config["PERSONAL_DATA"]),
agenda.conference_list.build_conference_list(
app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
),
date.today(),
),
get_country=agenda.get_country,
)
@app.route("/conference/series/<series_id>")
def conference_series_page(series_id: str) -> str:
"""Page showing one conference series."""
series_lookup = agenda.conference.load_series(app.config["PERSONAL_DATA"])
series = series_lookup.get(series_id)
if series is None:
flask.abort(404)
conferences = [
conf
for conf in agenda.conference_list.build_conference_list(
app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
)
if conf.get("series") == series_id
]
return flask.render_template(
"conference_series.html",
series_id=series_id,
series=series,
conferences=conferences,
today=date.today(),
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
)
@app.route("/conference/ical")
def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed."""
items = agenda.conference_list.build_conference_list(
app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
)
ical_data = agenda.conference_ical.build_conference_ical(items)
response = flask.Response(ical_data, mimetype="text/calendar")
response.headers["Content-Disposition"] = "inline; filename=conferences.ics"
return response
@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)
context = agenda.accommodation.prepare_accommodation_list(
items, agenda.trip.build_trip_list(), uk_tz.localize(datetime.now())
)
return flask.render_template(
"accommodation.html",
**context,
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
)
@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"))
@app.route("/trip/ical")
def trip_ical() -> werkzeug.Response:
"""Return trip components in iCal format."""
trips = get_trip_list()
ical_data = agenda.trip.build_trip_ical(trips)
response = flask.Response(ical_data, mimetype="text/calendar")
response.headers["Content-Disposition"] = "inline; filename=trips.ics"
return response
def get_home_weather() -> list[StrDict]:
"""Get Bristol home weather forecast, with date objects added for templates."""
forecasts = agenda.weather.get_forecast(
app.config["DATA_DIR"],
app.config.get("OPENWEATHERMAP_API_KEY", ""),
app.config["HOME_LATITUDE"],
app.config["HOME_LONGITUDE"],
cache_only=True,
)
for f in forecasts:
f["date_obj"] = date.fromisoformat(f["date"])
return forecasts
def get_trip_list() -> list[Trip]:
"""Get trip list with route distances."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
return agenda.trip.get_trip_list(route_distances)
@app.route("/trip/past")
def trip_past_list() -> str:
"""Page showing a list of past trips."""
today = date.today()
past = [item for item in get_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),
trip_school_holiday_map=agenda.holidays.trip_school_holiday_map(
past, app.config["DATA_DIR"]
),
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=agenda.stats.calc_total_distance(past),
total_co2_kg=agenda.stats.calc_total_co2_kg(past),
distances_by_transport_type=agenda.stats.sum_distances_by_transport_type(past),
co2_by_transport_type=agenda.stats.sum_co2_by_transport_type(past),
)
@app.route("/fixture/map")
def fixture_trip_map() -> str:
"""Map of trips."""
trips = get_trip_list()
coordinates, routes = agenda.trip.get_coordinates_and_routes(trips)
return flask.render_template(
"fixture/map.html",
heading="Trip map",
trips=trips,
coordinates=coordinates,
routes=routes,
get_country=agenda.get_country,
)
@app.route("/trip/future")
def trip_future_list() -> str:
"""Page showing a list of future trips."""
trip_list = get_trip_list()
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)
shown = current + future
trip_weather_map = {
trip.start.isoformat(): agenda.weather.get_trip_weather(
app.config["DATA_DIR"],
app.config.get("OPENWEATHERMAP_API_KEY", ""),
trip,
cache_only=True,
)
for trip in shown
}
return flask.render_template(
"trip/list.html",
heading="Future trips",
trips=shown,
trip_school_holiday_map=agenda.holidays.trip_school_holiday_map(
shown, app.config["DATA_DIR"]
),
trip_weather_map=trip_weather_map,
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=agenda.stats.calc_total_distance(current + future),
total_co2_kg=agenda.stats.calc_total_co2_kg(current + future),
distances_by_transport_type=agenda.stats.sum_distances_by_transport_type(
current + future
),
co2_by_transport_type=agenda.stats.sum_co2_by_transport_type(current + future),
)
@app.route("/trip/text")
def trip_list_text() -> str:
"""Page showing a list of trips."""
trip_list = agenda.trip.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/<start>")
def trip_page(start: str) -> str:
"""Individual trip page."""
trip_list = get_trip_list()
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, app.config["PERSONAL_DATA"]
)
agenda.trip.add_coordinates_for_eurostar_home_feeder(
trip, coordinates, app.config["PERSONAL_DATA"]
)
for route in routes:
if "geojson_filename" in route:
route["geojson"] = agenda.trip.read_geojson(
app.config["PERSONAL_DATA"], route.pop("geojson_filename")
)
trip_weather = agenda.weather.get_trip_weather(
app.config["DATA_DIR"],
app.config.get("OPENWEATHERMAP_API_KEY", ""),
trip,
cache_only=True,
)
agenda.trip_timezones.localize_trip_car_journey_display_times(
trip, app.config["PERSONAL_DATA"]
)
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),
school_holidays=agenda.holidays.get_trip_school_holidays(trip),
destination_times=agenda.trip_timezones.get_destination_timezones(trip),
human_readable_delta=agenda.utils.human_readable_delta,
trip_weather=trip_weather,
conference_free_days=agenda.trip.conference_free_days(trip),
)
@app.route("/trip/<start>/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)
trip_list = get_trip_list()
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)
trip_json, trip_yaml = agenda.trip_debug.serialize_trip(trip)
return flask.render_template(
"trip_debug.html",
trip=trip,
trip_json=trip_json,
trip_yaml=trip_yaml,
start=start,
)
@app.route("/holidays")
def holiday_list() -> str:
"""List of holidays."""
today = date.today()
data_dir = app.config["DATA_DIR"]
year_start = date(today.year, 1, 1)
next_year = today + timedelta(days=1 * 365)
items = agenda.holidays.get_all(today - timedelta(days=2), next_year, data_dir)
school_holidays = agenda.holidays.get_school_holidays(
year_start, next_year, data_dir
)
items.sort(key=lambda item: (item.date, item.country))
school_holidays.sort(key=lambda item: (item.as_date, item.end_as_date))
return flask.render_template(
"holiday_list.html",
items=items,
school_holidays=school_holidays,
school_holiday_page_url=agenda.uk_school_holiday.school_holiday_page_url,
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."""
trip_list = get_trip_list()
conferences = sum(len(item.conferences) for item in trip_list)
yearly_stats = agenda.stats.calculate_yearly_stats(
trip_list, app.config.get("PREVIOUSLY_VISITED")
)
overall_stats = agenda.stats.calculate_overall_stats(yearly_stats)
return flask.render_template(
"trip/stats.html",
count=len(trip_list),
total_distance=agenda.stats.calc_total_distance(trip_list),
total_co2_kg=agenda.stats.calc_total_co2_kg(trip_list),
distances_by_transport_type=agenda.stats.sum_distances_by_transport_type(
trip_list
),
co2_by_transport_type=agenda.stats.sum_co2_by_transport_type(trip_list),
yearly_stats=yearly_stats,
overall_stats=overall_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("/login")
def login() -> werkzeug.Response:
"""Redirect to Authentik for OIDC login."""
next_url = flask.request.args.get("next", flask.url_for("index"))
flask.session["login_next"] = next_url
redirect_uri = flask.url_for("auth_callback", _external=True)
return typing.cast(
werkzeug.Response, oauth.authentik.authorize_redirect(redirect_uri)
)
@app.route("/callback")
def auth_callback() -> werkzeug.Response:
"""Handle OIDC callback from Authentik."""
try:
token = oauth.authentik.authorize_access_token()
except Exception:
return flask.redirect(flask.url_for("login"))
userinfo = token.get("userinfo") or oauth.authentik.userinfo()
username = userinfo.get("preferred_username") or userinfo.get("email")
flask.session["user"] = {
"sub": userinfo["sub"],
"username": userinfo.get("preferred_username"),
"email": userinfo.get("email"),
}
flask.flash(f"Logged in as {username}")
next_url = flask.session.pop("login_next", flask.url_for("index"))
return flask.redirect(next_url)
@app.route("/logout")
def logout() -> werkzeug.Response:
"""Log out and redirect to Authentik end-session endpoint."""
flask.session.pop("user", None)
end_session_url = f"{authentik_url}/application/o/agenda/end-session/"
return flask.redirect(end_session_url)
if __name__ == "__main__":
app.run(host="0.0.0.0")