agenda/agenda/types.py

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