Add trips page

Creating a new entity called a trip. This will group together any travel
accommodation and conferences that happen together on one trip.

A trip is assumed to start when leaving home and finish when returning
home.

The start date of a trip in is the trip ID. The date is written in ISO
format.

This assumes there cannot be multiple trips one one day. This assumption
might be wrong, for example a morning day trip by rail, then another
trip starts in the afternoon. I can change my choice of using dates as
trip IDs if that happens.

Sometimes during the planning of a trip the start date is unknown. For
now we make up a start date, we can always change it later. If we use
the start date in URLs then the URLs will change. Might need to keep a
file of redirects, or could think of a different style of identifier.

Trip ID have been added to accommodation, conferences, trains and
flights.

Later there will be a trips.yaml with notes about each trip.
This commit is contained in:
Edward Betts 2024-01-04 22:55:19 +00:00
parent 5786e3d575
commit ce9faa654f
10 changed files with 234 additions and 120 deletions

View file

@ -2,6 +2,7 @@
from datetime import date, datetime, time from datetime import date, datetime, time
import pycountry
import pytz import pytz
uk_tz = pytz.timezone("Europe/London") uk_tz = pytz.timezone("Europe/London")
@ -10,3 +11,23 @@ uk_tz = pytz.timezone("Europe/London")
def uk_time(d: date, t: time) -> datetime: def uk_time(d: date, t: time) -> datetime:
"""Combine time and date for UK timezone.""" """Combine time and date for UK timezone."""
return uk_tz.localize(datetime.combine(d, t)) return uk_tz.localize(datetime.combine(d, t))
def format_list_with_ampersand(items: list[str]) -> str:
"""Join a list of strings with commas and an ampersand."""
if len(items) > 1:
return ", ".join(items[:-1]) + " & " + items[-1]
elif items:
return items[0]
return ""
def get_country(alpha_2: str) -> pycountry.db.Country | None:
"""Lookup country by alpha-2 country code."""
if not alpha_2:
return None
if alpha_2 == "xk":
return pycountry.db.Country(flag="\U0001F1FD\U0001F1F0", name="Kosovo")
country: pycountry.db.Country = pycountry.countries.get(alpha_2=alpha_2.upper())
return country

View file

@ -18,6 +18,7 @@ class Conference:
location: str location: str
start: date | datetime start: date | datetime
end: date | datetime end: date | datetime
trip: date | None = None
country: str | None = None country: str | None = None
venue: str | None = None venue: str | None = None
address: str | None = None address: str | None = None

View file

@ -36,9 +36,7 @@ from . import (
uk_tz, uk_tz,
waste_schedule, waste_schedule,
) )
from .types import Event, Holiday from .types import Event, Holiday, StrDict
StrDict = dict[str, typing.Any]
here = dateutil.tz.tzlocal() here = dateutil.tz.tzlocal()

View file

