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
import pycountry
import pytz
uk_tz = pytz.timezone("Europe/London")
@ -10,3 +11,23 @@ uk_tz = pytz.timezone("Europe/London")
def uk_time(d: date, t: time) -> datetime:
"""Combine time and date for UK timezone."""
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
start: date | datetime
end: date | datetime
trip: date | None = None
country: str | None = None
venue: str | None = None
address: str | None = None

View file

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

View file

@ -2,6 +2,53 @@
import dataclasses
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

View file

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros.html" import accommodation_row with context %}
{% block style %}
{% set column_count = 7 %}
<style>
@ -19,36 +20,10 @@
</style>
{% 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) %}
{% if item_list %}
<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 %}
{% endmacro %}

View file

@ -1,5 +1,7 @@
{% extends "base.html" %}
{% from "macros.html" import conference_row with context %}
{% block style %}
{% set column_count = 6 %}
<style>
@ -20,56 +22,17 @@
</style>
{% 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) %}
{% if item_list %}
<div class="heading"><h2>{{heading}}</h2></div>
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
{% endif %}
{% if item_list %}
<div class="heading"><h2>{{ heading }}</h2></div>
{% for item in item_list %}{{ conference_row(item, badge) }}{% endfor %}
{% endif %}
{% endmacro %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Conferences</h1>
<div class="grid-container">
{{ section("Current", current, "attending") }}
{{ 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 = [
{"endpoint": "index", "label": "Home" },
{"endpoint": "trip_list", "label": "Trips" },
{"endpoint": "conference_list", "label": "Conference" },
{"endpoint": "travel_list", "label": "Travel" },
{"endpoint": "accommodation_list", "label": "Accommodation" },

View file

@ -1,10 +1,5 @@
{% extends "base.html" %}
{% 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 %}
{% from "macros.html" import flight_row, train_row with context %}
{% block style %}
<style>
@ -17,7 +12,7 @@
.train-grid-container {
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;
justify-content: start;
}
@ -45,19 +40,8 @@
<div class="grid-item">flight</div>
<div class="grid-item">reference</div>
{% for item in flights | sort(attribute="depart") if item.arrive %}
<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>
{% for item in flights | sort(attribute="depart") %}
{{ flight_row(item) }}
{% endfor %}
</div>
@ -68,21 +52,12 @@
<div class="grid-item">route</div>
<div class="grid-item">depart</div>
<div class="grid-item">arrive</div>
<div class="grid-item">duration</div>
<div class="grid-item">operator</div>
<div class="grid-item">reference</div>
{% for item in trains | sort(attribute="depart") if item.arrive %}
<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.operator }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% for item in trains | sort(attribute="depart") %}
{{ train_row(item) }}
{% endfor %}
</div>

View file

@ -7,11 +7,9 @@ import operator
import os.path
import sys
import traceback
import typing
from datetime import date, datetime
import flask
import pycountry
import werkzeug
import werkzeug.debug.tbtools
import yaml
@ -19,7 +17,8 @@ import yaml
import agenda.data
import agenda.error_mail
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.debug = False
@ -87,8 +86,8 @@ async def gaps_page() -> str:
def travel_list() -> str:
"""Page showing a list of upcoming travel."""
data_dir = app.config["PERSONAL_DATA"]
flights = agenda.travel.parse_yaml("flights", data_dir)
trains = agenda.travel.parse_yaml("trains", data_dir)
flights = travel.parse_yaml("flights", data_dir)
trains = travel.parse_yaml("trains", data_dir)
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
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")
def conference_list() -> str:
"""Page showing a list of conferences."""
@ -133,7 +125,7 @@ def conference_list() -> str:
past=past,
future=future,
today=today,
get_country=get_country,
get_country=agenda.get_country,
)
@ -141,7 +133,7 @@ def conference_list() -> str:
def accommodation_list() -> str:
"""Page showing a list of past, present and future accommodation."""
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]
total_nights_2024 = sum(
@ -159,7 +151,52 @@ def accommodation_list() -> str:
items=items,
total_nights_2024=total_nights_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,
)