497 lines
16 KiB
Python
497 lines
16 KiB
Python
"""Types."""
|
|
|
|
import collections
|
|
import datetime
|
|
import typing
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
|
|
import emoji
|
|
from pycountry.db import Country
|
|
|
|
import agenda
|
|
from agenda import format_list_with_ampersand
|
|
|
|
from . import utils
|
|
|
|
StrDict = dict[str, typing.Any]
|
|
DateOrDateTime = datetime.datetime | datetime.date
|
|
|
|
|
|
@dataclass
|
|
class TripElement:
|
|
"""Trip element."""
|
|
|
|
start_time: DateOrDateTime
|
|
title: str
|
|
element_type: str
|
|
detail: StrDict
|
|
end_time: DateOrDateTime | None = None
|
|
start_loc: str | None = None
|
|
end_loc: str | None = None
|
|
start_country: Country | None = None
|
|
end_country: Country | None = None
|
|
|
|
def get_emoji(self) -> str | None:
|
|
"""Emoji for trip element."""
|
|
emoji_map = {
|
|
"check-in": ":hotel:",
|
|
"check-out": ":hotel:",
|
|
"train": ":train:",
|
|
"flight": ":airplane:",
|
|
"ferry": ":ferry:",
|
|
}
|
|
|
|
alias = emoji_map.get(self.element_type)
|
|
return emoji.emojize(alias, language="alias") if alias else None
|
|
|
|
|
|
def airport_label(airport: StrDict) -> str:
|
|
"""Airport label: name and iata."""
|
|
name = airport.get("alt_name") or airport["city"]
|
|
return f"{name} ({airport['iata']})"
|
|
|
|
|
|
@dataclass
|
|
class Trip:
|
|
"""Trip."""
|
|
|
|
start: datetime.date
|
|
travel: list[StrDict] = field(default_factory=list)
|
|
accommodation: list[StrDict] = field(default_factory=list)
|
|
conferences: list[StrDict] = field(default_factory=list)
|
|
events: list[StrDict] = field(default_factory=list)
|
|
flight_bookings: list[StrDict] = field(default_factory=list)
|
|
name: str | None = None
|
|
private: bool = False
|
|
|
|
@property
|
|
def title(self) -> str:
|
|
"""Trip title."""
|
|
if self.name:
|
|
return self.name
|
|
titles: list[str] = [conf["name"] for conf in self.conferences] + [
|
|
event["title"] for event in self.events
|
|
]
|
|
if not titles:
|
|
for travel in self.travel:
|
|
if travel["depart"] and travel["depart"].date() != self.start:
|
|
place = travel["from"]
|
|
if place not in titles:
|
|
titles.append(place)
|
|
if travel["depart"] and travel["depart"].date() != self.end:
|
|
place = travel["to"]
|
|
if place not in titles:
|
|
titles.append(place)
|
|
|
|
return format_list_with_ampersand(titles) or "[unnamed trip]"
|
|
|
|
@property
|
|
def end(self) -> datetime.date | None:
|
|
"""End date for trip."""
|
|
max_conference_end = (
|
|
max(utils.as_date(item["end"]) for item in self.conferences)
|
|
if self.conferences
|
|
else datetime.date.min
|
|
)
|
|
assert isinstance(max_conference_end, datetime.date)
|
|
|
|
arrive = [
|
|
utils.as_date(item["arrive"]) for item in self.travel if "arrive" in item
|
|
]
|
|
travel_end = max(arrive) if arrive else datetime.date.min
|
|
assert isinstance(travel_end, datetime.date)
|
|
|
|
accommodation_end = (
|
|
max(utils.as_date(item["to"]) for item in self.accommodation)
|
|
if self.accommodation
|
|
else datetime.date.min
|
|
)
|
|
assert isinstance(accommodation_end, datetime.date)
|
|
|
|
max_date = max(max_conference_end, travel_end, accommodation_end)
|
|
return max_date if max_date != datetime.date.min else None
|
|
|
|
def locations(self) -> list[tuple[str, Country]]:
|
|
"""Locations for trip."""
|
|
seen: set[tuple[str, str]] = set()
|
|
items = []
|
|
|
|
for item in self.conferences + self.accommodation + self.events:
|
|
if "country" not in item or "location" not in item:
|
|
continue
|
|
key = (item["location"], item["country"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
country = agenda.get_country(item["country"])
|
|
assert country
|
|
items.append((item["location"], country))
|
|
|
|
return items
|
|
|
|
@property
|
|
def countries(self) -> list[Country]:
|
|
"""Countries visited as part of trip, in order."""
|
|
seen: set[str] = set()
|
|
items: list[Country] = []
|
|
for item in self.conferences + self.accommodation + self.events:
|
|
if "country" not in item:
|
|
continue
|
|
if item["country"] in seen:
|
|
continue
|
|
seen.add(item["country"])
|
|
country = agenda.get_country(item["country"])
|
|
assert country
|
|
items.append(country)
|
|
|
|
for item in self.travel:
|
|
travel_countries = set()
|
|
if item["type"] == "flight":
|
|
for key in "from_airport", "to_airport":
|
|
c = item[key]["country"]
|
|
travel_countries.add(c)
|
|
if item["type"] == "train":
|
|
for leg in item["legs"]:
|
|
for key in "from_station", "to_station":
|
|
c = leg[key]["country"]
|
|
travel_countries.add(c)
|
|
|
|
for c in travel_countries - seen:
|
|
seen.add(c)
|
|
country = agenda.get_country(c)
|
|
assert country
|
|
items.append(country)
|
|
|
|
# Don't include GB in countries visited unless entire trip was GB based
|
|
return [c for c in items if c.alpha_2 != "GB"] or items
|
|
|
|
@property
|
|
def countries_str(self) -> str:
|
|
"""List of countries visited on this trip."""
|
|
return format_list_with_ampersand(
|
|
[f"{c.name} {c.flag}" for c in self.countries]
|
|
)
|
|
|
|
@property
|
|
def locations_str(self) -> str:
|
|
"""List of countries visited on this trip."""
|
|
return format_list_with_ampersand(
|
|
[f"{location} ({c.name}) {c.flag}" for location, c in self.locations()]
|
|
)
|
|
|
|
@property
|
|
def country_flags(self) -> str:
|
|
"""Countries flags for trip."""
|
|
return "".join(c.flag for c in self.countries)
|
|
|
|
def total_distance(self) -> float | None:
|
|
"""Total distance for trip."""
|
|
return (
|
|
sum(t["distance"] for t in self.travel)
|
|
if all(t.get("distance") for t in self.travel)
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def flights(self) -> list[StrDict]:
|
|
"""Flights."""
|
|
return [item for item in self.travel if item["type"] == "flight"]
|
|
|
|
def distances_by_transport_type(self) -> list[tuple[str, float]]:
|
|
"""Calculate the total distance travelled for each type of transport.
|
|
|
|
Any travel item with a missing or None 'distance' field is ignored.
|
|
"""
|
|
transport_distances: defaultdict[str, float] = defaultdict(float)
|
|
|
|
for item in self.travel:
|
|
distance = item.get("distance")
|
|
if distance:
|
|
transport_type: str = item.get("type", "unknown")
|
|
transport_distances[transport_type] += distance
|
|
|
|
return list(transport_distances.items())
|
|
|
|
def elements(self) -> list[TripElement]:
|
|
"""Trip elements ordered by time."""
|
|
elements: list[TripElement] = []
|
|
|
|
for item in self.accommodation:
|
|
title = "Airbnb" if item.get("operator") == "airbnb" else item["name"]
|
|
start = TripElement(
|
|
start_time=item["from"],
|
|
title=title,
|
|
detail=item,
|
|
element_type="check-in",
|
|
)
|
|
|
|
elements.append(start)
|
|
|
|
end = TripElement(
|
|
start_time=item["to"],
|
|
title=title,
|
|
detail=item,
|
|
element_type="check-out",
|
|
)
|
|
|
|
elements.append(end)
|
|
|
|
for item in self.travel:
|
|
if item["type"] == "flight":
|
|
name = (
|
|
f"{airport_label(item['from_airport'])} → "
|
|
+ f"{airport_label(item['to_airport'])}"
|
|
)
|
|
|
|
from_country = agenda.get_country(item["from_airport"]["country"])
|
|
to_country = agenda.get_country(item["to_airport"]["country"])
|
|
|
|
elements.append(
|
|
TripElement(
|
|
start_time=item["depart"],
|
|
end_time=item.get("arrive"),
|
|
title=name,
|
|
detail=item,
|
|
element_type="flight",
|
|
start_loc=airport_label(item["from_airport"]),
|
|
end_loc=airport_label(item["to_airport"]),
|
|
start_country=from_country,
|
|
end_country=to_country,
|
|
)
|
|
)
|
|
if item["type"] == "train":
|
|
for leg in item["legs"]:
|
|
from_country = agenda.get_country(leg["from_station"]["country"])
|
|
to_country = agenda.get_country(leg["to_station"]["country"])
|
|
|
|
assert from_country and to_country
|
|
name = f"{leg['from']} → {leg['to']}"
|
|
elements.append(
|
|
TripElement(
|
|
start_time=leg["depart"],
|
|
end_time=leg["arrive"],
|
|
title=name,
|
|
detail=leg,
|
|
element_type="train",
|
|
start_loc=leg["from"],
|
|
end_loc=leg["to"],
|
|
start_country=from_country,
|
|
end_country=to_country,
|
|
)
|
|
)
|
|
if item["type"] == "ferry":
|
|
from_country = agenda.get_country(item["from_terminal"]["country"])
|
|
to_country = agenda.get_country(item["to_terminal"]["country"])
|
|
|
|
name = f"{item['from']} → {item['to']}"
|
|
elements.append(
|
|
TripElement(
|
|
start_time=item["depart"],
|
|
end_time=item["arrive"],
|
|
title=name,
|
|
detail=item,
|
|
element_type="ferry",
|
|
start_loc=item["from"],
|
|
end_loc=item["to"],
|
|
start_country=from_country,
|
|
end_country=to_country,
|
|
)
|
|
)
|
|
|
|
return sorted(elements, key=lambda e: utils.as_datetime(e.start_time))
|
|
|
|
def elements_grouped_by_day(self) -> list[tuple[datetime.date, list[TripElement]]]:
|
|
"""Group trip elements by day."""
|
|
# Create a dictionary to hold lists of TripElements grouped by their date
|
|
grouped_elements: collections.defaultdict[datetime.date, list[TripElement]] = (
|
|
collections.defaultdict(list)
|
|
)
|
|
|
|
for element in self.elements():
|
|
# Extract the date part of the 'when' attribute
|
|
day = utils.as_date(element.start_time)
|
|
grouped_elements[day].append(element)
|
|
|
|
# Sort elements within each day
|
|
for day in grouped_elements:
|
|
grouped_elements[day].sort(
|
|
key=lambda e: (
|
|
e.element_type == "check-in", # check-out elements last
|
|
e.element_type != "check-out", # check-in elements first
|
|
utils.as_datetime(e.start_time), # then sort by time
|
|
)
|
|
)
|
|
|
|
# Convert the dictionary to a sorted list of tuples
|
|
grouped_elements_list = sorted(grouped_elements.items())
|
|
|
|
return grouped_elements_list
|
|
|
|
|
|
# Example usage:
|
|
# You would call the function with your travel list here to get the results.
|
|
|
|
|
|
@dataclass
|
|
class Holiday:
|
|
"""Holiay."""
|
|
|
|
name: str
|
|
country: str
|
|
date: datetime.date
|
|
local_name: str | None = None
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
"""Format name for display."""
|
|
return (
|
|
f"{self.name} ({self.local_name})"
|
|
if self.local_name and self.local_name != self.name
|
|
else self.name
|
|
)
|
|
|
|
|
|
emojis = {
|
|
"market": "🧺",
|
|
"us_presidential_election": "🗳️🇺🇸",
|
|
"bus_route_closure": "🚌❌",
|
|
"meetup": "👥",
|
|
"dinner": "🍷",
|
|
"party": "🍷",
|
|
"ba_voucher": "✈️",
|
|
"accommodation": "🏨", # alternative: 🧳
|
|
"flight": "✈️",
|
|
"conference": "🎤",
|
|
"rocket": "🚀",
|
|
"birthday": "🎈",
|
|
"waste_schedule": "🗑️",
|
|
"economist": "📰",
|
|
"running": "🏃",
|
|
"critical_mass": "🚴",
|
|
"trip": "🧳",
|
|
"hackathon": "💻",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Event:
|
|
"""Event."""
|
|
|
|
name: str
|
|
date: DateOrDateTime
|
|
end_date: DateOrDateTime | None = None
|
|
title: str | None = None
|
|
url: str | None = None
|
|
going: bool | None = None
|
|
|
|
@property
|
|
def as_datetime(self) -> datetime.datetime:
|
|
"""Date/time of event."""
|
|
return utils.as_datetime(self.date)
|
|
|
|
@property
|
|
def has_time(self) -> bool:
|
|
"""Event has a time associated with it."""
|
|
return isinstance(self.date, datetime.datetime)
|
|
|
|
@property
|
|
def as_date(self) -> datetime.date:
|
|
"""Date of event."""
|
|
return (
|
|
self.date.date() if isinstance(self.date, datetime.datetime) else self.date
|
|
)
|
|
|
|
@property
|
|
def end_as_date(self) -> datetime.date:
|
|
"""Date of event."""
|
|
return (
|
|
(
|
|
self.end_date.date()
|
|
if isinstance(self.end_date, datetime.datetime)
|
|
else self.end_date
|
|
)
|
|
if self.end_date
|
|
else self.as_date
|
|
)
|
|
|
|
@property
|
|
def display_time(self) -> str | None:
|
|
"""Time for display on web page."""
|
|
return (
|
|
self.date.strftime("%H:%M")
|
|
if isinstance(self.date, datetime.datetime)
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def display_timezone(self) -> str | None:
|
|
"""Timezone for display on web page."""
|
|
return (
|
|
self.date.strftime("%z")
|
|
if isinstance(self.date, datetime.datetime)
|
|
else None
|
|
)
|
|
|
|
def display_duration(self) -> str | None:
|
|
"""Duration for display."""
|
|
if self.end_as_date != self.as_date or not self.has_time:
|
|
return None
|
|
|
|
assert isinstance(self.date, datetime.datetime)
|
|
assert isinstance(self.end_date, datetime.datetime)
|
|
|
|
secs: int = int((self.end_date - self.date).total_seconds())
|
|
|
|
hours: int = secs // 3600
|
|
mins: int = (secs % 3600) // 60
|
|
|
|
if mins == 0:
|
|
return f"{hours:d}h"
|
|
if hours == 0:
|
|
return f"{mins:d} mins"
|
|
|
|
return f"{hours:d}h {mins:02d} mins"
|
|
|
|
def delta_days(self, today: datetime.date) -> str:
|
|
"""Return number of days from today as a string."""
|
|
delta = (self.as_date - today).days
|
|
|
|
match delta:
|
|
case 0:
|
|
return "today"
|
|
case 1:
|
|
return "1 day"
|
|
case _:
|
|
return f"{delta:,d} days"
|
|
|
|
@property
|
|
def display_date(self) -> str:
|
|
"""Date for display on web page."""
|
|
if isinstance(self.date, datetime.datetime):
|
|
return self.date.strftime("%a, %d, %b %Y %H:%M %z")
|
|
else:
|
|
return self.date.strftime("%a, %d, %b %Y")
|
|
|
|
@property
|
|
def display_title(self) -> str:
|
|
"""Name for display."""
|
|
return self.title or self.name
|
|
|
|
@property
|
|
def emoji(self) -> str | None:
|
|
"""Emoji."""
|
|
if self.title == "LHG Run Club":
|
|
return "🏃🍻"
|
|
return emojis.get(self.name)
|
|
|
|
@property
|
|
def title_with_emoji(self) -> str | None:
|
|
"""Title with optional emoji at the start."""
|
|
title = self.title or self.name
|
|
if title is None:
|
|
return None
|
|
emoji = self.emoji
|
|
return f"{emoji} {title}" if emoji else title
|