@ -2,6 +2,53 @@
import dataclasses import dataclasses
import datetime import datetime
import typing
from pycountry.db import Country
import agenda
from agenda import format_list_with_ampersand
StrDict = dict[str, typing.Any]
@dataclasses.dataclass
class Trip:
"""Trip."""
date: datetime.date
travel: list[StrDict] = dataclasses.field(default_factory=list)
accommodation: list[StrDict] = dataclasses.field(default_factory=list)
conferences: list[StrDict] = dataclasses.field(default_factory=list)
@property
def title(self) -> str:
"""Trip title."""
names = (
format_list_with_ampersand([conf["name"] for conf in self.conferences])
or "[no conferences in trip]"
)
return f'{names} ({self.date.strftime("%b %Y")})'
@property
def countries(self) -> set[Country]:
"""Trip countries."""
found: set[Country] = set()
for item in self.conferences + self.accommodation:
if "country" not in item:
continue
country = agenda.get_country(item["country"])
assert country
found.add(country)
return found
@property
def countries_str(self) -> str:
"""List of countries visited on this trip."""
return format_list_with_ampersand(
[f"{c.flag} {c.name}" for c in self.countries]
)
@dataclasses.dataclass @dataclasses.dataclass

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import accommodation_row with context %}
{% block style %} {% block style %}
{% set column_count = 7 %} {% set column_count = 7 %}
<style> <style>
@ -19,36 +20,10 @@
</style> </style>
{% endblock %} {% endblock %}
{% macro row(item, badge) %}
{% set country = get_country(item.country) %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="grid-item text-end">{{ item.from.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.to.strftime("%a, %d %b") }}</div>
<div class="grid-item text-end">{% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
<div class="grid-item">
{% if item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</div>
{% endmacro %}
{% macro section(heading, item_list, badge) %} {% macro section(heading, item_list, badge) %}
{% if item_list %} {% if item_list %}
<div class="heading"><h2>{{heading}}</h2></div> <div class="heading"><h2>{{heading}}</h2></div>
{% for item in item_list %}{{ row(item, badge) }}{% endfor %} {% for item in item_list %}{{ accommodation_row(item, badge) }}{% endfor %}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View file

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import conference_row with context %}
{% block style %} {% block style %}
{% set column_count = 6 %} {% set column_count = 6 %}
<style> <style>
@ -20,56 +22,17 @@
</style> </style>
{% endblock %} {% endblock %}
{% macro row(item, badge) %}
{% set country = get_country(item.country) if item.country else None %}
<div class="grid-item text-end">{{ item.start.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.end.strftime("%a, %d %b") }}</div>
<div class="grid-item">
{% if item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
{% if item.going and not (item.accommodation_booked or item.travel_booked) %}
<span class="badge text-bg-primary">
{{ badge }}
</span>
{% endif %}
{% if item.accommodation_booked %}
<span class="badge text-bg-success">accommodation</span>
{% endif %}
{% if item.transport_booked %}
<span class="badge text-bg-success">transport</span>
{% endif %}
</div>
<div class="grid-item">{{ item.topic }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
{% endmacro %}
{% macro section(heading, item_list, badge) %} {% macro section(heading, item_list, badge) %}
{% if item_list %} {% if item_list %}
<div class="heading"><h2>{{heading}}</h2></div> <div class="heading"><h2>{{ heading }}</h2></div>
{% for item in item_list %}{{ row(item, badge) }}{% endfor %} {% for item in item_list %}{{ conference_row(item, badge) }}{% endfor %}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
<div class="container-fluid mt-2"> <div class="container-fluid mt-2">
<h1>Conferences</h1> <h1>Conferences</h1>
<div class="grid-container"> <div class="grid-container">
{{ section("Current", current, "attending") }} {{ section("Current", current, "attending") }}
{{ section("Future", future, "going") }} {{ section("Future", future, "going") }}

96
templates/macros.html Normal file
View file

@ -0,0 +1,96 @@
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
{% macro conference_row(item, badge) %}
{% set country = get_country(item.country) if item.country else None %}
<div class="grid-item text-end">{{ item.start.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.end.strftime("%a, %d %b") }}</div>
<div class="grid-item">
{% if item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
{% if item.going and not (item.accommodation_booked or item.travel_booked) %}
<span class="badge text-bg-primary">
{{ badge }}
</span>
{% endif %}
{% if item.accommodation_booked %}
<span class="badge text-bg-success">accommodation</span>
{% endif %}
{% if item.transport_booked %}
<span class="badge text-bg-success">transport</span>
{% endif %}
</div>
<div class="grid-item">{{ item.topic }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
{% endmacro %}
{% macro accommodation_row(item, badge) %}
{% set country = get_country(item.country) %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="grid-item text-end">{{ item.from.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.to.strftime("%a, %d %b") }}</div>
<div class="grid-item text-end">{% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
<div class="grid-item">
{% if item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</div>
{% endmacro %}
{% macro flight_row(item) %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.duration }}</div>
<div class="grid-item">{{ item.airline }}{{ item.flight_number }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% endmacro %}
{% macro train_row(item) %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% endmacro %}

View file

@ -2,6 +2,7 @@
{% set pages = [ {% set pages = [
{"endpoint": "index", "label": "Home" }, {"endpoint": "index", "label": "Home" },
{"endpoint": "trip_list", "label": "Trips" },
{"endpoint": "conference_list", "label": "Conference" }, {"endpoint": "conference_list", "label": "Conference" },
{"endpoint": "travel_list", "label": "Travel" }, {"endpoint": "travel_list", "label": "Travel" },
{"endpoint": "accommodation_list", "label": "Accommodation" }, {"endpoint": "accommodation_list", "label": "Accommodation" },

View file

@ -1,10 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import flight_row, train_row with context %}
{% block travel %}
{% endblock %}
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
{% block style %} {% block style %}
<style> <style>
@ -17,7 +12,7 @@
.train-grid-container { .train-grid-container {
display: grid; display: grid;
grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */ grid-template-columns: repeat(7, auto); /* 7 columns for each piece of information */
gap: 10px; gap: 10px;
justify-content: start; justify-content: start;
} }
@ -45,19 +40,8 @@
<div class="grid-item">flight</div> <div class="grid-item">flight</div>
<div class="grid-item">reference</div> <div class="grid-item">reference</div>
{% for item in flights | sort(attribute="depart") if item.arrive %} {% for item in flights | sort(attribute="depart") %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div> {{ flight_row(item) }}
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.duration }}</div>
<div class="grid-item">{{ item.airline }}{{ item.flight_number }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% endfor %} {% endfor %}
</div> </div>
@ -68,21 +52,12 @@
<div class="grid-item">route</div> <div class="grid-item">route</div>
<div class="grid-item">depart</div> <div class="grid-item">depart</div>
<div class="grid-item">arrive</div> <div class="grid-item">arrive</div>
<div class="grid-item">duration</div>
<div class="grid-item">operator</div> <div class="grid-item">operator</div>
<div class="grid-item">reference</div> <div class="grid-item">reference</div>
{% for item in trains | sort(attribute="depart") if item.arrive %} {% for item in trains | sort(attribute="depart") %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div> {{ train_row(item) }}
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -7,11 +7,9 @@ import operator
import os.path import os.path
import sys import sys
import traceback import traceback
import typing
from datetime import date, datetime from datetime import date, datetime
import flask import flask
import pycountry
import werkzeug import werkzeug
import werkzeug.debug.tbtools import werkzeug.debug.tbtools
import yaml import yaml
@ -19,7 +17,8 @@ import yaml
import agenda.data import agenda.data
import agenda.error_mail import agenda.error_mail
import agenda.thespacedevs import agenda.thespacedevs
import agenda.travel from agenda import format_list_with_ampersand, travel
from agenda.types import StrDict, Trip
app = flask.Flask(__name__) app = flask.Flask(__name__)
app.debug = False app.debug = False
@ -87,8 +86,8 @@ async def gaps_page() -> str:
def travel_list() -> str: def travel_list() -> str:
"""Page showing a list of upcoming travel.""" """Page showing a list of upcoming travel."""
data_dir = app.config["PERSONAL_DATA"] data_dir = app.config["PERSONAL_DATA"]
flights = agenda.travel.parse_yaml("flights", data_dir) flights = travel.parse_yaml("flights", data_dir)
trains = agenda.travel.parse_yaml("trains", data_dir) trains = travel.parse_yaml("trains", data_dir)
return flask.render_template("travel.html", flights=flights, trains=trains) return flask.render_template("travel.html", flights=flights, trains=trains)
@ -98,13 +97,6 @@ def as_date(d: date | datetime) -> date:
return d.date() if isinstance(d, datetime) else d return d.date() if isinstance(d, datetime) else d
def get_country(alpha_2: str) -> str | None:
"""Lookup country by alpha-2 country code."""
if not alpha_2:
return None
return typing.cast(str, pycountry.countries.get(alpha_2=alpha_2.upper()))
@app.route("/conference") @app.route("/conference")
def conference_list() -> str: def conference_list() -> str:
"""Page showing a list of conferences.""" """Page showing a list of conferences."""
@ -133,7 +125,7 @@ def conference_list() -> str:
past=past, past=past,
future=future, future=future,
today=today, today=today,
get_country=get_country, get_country=agenda.get_country,
) )
@ -141,7 +133,7 @@ def conference_list() -> str:
def accommodation_list() -> str: def accommodation_list() -> str:
"""Page showing a list of past, present and future accommodation.""" """Page showing a list of past, present and future accommodation."""
data_dir = app.config["PERSONAL_DATA"] data_dir = app.config["PERSONAL_DATA"]
items = agenda.travel.parse_yaml("accommodation", data_dir) items = travel.parse_yaml("accommodation", data_dir)
stays_in_2024 = [item for item in items if item["from"].year == 2024] stays_in_2024 = [item for item in items if item["from"].year == 2024]
total_nights_2024 = sum( total_nights_2024 = sum(
@ -159,7 +151,52 @@ def accommodation_list() -> str:
items=items, items=items,
total_nights_2024=total_nights_2024, total_nights_2024=total_nights_2024,
nights_abroad_2024=nights_abroad_2024, nights_abroad_2024=nights_abroad_2024,
get_country=get_country, get_country=agenda.get_country,
)
def load_travel(travel_type: str) -> list[StrDict]:
"""Read flight and train journeys."""
data_dir = app.config["PERSONAL_DATA"]
items = travel.parse_yaml(travel_type + "s", data_dir)
for item in items:
item["type"] = travel_type
return items
@app.route("/trip")
def trip_list() -> str:
"""Page showing a list of trips."""
trips: dict[date, Trip] = {}
data_dir = app.config["PERSONAL_DATA"]
travel_items = sorted(
load_travel("flight") + load_travel("train"), key=operator.itemgetter("depart")
)
data = {
"travel": travel_items,
"accommodation": travel.parse_yaml("accommodation", data_dir),
"conferences": travel.parse_yaml("conferences", data_dir),
}
for key, item_list in data.items():
assert isinstance(item_list, list)
for item in item_list:
if not (trip_id := item.get("trip")):
continue
if trip_id not in trips:
trips[trip_id] = Trip(date=trip_id)
getattr(trips[trip_id], key).append(item)
trip_list = [trip for _, trip in sorted(trips.items(), reverse=True)]
return flask.render_template(
"trips.html",
trips=trip_list,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
) )