Compare commits

..

No commits in common. "main" and "launch-page" have entirely different histories.

64 changed files with 1042 additions and 5077 deletions

View file

@ -1,17 +0,0 @@
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 14,
"sourceType": "module"
},
"rules": {
}
};

View file

@ -2,7 +2,6 @@
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")
@ -11,31 +10,3 @@ 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 alpha_2.count(",") > 10: # ESA
return pycountry.db.Country(flag="🇪🇺", name="ESA")
if not alpha_2:
return None
if alpha_2 == "xk":
return pycountry.db.Country(
flag="\U0001F1FD\U0001F1F0", name="Kosovo", alpha_2="xk"
)
country: pycountry.db.Country
if len(alpha_2) == 2:
country = pycountry.countries.get(alpha_2=alpha_2.upper())
elif len(alpha_2) == 3:
country = pycountry.countries.get(alpha_3=alpha_2.upper())
return country

View file

@ -1,19 +1,20 @@
"""Accommodation.""" """Accomodation"""
import yaml import yaml
from .event import Event from .types import Event
def get_events(filepath: str) -> list[Event]: def get_events(filepath: str) -> list[Event]:
"""Get accommodation from YAML.""" """Get accomodation from YAML."""
with open(filepath) as f: with open(filepath) as f:
return [ return [
Event( Event(
date=item["from"], date=item["from"],
end_date=item["to"], end_date=item["to"],
name="accommodation", name="accommodation",
title=( title="🧳"
+ (
f'{item["location"]} Airbnb' f'{item["location"]} Airbnb'
if item.get("operator") == "airbnb" if item.get("operator") == "airbnb"
else item["name"] else item["name"]

View file

@ -4,7 +4,7 @@ from datetime import date
import yaml import yaml
from .event import Event from .types import Event
YEAR_NOT_KNOWN = 1900 YEAR_NOT_KNOWN = 1900
@ -42,7 +42,7 @@ def get_birthdays(from_date: date, filepath: str) -> list[Event]:
Event( Event(
date=bday.replace(year=bday.year + offset), date=bday.replace(year=bday.year + offset),
name="birthday", name="birthday",
title=f'{entity["label"]} ({display_age})', title=f'🎈 {entity["label"]} ({display_age})',
) )
) )

View file

@ -1,131 +0,0 @@
"""Waste collection schedules."""
import json
import os
import typing
from collections import defaultdict
from datetime import date, datetime, timedelta
import httpx
from .event import Event
from .utils import make_waste_dir
ttl_hours = 12
BristolSchedule = list[dict[str, typing.Any]]
async def get(start_date: date, data_dir: str, uprn: str, cache: str) -> list[Event]:
"""Get waste collection schedule from Bristol City Council."""
by_date: defaultdict[date, list[str]] = defaultdict(list)
for item in await get_data(data_dir, uprn, cache):
service = get_service(item)
for d in collections(item):
if d < start_date and service not in by_date[d]:
by_date[d].append(service)
return [
Event(name="waste_schedule", date=d, title="Bristol: " + ", ".join(services))
for d, services in by_date.items()
]
async def get_data(data_dir: str, uprn: str, cache: str) -> BristolSchedule:
"""Get Bristol Waste schedule, with cache."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(f"_{uprn}.json")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H:%M_{uprn}.json")
delta = now - recent
def get_from_recent() -> BristolSchedule:
json_data = json.load(open(os.path.join(waste_dir, recent_filename)))
return typing.cast(BristolSchedule, json_data["data"])
if (
cache != "refresh"
and existing
and (cache == "force" or delta < timedelta(hours=ttl_hours))
):
return get_from_recent()
try:
r = await get_web_data(uprn)
except httpx.ReadTimeout:
return get_from_recent()
with open(f'{waste_dir}/{now.strftime("%Y-%m-%d_%H:%M")}_{uprn}.json', "wb") as out:
out.write(r.content)
return typing.cast(BristolSchedule, r.json()["data"])
async def get_web_data(uprn: str) -> httpx.Response:
"""Get JSON from Bristol City Council."""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-GB,en;q=0.9",
"Connection": "keep-alive",
"Ocp-Apim-Subscription-Key": "47ffd667d69c4a858f92fc38dc24b150",
"Ocp-Apim-Trace": "true",
"Origin": "https://bristolcouncil.powerappsportals.com",
"Referer": "https://bristolcouncil.powerappsportals.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
"Sec-GPC": "1",
"User-Agent": UA,
}
_uprn = str(uprn).zfill(12)
async with httpx.AsyncClient(timeout=20) as client:
# Initialise form
payload = {"servicetypeid": "7dce896c-b3ba-ea11-a812-000d3a7f1cdc"}
response = await client.get(
"https://bristolcouncil.powerappsportals.com/completedynamicformunauth/",
headers=HEADERS,
params=payload,
)
host = "bcprdapidyna002.azure-api.net"
# Set the search criteria
payload = {"Uprn": "UPRN" + _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-llpg/DetailedLLPG",
headers=HEADERS,
json=payload,
)
# Retrieve the schedule
payload = {"uprn": _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-alloy/NextCollectionDates",
headers=HEADERS,
json=payload,
)
return response
def get_service(item: dict[str, typing.Any]) -> str:
"""Bristol waste service name."""
service: str = item["containerName"]
return "Recycling" if "Recycling" in service else service.partition(" ")[2]
def collections(item: dict[str, typing.Any]) -> typing.Iterable[date]:
"""Bristol dates from collections."""
for collection in item["collection"]:
for collection_date_key in ["nextCollectionDate", "lastCollectionDate"]:
yield date.fromisoformat(collection[collection_date_key][:10])

View file

@ -1,160 +0,0 @@
"""Identify busy events and gaps when nothing is scheduled."""
import itertools
import typing
from datetime import date, datetime, timedelta
import flask
from . import events_yaml
from .event import Event
from .types import StrDict, Trip
def busy_event(e: Event) -> bool:
"""Busy."""
if e.name not in {
"event",
"accommodation",
"conference",
"transport",
"meetup",
"party",
"trip",
}:
return False
if e.title in ("IA UK board meeting", "Mill Road Winter Fair"):
return False
if e.name == "conference" and not e.going:
return False
if not e.title:
return True
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
return False
lc_title = e.title.lower()
return (
"rebels" not in lc_title
and "south west data social" not in lc_title
and "dorkbot" not in lc_title
)
def get_busy_events(
today: date, config: flask.config.Config, trips: list[Trip]
) -> list[Event]:
"""Find busy events from a year ago to two years in the future."""
last_year = today - timedelta(days=365)
next_year = today + timedelta(days=2 * 365)
my_data = config["PERSONAL_DATA"]
events = events_yaml.read(my_data, last_year, next_year, skip_trips=True)
for trip in trips:
event_type = "trip"
if trip.events and not trip.conferences:
event_type = trip.events[0]["name"]
elif len(trip.conferences) == 1 and trip.conferences[0].get("hackathon"):
event_type = "hackathon"
events.append(
Event(
name=event_type,
title=trip.title + " " + trip.country_flags,
date=trip.start,
end_date=trip.end,
url=flask.url_for("trip_page", start=trip.start.isoformat()),
)
)
busy_events = [
e
for e in sorted(events, key=lambda e: e.as_date)
if (e.as_date >= today or (e.end_date and e.end_as_date >= today))
and e.as_date < next_year
and busy_event(e)
]
return busy_events
def weekends(busy_events: list[Event]) -> typing.Sequence[StrDict]:
"""Next ten weekends."""
today = datetime.today()
weekday = today.weekday()
# Calculate the difference to the next or previous Saturday
if weekday == 6: # Sunday
start_date = (today - timedelta(days=1)).date()
else:
start_date = (today + timedelta(days=(5 - weekday))).date()
weekends_info = []
for i in range(52):
saturday = start_date + timedelta(weeks=i)
sunday = saturday + timedelta(days=1)
saturday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= saturday <= event.end_as_date
]
sunday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= sunday <= event.end_as_date
]
weekends_info.append(
{"date": saturday, "saturday": saturday_events, "sunday": sunday_events}
)
return weekends_info
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
"""Gaps of at least `min_gap_days` between events in a list of events."""
# Sort events by start date
gaps: list[tuple[date, date]] = []
previous_event_end = None
by_start_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
}
by_end_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
}
for event in events:
# Use start date for current event
start_date = event.as_date
# If previous event exists, calculate the gap
if previous_event_end:
gap_days = (start_date - previous_event_end).days
if gap_days >= (min_gap_days + 2):
start_end = (
previous_event_end + timedelta(days=1),
start_date - timedelta(days=1),
)
gaps.append(start_end)
# Update previous event end date
end = event.end_as_date
if not previous_event_end or end > previous_event_end:
previous_event_end = end
return [
{
"start": gap_start,
"end": gap_end,
"after": by_start_date[gap_end + timedelta(days=1)],
"before": by_end_date[gap_start - timedelta(days=1)],
}
for gap_start, gap_end in gaps
]

View file

@ -3,7 +3,7 @@
import typing import typing
from datetime import timedelta from datetime import timedelta
from .event import Event from .types import Event
event_type_color_map = { event_type_color_map = {
"bank_holiday": "success-subtle", "bank_holiday": "success-subtle",
@ -36,7 +36,7 @@ def build_events(events: list[Event]) -> list[dict[str, typing.Any]]:
assert e.title and e.end_date assert e.title and e.end_date
item = { item = {
"allDay": True, "allDay": True,
"title": e.title_with_emoji, "title": e.display_title,
"start": e.as_date.isoformat(), "start": e.as_date.isoformat(),
"end": (e.end_as_date + one_day).isoformat(), "end": (e.end_as_date + one_day).isoformat(),
"url": e.url, "url": e.url,
@ -61,12 +61,12 @@ def build_events(events: list[Event]) -> list[dict[str, typing.Any]]:
continue continue
if e.has_time: if e.has_time:
end = e.end_date or e.date + timedelta(minutes=30) end = e.end_date or e.date + timedelta(hours=1)
else: else:
end = (e.end_as_date if e.end_date else e.as_date) + one_day end = (e.end_as_date if e.end_date else e.as_date) + one_day
item = { item = {
"allDay": not e.has_time, "allDay": not e.has_time,
"title": e.title_with_emoji, "title": e.display_title,
"start": e.date.isoformat(), "start": e.date.isoformat(),
"end": end.isoformat(), "end": end.isoformat(),
} }

View file

@ -1,33 +0,0 @@
"""Calculate the date for carnival."""
from datetime import date, timedelta
from dateutil.easter import easter
from .event import Event
def rio_carnival_events(start_date: date, end_date: date) -> list[Event]:
"""List of events for Rio Carnival for each year between start_date and end_date."""
events = []
for year in range(start_date.year, end_date.year + 1):
easter_date = easter(year)
carnival_start = easter_date - timedelta(days=51)
carnival_end = easter_date - timedelta(days=46)
# Only include the carnival if it falls within the specified date range
if (
start_date <= carnival_start <= end_date
or start_date <= carnival_end <= end_date
):
events.append(
Event(
name="carnival",
title="Rio Carnival",
date=carnival_start,
end_date=carnival_end,
url="https://en.wikipedia.org/wiki/Rio_Carnival",
)
)
return events

View file

@ -6,10 +6,7 @@ from datetime import date, datetime
import yaml import yaml
from . import utils from .types import Event
from .event import Event
MAX_CONF_DAYS = 20
@dataclasses.dataclass @dataclasses.dataclass
@ -21,8 +18,6 @@ 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
venue: str | None = None venue: str | None = None
address: str | None = None address: str | None = None
url: str | None = None url: str | None = None
@ -34,15 +29,6 @@ class Conference:
online: bool = False online: bool = False
price: decimal.Decimal | None = None price: decimal.Decimal | None = None
currency: str | None = None currency: str | None = None
latitude: float | None = None
longitude: float | None = None
cfp_end: date | None = None
cfp_url: str | None = None
free: bool | None = None
hackathon: bool | None = None
ticket_type: str | None = None
attendees: int | None = None
hashtag: str | None = None
@property @property
def display_name(self) -> str: def display_name(self) -> str:
@ -56,28 +42,17 @@ class Conference:
def get_list(filepath: str) -> list[Event]: def get_list(filepath: str) -> list[Event]:
"""Read conferences from a YAML file and return a list of Event objects.""" """Read conferences from a YAML file and return a list of Event objects."""
events: list[Event] = [] return [
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))): Event(
assert conf.start <= conf.end
duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days
assert duration < MAX_CONF_DAYS
event = Event(
name="conference", name="conference",
date=conf.start, date=conf.start,
end_date=conf.end, end_date=conf.end,
title=conf.display_name, title=f"🎤 {conf.display_name}",
url=conf.url, url=conf.url,
going=conf.going, going=conf.going,
) )
events.append(event) for conf in (
if not conf.cfp_end: Conference(**conf)
continue for conf in yaml.safe_load(open(filepath, "r"))["conferences"]
cfp_end_event = Event(
name="cfp_end",
date=conf.cfp_end,
title="CFP end: " + conf.display_name,
url=conf.cfp_url or conf.url,
) )
events.append(cfp_end_event) ]
return events

View file

@ -1,50 +1,51 @@
"""Agenda data.""" """Agenda data."""
import asyncio import asyncio
import collections
import itertools
import os import os
import typing import typing
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from time import time
import dateutil.rrule import dateutil.rrule
import dateutil.tz import dateutil.tz
import flask import flask
import holidays
import isodate # type: ignore
import lxml import lxml
import pytz import pytz
import yaml
from . import ( from . import (
accommodation, accommodation,
birthday, birthday,
bristol_waste, calendar,
busy,
carnival,
conference, conference,
domains, domains,
economist, economist,
events_yaml, fx,
gandi,
gwr, gwr,
hn, hn,
holidays,
meetup, meetup,
n_somerset_waste,
stock_market, stock_market,
subscription, subscription,
sun, sun,
thespacedevs, thespacedevs,
travel, travel,
uk_holiday, uk_holiday,
uk_tz,
waste_schedule,
) )
from .event import Event from .types import Event, Holiday
from .types import StrDict
from .utils import time_function StrDict = dict[str, typing.Any]
here = dateutil.tz.tzlocal() here = dateutil.tz.tzlocal()
# deadline to file tax return # deadline to file tax return
# credit card expiry dates # credit card expiry dates
# morzine ski lifts # morzine ski lifts
# chalet availability calendar # chalet availablity calendar
# starlink visible # starlink visible
@ -61,114 +62,298 @@ def timezone_transition(
] ]
async def n_somerset_waste_collection_events( def us_holidays(start_date: date, end_date: date) -> list[Holiday]:
data_dir: str, postcode: str, uprn: str, force_cache: bool = False """Get US holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays("US", years=year, language="en")
found += [
Holiday(date=hol_date, name=title, country="us")
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
extra = []
for h in found:
if h.name != "Thanksgiving":
continue
extra += [
Holiday(date=h.date + timedelta(days=1), name="Black Friday", country="us"),
Holiday(date=h.date + timedelta(days=4), name="Cyber Monday", country="us"),
]
return found + extra
def get_nyse_holidays(
start_date: date, end_date: date, us_hols: list[Holiday]
) -> list[Event]: ) -> list[Event]:
"""NYSE holidays."""
known_us_hols = {(h.date, h.name) for h in us_hols}
found: list[Event] = []
rename = {"Thanksgiving Day": "Thanksgiving"}
for year in range(start_date.year, end_date.year + 1):
hols = holidays.financial_holidays("NYSE", years=year)
found += [
Event(
name="holiday",
date=hol_date,
title=rename.get(title, title),
)
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
found = [hol for hol in found if (hol.date, hol.title) not in known_us_hols]
for hol in found:
assert hol.title
hol.title += " (NYSE)"
return found
def get_holidays(country: str, start_date: date, end_date: date) -> list[Holiday]:
"""Get holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays(country.upper(), years=year, language="en_US")
found += [
Holiday(
date=hol_date,
name=title,
country=country.lower(),
)
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
return found
def midnight(d: date) -> datetime:
"""Convert from date to midnight on that day."""
return datetime.combine(d, datetime.min.time())
def dates_from_rrule(
rrule: str, start: date, end: date
) -> typing.Sequence[datetime | date]:
"""Generate events from an RRULE between start_date and end_date."""
all_day = not any(param in rrule for param in ["BYHOUR", "BYMINUTE", "BYSECOND"])
return [
i.date() if all_day else uk_tz.localize(i)
for i in dateutil.rrule.rrulestr(rrule, dtstart=midnight(start)).between(
midnight(start), midnight(end)
)
]
async def waste_collection_events(data_dir: str) -> list[Event]:
"""Waste colllection events.""" """Waste colllection events."""
html = await n_somerset_waste.get_html(data_dir, postcode, uprn, force_cache) postcode = "BS48 3HG"
uprn = "24071046"
html = await waste_schedule.get_html(data_dir, postcode, uprn)
root = lxml.html.fromstring(html) root = lxml.html.fromstring(html)
events = n_somerset_waste.parse(root) events = waste_schedule.parse(root)
return events return events
async def bristol_waste_collection_events( async def bristol_waste_collection_events(
data_dir: str, start_date: date, uprn: str, force_cache: bool = False data_dir: str, start_date: date
) -> list[Event]: ) -> list[Event]:
"""Waste colllection events.""" """Waste colllection events."""
cache = "force" if force_cache else "recent" uprn = "358335"
return await bristol_waste.get(start_date, data_dir, uprn, cache)
return await waste_schedule.get_bristol_gov_uk(start_date, data_dir, uprn)
def find_events_during_stay( def combine_holidays(holidays: list[Holiday]) -> list[Event]:
accommodation_events: list[Event], markets: list[Event] """Combine UK and US holidays with the same date and title."""
) -> list[Event]:
"""Market events that happen during accommodation stays."""
overlapping_markets = []
for market in markets:
market_date = market.as_date
assert isinstance(market_date, date)
for e in accommodation_events:
start, end = e.as_date, e.end_as_date
assert start and end and all(isinstance(i, date) for i in (start, end))
# Check if the market date is within the accommodation dates.
if start <= market_date <= end:
overlapping_markets.append(market)
break # Breaks the inner loop if overlap is found.
return overlapping_markets
all_countries = {h.country for h in holidays}
def hide_markets_while_away( standard_name = {
events: list[Event], accommodation_events: list[Event] (1, 1): "New Year's Day",
) -> None: (1, 6): "Epiphany",
"""Hide markets that happen while away.""" (5, 1): "Labour Day",
optional = [ (8, 15): "Assumption Day",
e (12, 8): "Immaculate conception",
for e in events (12, 25): "Christmas Day",
if e.name == "market" or (e.title and "LHG Run Club" in e.title) (12, 26): "Boxing Day",
] }
going = [e for e in events if e.going]
overlapping_markets = find_events_during_stay( combined: collections.defaultdict[
accommodation_events + going, optional tuple[date, str], set[str]
) ] = collections.defaultdict(set)
for market in overlapping_markets:
events.remove(market)
for h in holidays:
assert isinstance(h.name, str) and isinstance(h.date, date)
class AgendaData(typing.TypedDict, total=False): event_key = (h.date, standard_name.get((h.date.month, h.date.day), h.name))
"""Agenda Data.""" combined[event_key].add(h.country)
now: datetime
stock_markets: list[str]
rockets: list[thespacedevs.Summary]
gwr_advance_tickets: date | None
data_gather_seconds: float
stock_market_times_seconds: float
timings: list[tuple[str, float]]
events: list[Event]
accommodation_events: list[Event]
gaps: list[StrDict]
sunrise: datetime
sunset: datetime
last_week: date
two_weeks_ago: date
errors: list[tuple[str, Exception]]
def rocket_launch_events(rockets: list[thespacedevs.Summary]) -> list[Event]:
"""Rocket launch events."""
events: list[Event] = [] events: list[Event] = []
for launch in rockets: for (d, name), countries in combined.items():
dt = None if len(countries) == len(all_countries):
country_list = ""
net_precision = launch["net_precision"] elif len(countries) < len(all_countries) / 2:
skip = {"Year", "Month", "Quarter", "Fiscal Year"} country_list = ", ".join(sorted(country.upper() for country in countries))
if net_precision == "Day": else:
dt = datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ").date() country_list = "not " + ", ".join(
elif ( sorted(country.upper() for country in all_countries - set(countries))
net_precision
and net_precision not in skip
and "Year" not in net_precision
and launch["t0_time"]
):
dt = pytz.utc.localize(
datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ")
) )
if not dt: e = Event(
continue name="holiday",
date=d,
rocket_name = ( title=f"{name} ({country_list})" if country_list else name,
f'{launch["rocket"]["full_name"]}: '
+ f'{launch["mission_name"] or "[no mission]"}'
) )
e = Event(name="rocket", date=dt, title=rocket_name)
events.append(e) events.append(e)
return events return events
async def get_data(now: datetime, config: flask.config.Config) -> AgendaData: def get_yaml_event_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def get_yaml_event_end_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def read_events_yaml(data_dir: str, start: date, end: date) -> list[Event]:
"""Read eventes from YAML file."""
events: list[Event] = []
for item in yaml.safe_load(open(os.path.join(data_dir, "events.yaml"))):
duration = (
isodate.parse_duration(item["duration"]) if "duration" in item else None
)
dates = (
dates_from_rrule(item["rrule"], start, end)
if "rrule" in item
else [item[get_yaml_event_date_field(item)]]
)
for dt in dates:
e = Event(
name=item["name"],
date=dt,
end_date=(
dt + duration
if duration
else (
item.get("end_date")
if item["name"] != "travel_insurance"
else None
)
),
title=item.get("title"),
url=item.get("url"),
)
events.append(e)
return events
def find_markets_during_stay(
accommodation_events: list[Event], markets: list[Event]
) -> list[Event]:
"""Market events that happen during accommodation stays."""
overlapping_markets = []
for market in markets:
for e in accommodation_events:
# Check if the market date is within the accommodation dates.
if e.as_date <= market.as_date <= e.end_as_date:
overlapping_markets.append(market)
break # Breaks the inner loop if overlap is found.
return overlapping_markets
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
"""Gaps of at least `min_gap_days` between events in a list of events."""
# Sort events by start date
gaps: list[tuple[date, date]] = []
previous_event_end = None
by_start_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
}
by_end_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
}
for event in events:
# Use start date for current event
start_date = event.as_date
# If previous event exists, calculate the gap
if previous_event_end:
gap_days = (start_date - previous_event_end).days
if gap_days >= (min_gap_days + 2):
start_end = (
previous_event_end + timedelta(days=1),
start_date - timedelta(days=1),
)
gaps.append(start_end)
# Update previous event end date
end = event.end_as_date
if not previous_event_end or end > previous_event_end:
previous_event_end = end
return [
{
"start": gap_start,
"end": gap_end,
"after": by_start_date[gap_end + timedelta(days=1)],
"before": by_end_date[gap_start - timedelta(days=1)],
}
for gap_start, gap_end in gaps
]
def busy_event(e: Event) -> bool:
"""Busy."""
if e.name not in {
"event",
"accommodation",
"conference",
"dodainville",
"transport",
"meetup",
"party",
}:
return False
if e.title in ("IA UK board meeting", "Mill Road Winter Fair"):
return False
if e.name == "conference" and not e.going:
return False
if not e.title:
return True
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
return False
lc_title = e.title.lower()
return "rebels" not in lc_title and "south west data social" not in lc_title
async def get_data(
now: datetime, config: flask.config.Config
) -> typing.Mapping[str, str | object]:
"""Get data to display on agenda dashboard.""" """Get data to display on agenda dashboard."""
data_dir = config["DATA_DIR"] data_dir = config["DATA_DIR"]
@ -182,51 +367,28 @@ async def get_data(now: datetime, config: flask.config.Config) -> AgendaData:
minus_365 = now - timedelta(days=365) minus_365 = now - timedelta(days=365)
plus_365 = now + timedelta(days=365) plus_365 = now + timedelta(days=365)
t0 = time() (
offline_mode = bool(config.get("OFFLINE_MODE")) gbpusd,
result_list = await asyncio.gather( gwr_advance_tickets,
time_function( bank_holiday,
"gwr_advance_tickets", gwr.advance_ticket_date, data_dir, offline_mode rockets,
), backwell_bins,
time_function( bristol_bins,
"backwell_bins", ) = await asyncio.gather(
n_somerset_waste_collection_events, fx.get_gbpusd(config),
data_dir, gwr.advance_ticket_date(data_dir),
config["BACKWELL_POSTCODE"], uk_holiday.bank_holiday_list(last_year, next_year, data_dir),
config["BACKWELL_UPRN"], thespacedevs.get_launches(rocket_dir, limit=40),
offline_mode, waste_collection_events(data_dir),
), bristol_waste_collection_events(data_dir, today),
time_function(
"bristol_bins",
bristol_waste_collection_events,
data_dir,
today,
config["BRISTOL_UPRN"],
offline_mode,
),
) )
rockets = thespacedevs.read_cached_launches(rocket_dir)
results = {call[0]: call[1] for call in result_list} reply: dict[str, typing.Any] = {
errors = [(call[0], call[3]) for call in result_list if call[3]]
gwr_advance_tickets = results["gwr_advance_tickets"]
data_gather_seconds = time() - t0
t0 = time()
stock_market_times = stock_market.open_and_close()
stock_market_times_seconds = time() - t0
reply: AgendaData = {
"now": now, "now": now,
"stock_markets": stock_market_times, "gbpusd": gbpusd,
"stock_markets": stock_market.open_and_close(),
"rockets": rockets, "rockets": rockets,
"gwr_advance_tickets": gwr_advance_tickets, "gwr_advance_tickets": gwr_advance_tickets,
"data_gather_seconds": data_gather_seconds,
"stock_market_times_seconds": stock_market_times_seconds,
"timings": [(call[0], call[2]) for call in result_list],
} }
my_data = config["PERSONAL_DATA"] my_data = config["PERSONAL_DATA"]
@ -243,43 +405,85 @@ async def get_data(now: datetime, config: flask.config.Config) -> AgendaData:
if gwr_advance_tickets: if gwr_advance_tickets:
events.append(Event(name="gwr_advance_tickets", date=gwr_advance_tickets)) events.append(Event(name="gwr_advance_tickets", date=gwr_advance_tickets))
us_hols = holidays.us_holidays(last_year, next_year) us_hols = us_holidays(last_year, next_year)
events += holidays.get_nyse_holidays(last_year, next_year, us_hols)
holidays: list[Holiday] = bank_holiday + us_hols
for country in (
"at",
"be",
"br",
"ch",
"cz",
"de",
"dk",
"ee",
"es",
"fi",
"fr",
"gr",
"it",
"ke",
"nl",
"pl",
):
holidays += get_holidays(country, last_year, next_year)
events += get_nyse_holidays(last_year, next_year, us_hols)
accommodation_events = accommodation.get_events( accommodation_events = accommodation.get_events(
os.path.join(my_data, "accommodation.yaml") os.path.join(my_data, "accommodation.yaml")
) )
holiday_list = holidays.get_all(last_year, next_year, data_dir) events += combine_holidays(holidays)
events += holidays.combine_holidays(holiday_list) events += birthday.get_birthdays(last_year, os.path.join(my_data, "entities.yaml"))
if flask.g.user.is_authenticated:
events += birthday.get_birthdays(
last_year, os.path.join(my_data, "entities.yaml")
)
events += domains.renewal_dates(my_data)
events += accommodation_events events += accommodation_events
events += travel.all_events(my_data) events += travel.all_events(my_data)
events += conference.get_list(os.path.join(my_data, "conferences.yaml")) events += conference.get_list(os.path.join(my_data, "conferences.yaml"))
for key in "backwell_bins", "bristol_bins": events += backwell_bins + bristol_bins
if results[key]: events += read_events_yaml(my_data, last_year, next_year)
events += results[key]
events += events_yaml.read(my_data, last_year, next_year)
events += subscription.get_events(os.path.join(my_data, "subscriptions.yaml")) events += subscription.get_events(os.path.join(my_data, "subscriptions.yaml"))
events += gandi.get_events(data_dir)
events += economist.publication_dates(last_week, next_year) events += economist.publication_dates(last_week, next_year)
events += meetup.get_events(my_data) events += meetup.get_events(my_data)
events += hn.whoishiring(last_year, next_year) events += hn.whoishiring(last_year, next_year)
events += carnival.rio_carnival_events(last_year, next_year)
events += rocket_launch_events(rockets) events += domains.renewal_dates(my_data)
# hide markets that happen while away
markets = [e for e in events if e.name == "market"]
going = [e for e in events if e.going]
overlapping_markets = find_markets_during_stay(
accommodation_events + going, markets
)
for market in overlapping_markets:
events.remove(market)
for launch in rockets:
dt = None
if launch["net_precision"] == "Day":
dt = datetime.strptime(launch["net"], "%Y-%m-%dT00:00:00Z").date()
elif launch["t0_time"]:
dt = pytz.utc.localize(
datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ")
)
if not dt:
continue
rocket_name = f'🚀{launch["rocket"]}: {launch["mission_name"] or "[no mission]"}'
e = Event(name="rocket", date=dt, title=rocket_name)
events.append(e)
events += [Event(name="today", date=today)] events += [Event(name="today", date=today)]
busy_events = [ busy_events = [
e e
for e in sorted(events, key=lambda e: e.as_date) for e in sorted(events, key=lambda e: e.as_date)
if e.as_date > today and e.as_date < next_year and busy.busy_event(e) if e.as_date > today and e.as_date < next_year and busy_event(e)
] ]
gaps = busy.find_gaps(busy_events) gaps = find_gaps(busy_events)
events += [ events += [
Event(name="gap", date=gap["start"], end_date=gap["end"]) for gap in gaps Event(name="gap", date=gap["start"], end_date=gap["end"]) for gap in gaps
@ -297,10 +501,9 @@ async def get_data(now: datetime, config: flask.config.Config) -> AgendaData:
reply["sunrise"] = sun.sunrise(observer) reply["sunrise"] = sun.sunrise(observer)
reply["sunset"] = sun.sunset(observer) reply["sunset"] = sun.sunset(observer)
reply["events"] = events reply["events"] = events
reply["accommodation_events"] = accommodation_events
reply["last_week"] = last_week reply["last_week"] = last_week
reply["two_weeks_ago"] = two_weeks_ago reply["two_weeks_ago"] = two_weeks_ago
reply["errors"] = errors reply["fullcalendar_events"] = calendar.build_events(events)
return reply return reply

View file

@ -1,10 +1,10 @@
"""Domain renewal dates.""" """Accomodation."""
import csv import csv
import os import os
from datetime import datetime from datetime import datetime
from .event import Event from .types import Event
url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/" url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/"

View file

@ -5,7 +5,7 @@ from datetime import date, time, timedelta
from dateutil.relativedelta import TH, relativedelta from dateutil.relativedelta import TH, relativedelta
from . import uk_time from . import uk_time
from .event import Event from .types import Event
def publication_dates(start_date: date, end_date: date) -> list[Event]: def publication_dates(start_date: date, end_date: date) -> list[Event]:

View file

@ -1,149 +0,0 @@
"""Types."""
import datetime
from dataclasses import dataclass
from . import utils
from .types import DateOrDateTime, StrDict
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

View file

@ -1,85 +0,0 @@
"""Read events from YAML."""
import os
import typing
from datetime import date, datetime
import dateutil.rrule
import isodate # type: ignore
import yaml
from . import uk_tz
from .event import Event
def midnight(d: date) -> datetime:
"""Convert from date to midnight on that day."""
return datetime.combine(d, datetime.min.time())
def dates_from_rrule(
rrule: str, start: date, end: date
) -> typing.Sequence[datetime | date]:
"""Generate events from an RRULE between start_date and end_date."""
all_day = not any(param in rrule for param in ["BYHOUR", "BYMINUTE", "BYSECOND"])
return [
i.date() if all_day else uk_tz.localize(i)
for i in dateutil.rrule.rrulestr(rrule, dtstart=midnight(start)).between(
midnight(start), midnight(end)
)
]
def get_yaml_event_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def get_yaml_event_end_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def read(
data_dir: str, start: date, end: date, skip_trips: bool = False
) -> list[Event]:
"""Read eventes from YAML file."""
events: list[Event] = []
for item in yaml.safe_load(open(os.path.join(data_dir, "events.yaml"))):
if "trip" in item and skip_trips:
continue
duration = (
isodate.parse_duration(item["duration"]) if "duration" in item else None
)
dates = (
dates_from_rrule(item["rrule"], start, end)
if "rrule" in item
else [item[get_yaml_event_date_field(item)]]
)
for dt in dates:
e = Event(
name=item["name"],
date=dt,
end_date=(
dt + duration
if duration
else (
item.get("end_date")
if item["name"] != "travel_insurance"
else None
)
),
title=item.get("title"),
url=item.get("url"),
)
events.append(e)
return events

View file

@ -42,72 +42,3 @@ async def get_gbpusd(config: flask.config.Config) -> Decimal:
data = json.loads(r.text, parse_float=Decimal) data = json.loads(r.text, parse_float=Decimal)
return typing.cast(Decimal, 1 / data["quotes"]["USDGBP"]) return typing.cast(Decimal, 1 / data["quotes"]["USDGBP"])
def read_cached_rates(
filename: str | None, currencies: list[str]
) -> dict[str, Decimal]:
"""Read FX rates from cache."""
if filename is None:
return {}
with open(filename) as file:
data = json.load(file, parse_float=Decimal)
return {
cur: Decimal(data["quotes"][f"GBP{cur}"])
for cur in currencies
if f"GBP{cur}" in data["quotes"]
}
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
"""Get current values of exchange rates for a list of currencies against GBP."""
currencies = config["CURRENCIES"]
access_key = config["EXCHANGERATE_ACCESS_KEY"]
data_dir = config["DATA_DIR"]
now = datetime.now()
now_str = now.strftime("%Y-%m-%d_%H:%M")
fx_dir = os.path.join(data_dir, "fx")
os.makedirs(fx_dir, exist_ok=True) # Ensure the directory exists
currency_string = ",".join(sorted(currencies))
file_suffix = f"{currency_string}_to_GBP.json"
existing_data = os.listdir(fx_dir)
existing_files = [f for f in existing_data if f.endswith(".json")]
full_path: str | None = None
if existing_files:
recent_filename = max(existing_files)
recent = datetime.strptime(recent_filename[:16], "%Y-%m-%d_%H:%M")
delta = now - recent
full_path = os.path.join(fx_dir, recent_filename)
if recent_filename.endswith(file_suffix) and delta < timedelta(hours=12):
return read_cached_rates(full_path, currencies)
url = "http://api.exchangerate.host/live"
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
filename = f"{now_str}_{file_suffix}"
try:
with httpx.Client() as client:
response = client.get(url, params=params)
except httpx.ConnectError:
return read_cached_rates(full_path, currencies)
try:
data = json.loads(response.text, parse_float=Decimal)
except json.decoder.JSONDecodeError:
return read_cached_rates(full_path, currencies)
with open(os.path.join(fx_dir, filename), "w") as file:
file.write(response.text)
return {
cur: Decimal(data["quotes"][f"GBP{cur}"])
for cur in currencies
if f"GBP{cur}" in data["quotes"]
}

View file

@ -1,27 +0,0 @@
"""Gandi domain renewal dates."""
import os
from .event import Event
from datetime import datetime
import json
def get_events(data_dir: str) -> list[Event]:
"""Get subscription renewal dates."""
filename = os.path.join(data_dir, "gandi_domains.json")
with open(filename) as f:
items = json.load(f)
assert isinstance(items, list)
assert all(item["fqdn"] and item["dates"]["registry_ends_at"] for item in items)
return [
Event(
date=datetime.fromisoformat(item["dates"]["registry_ends_at"]).date(),
name="domain",
title=item["fqdn"] + " renewal",
)
for item in items
]

View file

@ -1,107 +0,0 @@
"""Geomob events."""
import os
from dataclasses import dataclass
from datetime import date, datetime
from typing import List
import dateutil.parser
import flask
import lxml.html
import requests
import agenda.mail
import agenda.utils
@dataclass(frozen=True)
class GeomobEvent:
"""Geomob event."""
date: date
href: str
hashtag: str
def extract_events(
tree: lxml.html.HtmlElement,
) -> List[GeomobEvent]:
"""Extract upcoming events from the HTML content."""
events = []
for event in tree.xpath('//ol[@class="event-list"]/li/a'):
date_str, _, hashtag = event.text_content().strip().rpartition(" ")
events.append(
GeomobEvent(
date=dateutil.parser.parse(date_str).date(),
href=event.get("href"),
hashtag=hashtag,
)
)
return events
def find_new_events(
prev: list[GeomobEvent], cur: list[GeomobEvent]
) -> list[GeomobEvent]:
"""Find new events that appear in cur but not in prev."""
return list(set(cur) - set(prev))
def geomob_email(new_events: list[GeomobEvent], base_url: str) -> tuple[str, str]:
"""Generate email subject and body for new events.
Args:
new_events (List[Event]): List of new events.
base_url (str): The base URL of the website.
Returns:
tuple[str, str]: Email subject and body.
"""
assert new_events
subject = f"{len(new_events)} New Geomob Event(s) Announced"
body_lines = ["Hello,\n", "Here are the new Geomob events:\n"]
for event in new_events:
event_details = (
f"Date: {event.date}\n"
f"URL: {base_url + event.href}\n"
f"Hashtag: {event.hashtag}\n"
)
body_lines.append(event_details)
body_lines.append("-" * 40)
body = "\n".join(body_lines)
return (subject, body)
def get_cached_upcoming_events_list(geomob_dir: str) -> list[GeomobEvent]:
"""Get known geomob events."""
filename = agenda.utils.get_most_recent_file(geomob_dir, "html")
return extract_events(lxml.html.parse(filename).getroot()) if filename else []
def update(config: flask.config.Config) -> None:
"""Get upcoming Geomob events and report new ones."""
geomob_dir = os.path.join(config["DATA_DIR"], "geomob")
prev_events = get_cached_upcoming_events_list(geomob_dir)
r = requests.get("https://thegeomob.com/")
cur_events = extract_events(lxml.html.fromstring(r.content))
if cur_events == prev_events:
return # no change
now = datetime.now()
new_filename = os.path.join(geomob_dir, now.strftime("%Y-%m-%d_%H:%M:%S.html"))
open(new_filename, "w").write(r.text)
new_events = list(set(cur_events) - set(prev_events))
if not new_events:
return
base_url = "https://thegeomob.com/"
subject, body = geomob_email(new_events, base_url)
agenda.mail.send_mail(config, subject, body)

View file

@ -10,30 +10,6 @@ import httpx
url = "https://www.gwr.com/your-tickets/choosing-your-ticket/advance-tickets" url = "https://www.gwr.com/your-tickets/choosing-your-ticket/advance-tickets"
def parse_date_string(date_str: str) -> date:
"""Parse date string from HTML."""
if not date_str[-1].isdigit(): # If the year is missing, use the current year
date_str += f" {date.today().year}"
return datetime.strptime(date_str, "%A %d %B %Y").date()
def extract_dates(html: str) -> None | dict[str, date]:
"""Extract dates from HTML."""
pattern = re.compile(
r"<tr>\s*<td>(Weekdays|Saturdays|Sundays)</td>*"
+ r"\s*<td>(.*?)(?:\*\*)?</td>\s*</tr>",
)
if not pattern.search(html):
return None
return {
match.group(1): parse_date_string(match.group(2))
for match in pattern.finditer(html)
}
def extract_weekday_date(html: str) -> date | None: def extract_weekday_date(html: str) -> date | None:
"""Furthest date of GWR advance ticket booking.""" """Furthest date of GWR advance ticket booking."""
# Compile a regular expression pattern to match the relevant table row # Compile a regular expression pattern to match the relevant table row
@ -42,19 +18,22 @@ def extract_weekday_date(html: str) -> date | None:
) )
# Search the HTML for the pattern # Search the HTML for the pattern
if match := pattern.search(html): if not (match := pattern.search(html)):
return parse_date_string(match.group(1))
else:
return None return None
date_str = match.group(1)
# If the year is missing, use the current year
if not date_str[-1].isdigit():
date_str += f" {date.today().year}"
return datetime.strptime(date_str, "%A %d %B %Y").date()
async def advance_tickets_page_html( async def advance_tickets_page_html(data_dir: str, ttl: int = 60 * 60 * 6) -> str:
data_dir: str, ttl: int = 60 * 60 * 6, force_cache: bool = False
) -> str:
"""Get advance-tickets web page HTML with cache.""" """Get advance-tickets web page HTML with cache."""
filename = os.path.join(data_dir, "advance-tickets.html") filename = os.path.join(data_dir, "advance-tickets.html")
mtime = os.path.getmtime(filename) if os.path.exists(filename) else 0 mtime = os.path.getmtime(filename) if os.path.exists(filename) else 0
if force_cache or (time() - mtime) < ttl: # use cache if (time() - mtime) < ttl: # use cache
return open(filename).read() return open(filename).read()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get(url) r = await client.get(url)
@ -63,7 +42,7 @@ async def advance_tickets_page_html(
return html return html
async def advance_ticket_date(data_dir: str, force_cache: bool = False) -> date | None: async def advance_ticket_date(data_dir: str) -> date | None:
"""Get GWR advance tickets date with cache.""" """Get GWR advance tickets date with cache."""
html = await advance_tickets_page_html(data_dir, force_cache=force_cache) html = await advance_tickets_page_html(data_dir)
return extract_weekday_date(html) return extract_weekday_date(html)

View file

@ -5,7 +5,7 @@ from datetime import date, datetime, time, timedelta
import pytz import pytz
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from .event import Event from .types import Event
eastern_time = pytz.timezone("America/New_York") eastern_time = pytz.timezone("America/New_York")

View file

@ -1,179 +0,0 @@
"""Holidays."""
import collections
from datetime import date, timedelta
import flask
import agenda.uk_holiday
import holidays
from .event import Event
from .types import Holiday, Trip
def get_trip_holidays(trip: Trip) -> list[Holiday]:
"""Get holidays happening during trip."""
if not trip.end:
return []
countries = {c.alpha_2 for c in trip.countries}
return sorted(
(
hol
for hol in get_all(
trip.start, trip.end, flask.current_app.config["DATA_DIR"]
)
if hol.country.upper() in countries
),
key=lambda item: (item.date, item.country),
)
def us_holidays(start_date: date, end_date: date) -> list[Holiday]:
"""Get US holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays("US", years=year, language="en")
found += [
Holiday(date=hol_date, name=title, country="us")
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
extra = []
for h in found:
if h.name != "Thanksgiving":
continue
extra += [
Holiday(date=h.date + timedelta(days=1), name="Black Friday", country="us"),
Holiday(date=h.date + timedelta(days=4), name="Cyber Monday", country="us"),
]
return found + extra
def get_nyse_holidays(
start_date: date, end_date: date, us_hols: list[Holiday]
) -> list[Event]:
"""NYSE holidays."""
known_us_hols = {(h.date, h.name) for h in us_hols}
found: list[Event] = []
rename = {"Thanksgiving Day": "Thanksgiving"}
for year in range(start_date.year, end_date.year + 1):
hols = holidays.financial_holidays("NYSE", years=year)
found += [
Event(
name="holiday",
date=hol_date,
title=rename.get(title, title),
)
for hol_date, title in hols.items()
if start_date <= hol_date <= end_date
]
found = [hol for hol in found if (hol.date, hol.title) not in known_us_hols]
for hol in found:
assert hol.title
hol.title += " (NYSE)"
return found
def get_holidays(country: str, start_date: date, end_date: date) -> list[Holiday]:
"""Get holidays."""
found: list[Holiday] = []
uc_country = country.upper()
holiday_country = getattr(holidays, uc_country)
default_language = holiday_country.default_language
for year in range(start_date.year, end_date.year + 1):
en_hols = holidays.country_holidays(uc_country, years=year, language="en_US")
local_lang = holidays.country_holidays(
uc_country, years=year, language=default_language
)
found += [
Holiday(
date=hol_date,
name=title,
local_name=local_lang[hol_date],
country=country.lower(),
)
for hol_date, title in en_hols.items()
if start_date <= hol_date <= end_date
]
return found
def combine_holidays(holidays: list[Holiday]) -> list[Event]:
"""Combine UK and US holidays with the same date and title."""
all_countries = {h.country for h in holidays}
standard_name = {
(1, 1): "New Year's Day",
(1, 6): "Epiphany",
(5, 1): "Labour Day",
(8, 15): "Assumption Day",
(12, 8): "Immaculate conception",
(12, 25): "Christmas Day",
(12, 26): "Boxing Day",
}
combined: collections.defaultdict[tuple[date, str], set[str]] = (
collections.defaultdict(set)
)
for h in holidays:
assert isinstance(h.name, str) and isinstance(h.date, date)
event_key = (h.date, standard_name.get((h.date.month, h.date.day), h.name))
combined[event_key].add(h.country)
events: list[Event] = []
for (d, name), countries in combined.items():
if len(countries) == len(all_countries):
country_list = ""
elif len(countries) < len(all_countries) / 2:
country_list = ", ".join(sorted(country.upper() for country in countries))
else:
country_list = "not " + ", ".join(
sorted(country.upper() for country in all_countries - set(countries))
)
e = Event(
name="holiday",
date=d,
title=f"{name} ({country_list})" if country_list else name,
)
events.append(e)
return events
def get_all(last_year: date, next_year: date, data_dir: str) -> list[Holiday]:
"""Get holidays for various countries and return as a list."""
us_hols = us_holidays(last_year, next_year)
bank_holidays = agenda.uk_holiday.bank_holiday_list(last_year, next_year, data_dir)
holiday_list: list[Holiday] = bank_holidays + us_hols
for country in (
"at",
"be",
"br",
"ch",
"cz",
"de",
"dk",
"ee",
"es",
"fi",
"fr",
"gr",
"it",
"ke",
"nl",
"pl",
):
holiday_list += get_holidays(country, last_year, next_year)
return holiday_list

View file

@ -1,28 +0,0 @@
"""Send e-mail."""
import smtplib
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import flask
def send_mail(config: flask.config.Config, subject: str, body: str) -> None:
"""Send an e-mail."""
msg = EmailMessage()
msg["Subject"] = subject
msg["To"] = f"{config['NAME']} <{config['MAIL_TO']}>"
msg["From"] = f"{config['NAME']} <{config['MAIL_FROM']}>"
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid()
# Add extra mail headers
for header, value in config["MAIL_HEADERS"]:
msg[header] = value
msg.set_content(body)
s = smtplib.SMTP(config["SMTP_HOST"])
s.sendmail(config["MAIL_TO"], [config["MAIL_TO"]], msg.as_string())
s.quit()

View file

@ -4,7 +4,7 @@ import json
import os.path import os.path
from datetime import datetime from datetime import datetime
from .event import Event from .types import Event
def get_events(data_dir: str) -> list[Event]: def get_events(data_dir: str) -> list[Event]:
@ -21,7 +21,7 @@ def get_events(data_dir: str) -> list[Event]:
date=start, date=start,
end_date=end, end_date=end,
name="meetup", name="meetup",
title=item_event["title"], title="👥" + item_event["title"],
url=item_event["eventUrl"], url=item_event["eventUrl"],
) )
events.append(e) events.append(e)

View file

@ -1,93 +0,0 @@
"""Waste collection schedules."""
import os
import re
from collections import defaultdict
from datetime import date, datetime, time, timedelta
import httpx
import lxml.html
from . import uk_time
from .event import Event
from .utils import make_waste_dir
ttl_hours = 12
async def get_html(
data_dir: str, postcode: str, uprn: str, force_cache: bool = False
) -> str:
"""Get waste schedule."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(".html")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, "%Y-%m-%d_%H:%M.html")
delta = now - recent
if existing and (force_cache or delta < timedelta(hours=ttl_hours)):
return open(os.path.join(waste_dir, recent_filename)).read()
now_str = now.strftime("%Y-%m-%d_%H:%M")
filename = f"{waste_dir}/{now_str}.html"
forms_base_url = "https://forms.n-somerset.gov.uk"
url = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule"
async with httpx.AsyncClient() as client:
r = await client.post(
url,
data={
"PreviousHouse": "",
"PreviousPostcode": "-",
"Postcode": postcode,
"SelectedUprn": uprn,
},
)
form_post_html = r.text
pattern = r'<h2>Object moved to <a href="([^"]*)">here<\/a>\.<\/h2>'
m = re.search(pattern, form_post_html)
if m:
r = await client.get(forms_base_url + m.group(1))
html = r.text
open(filename, "w").write(html)
return html
def parse_waste_schedule_date(day_and_month: str) -> date:
"""Parse waste schedule date."""
today = date.today()
fmt = "%A %d %B %Y"
d = datetime.strptime(f"{day_and_month} {today.year}", fmt).date()
if d < today:
d = datetime.strptime(f"{day_and_month} {today.year + 1}", fmt).date()
return d
def parse(root: lxml.html.HtmlElement) -> list[Event]:
"""Parse waste schedule."""
tbody = root.find(".//table/tbody")
assert tbody is not None
by_date = defaultdict(list)
for e_service, e_next_date, e_following in tbody:
assert e_service.text and e_next_date.text and e_following.text
service = e_service.text
next_date = parse_waste_schedule_date(e_next_date.text)
following_date = parse_waste_schedule_date(e_following.text)
by_date[next_date].append(service)
by_date[following_date].append(service)
return [
Event(
name="waste_schedule",
date=uk_time(d, time(6, 30)),
title="Backwell: " + ", ".join(services),
)
for d, services in by_date.items()
]

View file

@ -1,60 +0,0 @@
"""Trip statistic functions."""
from collections import defaultdict
from typing import Counter, Mapping
from agenda.types import StrDict, Trip
def travel_legs(trip: Trip, stats: StrDict) -> None:
"""Calcuate stats for travel legs."""
for leg in trip.travel:
if leg["type"] == "flight":
stats.setdefault("flight_count", 0)
stats.setdefault("airlines", Counter())
stats["flight_count"] += 1
stats["airlines"][leg["airline_name"]] += 1
if leg["type"] == "train":
stats.setdefault("train_count", 0)
stats["train_count"] += 1
def conferences(trip: Trip, yearly_stats: Mapping[int, StrDict]) -> None:
"""Calculate conference stats."""
for c in trip.conferences:
yearly_stats[c["start"].year].setdefault("conferences", 0)
yearly_stats[c["start"].year]["conferences"] += 1
def calculate_yearly_stats(trips: list[Trip]) -> dict[int, StrDict]:
"""Calculate total distance and distance by transport type grouped by year."""
yearly_stats: defaultdict[int, StrDict] = defaultdict(dict)
for trip in trips:
year = trip.start.year
dist = trip.total_distance()
yearly_stats[year].setdefault("count", 0)
yearly_stats[year]["count"] += 1
conferences(trip, yearly_stats)
if dist:
yearly_stats[year]["total_distance"] = (
yearly_stats[year].get("total_distance", 0) + trip.total_distance()
)
for transport_type, distance in trip.distances_by_transport_type():
yearly_stats[year].setdefault("distances_by_transport_type", {})
yearly_stats[year]["distances_by_transport_type"][transport_type] = (
yearly_stats[year]["distances_by_transport_type"].get(transport_type, 0)
+ distance
)
for country in trip.countries:
if country.alpha_2 == "GB":
continue
yearly_stats[year].setdefault("countries", set())
yearly_stats[year]["countries"].add(country)
travel_legs(trip, yearly_stats[year])
return dict(yearly_stats)

View file

@ -3,14 +3,26 @@
from datetime import timedelta, timezone from datetime import timedelta, timezone
import dateutil.tz import dateutil.tz
import exchange_calendars # type: ignore import exchange_calendars
import pandas # type: ignore import pandas
from . import utils
here = dateutil.tz.tzlocal() here = dateutil.tz.tzlocal()
def timedelta_display(delta: timedelta) -> str:
"""Format timedelta as a human readable string."""
total_seconds = int(delta.total_seconds())
days, remainder = divmod(total_seconds, 24 * 60 * 60)
hours, remainder = divmod(remainder, 60 * 60)
mins, secs = divmod(remainder, 60)
return " ".join(
f"{v:>3} {label}"
for v, label in ((days, "days"), (hours, "hrs"), (mins, "mins"))
if v
)
def open_and_close() -> list[str]: def open_and_close() -> list[str]:
"""Stock markets open and close times.""" """Stock markets open and close times."""
# The trading calendars code is slow, maybe there is a faster way to do this # The trading calendars code is slow, maybe there is a faster way to do this
@ -28,11 +40,11 @@ def open_and_close() -> list[str]:
if cal.is_open_on_minute(now_local): if cal.is_open_on_minute(now_local):
next_close = cal.next_close(now).tz_convert(here) next_close = cal.next_close(now).tz_convert(here)
next_close = next_close.replace(minute=round(next_close.minute, -1)) next_close = next_close.replace(minute=round(next_close.minute, -1))
delta_close = utils.timedelta_display(next_close - now_local) delta_close = timedelta_display(next_close - now_local)
prev_open = cal.previous_open(now).tz_convert(here) prev_open = cal.previous_open(now).tz_convert(here)
prev_open = prev_open.replace(minute=round(prev_open.minute, -1)) prev_open = prev_open.replace(minute=round(prev_open.minute, -1))
delta_open = utils.timedelta_display(now_local - prev_open) delta_open = timedelta_display(now_local - prev_open)
msg = ( msg = (
f"{label:>6} market opened {delta_open} ago, " f"{label:>6} market opened {delta_open} ago, "
@ -42,7 +54,7 @@ def open_and_close() -> list[str]:
ts = cal.next_open(now) ts = cal.next_open(now)
ts = ts.replace(minute=round(ts.minute, -1)) ts = ts.replace(minute=round(ts.minute, -1))
ts = ts.tz_convert(here) ts = ts.tz_convert(here)
delta = utils.timedelta_display(ts - now_local) delta = timedelta_display(ts - now_local)
msg = f"{label:>6} market opens in {delta}" + ( msg = f"{label:>6} market opens in {delta}" + (
f" ({ts:%H:%M})" if (ts - now_local) < timedelta(days=1) else "" f" ({ts:%H:%M})" if (ts - now_local) < timedelta(days=1) else ""
) )

View file

@ -2,7 +2,7 @@
import yaml import yaml
from .event import Event from .types import Event
def get_events(filepath: str) -> list[Event]: def get_events(filepath: str) -> list[Event]:

View file

@ -3,7 +3,7 @@
import typing import typing
from datetime import datetime from datetime import datetime
import ephem # type: ignore import ephem
def bristol() -> ephem.Observer: def bristol() -> ephem.Observer:

View file

@ -5,43 +5,35 @@ import os
import typing import typing
from datetime import datetime from datetime import datetime
import requests import httpx
from .types import StrDict
from .utils import filename_timestamp, get_most_recent_file
Launch = dict[str, typing.Any] Launch = dict[str, typing.Any]
Summary = dict[str, typing.Any] Summary = dict[str, typing.Any]
ttl = 60 * 60 * 2 # two hours
LIMIT = 500 async def next_launch_api(rocket_dir: str, limit: int = 200) -> list[Launch]:
def next_launch_api_data(rocket_dir: str, limit: int = LIMIT) -> StrDict | None:
"""Get the next upcoming launches from the API.""" """Get the next upcoming launches from the API."""
now = datetime.now() now = datetime.now()
filename = os.path.join(rocket_dir, now.strftime("%Y-%m-%d_%H:%M:%S.json")) filename = os.path.join(rocket_dir, now.strftime("%Y-%m-%d_%H:%M:%S.json"))
url = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/" url = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/"
params: dict[str, str | int] = {"limit": limit} params: dict[str, str | int] = {"limit": limit}
r = requests.get(url, params=params) async with httpx.AsyncClient() as client:
try: r = await client.get(url, params=params)
data: StrDict = r.json()
except requests.exceptions.JSONDecodeError:
return None
open(filename, "w").write(r.text) open(filename, "w").write(r.text)
return data data = r.json()
def next_launch_api(rocket_dir: str, limit: int = LIMIT) -> list[Summary] | None:
"""Get the next upcoming launches from the API."""
data = next_launch_api_data(rocket_dir, limit)
if not data:
return None
return [summarize_launch(launch) for launch in data["results"]] return [summarize_launch(launch) for launch in data["results"]]
def filename_timestamp(filename: str) -> tuple[datetime, str] | None:
"""Get datetime from filename."""
try:
ts = datetime.strptime(filename, "%Y-%m-%d_%H:%M:%S.json")
except ValueError:
return None
return (ts, filename)
def format_time(time_str: str, net_precision: str) -> tuple[str, str | None]: def format_time(time_str: str, net_precision: str) -> tuple[str, str | None]:
"""Format time based on precision.""" """Format time based on precision."""
dt = datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ") dt = datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")
@ -124,7 +116,6 @@ def summarize_launch(launch: Launch) -> Summary:
return { return {
"name": launch.get("name"), "name": launch.get("name"),
"slug": launch["slug"],
"status": launch.get("status"), "status": launch.get("status"),
"net": launch.get("net"), "net": launch.get("net"),
"net_precision": net_precision, "net_precision": net_precision,
@ -135,7 +126,7 @@ def summarize_launch(launch: Launch) -> Summary:
"launch_provider": launch_provider, "launch_provider": launch_provider,
"launch_provider_abbrev": launch_provider_abbrev, "launch_provider_abbrev": launch_provider_abbrev,
"launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]), "launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]),
"rocket": launch["rocket"]["configuration"], "rocket": launch["rocket"]["configuration"]["full_name"],
"mission": launch.get("mission"), "mission": launch.get("mission"),
"mission_name": get_nested(launch, ["mission", "name"]), "mission_name": get_nested(launch, ["mission", "name"]),
"pad_name": launch["pad"]["name"], "pad_name": launch["pad"]["name"],
@ -143,39 +134,21 @@ def summarize_launch(launch: Launch) -> Summary:
"location": launch["pad"]["location"]["name"], "location": launch["pad"]["location"]["name"],
"country_code": launch["pad"]["country_code"], "country_code": launch["pad"]["country_code"],
"orbit": get_nested(launch, ["mission", "orbit"]), "orbit": get_nested(launch, ["mission", "orbit"]),
"probability": launch["probability"],
"weather_concerns": launch["weather_concerns"],
} }
def load_cached_launches(rocket_dir: str) -> StrDict | None: async def get_launches(rocket_dir: str, limit: int = 200) -> list[Summary]:
"""Read the most recent cache of launches."""
filename = get_most_recent_file(rocket_dir, "json")
return typing.cast(StrDict, json.load(open(filename))) if filename else None
def read_cached_launches(rocket_dir: str) -> list[Summary]:
"""Read cached launches."""
data = load_cached_launches(rocket_dir)
return [summarize_launch(launch) for launch in data["results"]] if data else []
def get_launches(
rocket_dir: str, limit: int = LIMIT, refresh: bool = False
) -> list[Summary] | None:
"""Get rocket launches with caching.""" """Get rocket launches with caching."""
now = datetime.now() now = datetime.now()
existing = [ existing = [x for x in (filename_timestamp(f) for f in os.listdir(rocket_dir)) if x]
x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x
]
existing.sort(reverse=True) existing.sort(reverse=True)
if refresh or not existing or (now - existing[0][0]).seconds > ttl: if not existing or (now - existing[0][0]).seconds > 3600: # one hour
try: try:
return next_launch_api(rocket_dir, limit=limit) return await next_launch_api(rocket_dir, limit=limit)
except Exception: except httpx.ReadTimeout:
pass # fallback to cached version pass
f = existing[0][1] f = existing[0][1]

View file

@ -1,79 +1,36 @@
"""Travel.""" """Travel."""
import decimal
import json
import os import os
import typing import typing
import flask
import yaml import yaml
from geopy.distance import geodesic # type: ignore
from .event import Event from .types import Event
from .types import StrDict
Leg = dict[str, str] Leg = dict[str, str]
TravelList = list[dict[str, typing.Any]] TravelList = list[dict[str, typing.Any]]
RouteDistances = dict[tuple[str, str], float]
def coords(airport: StrDict) -> tuple[float, float]:
"""Longitude / Latitude as coordinate tuples."""
# return (airport["longitude"], airport["latitude"])
return (airport["latitude"], airport["longitude"])
def flight_distance(f: StrDict) -> float:
"""Distance of flight."""
return float(geodesic(coords(f["from_airport"]), coords(f["to_airport"])).km)
def route_distances_as_json(route_distances: RouteDistances) -> str:
"""Format route distances as JSON string."""
return (
"[\n"
+ ",\n".join(
" " + json.dumps([s1, s2, dist])
for (s1, s2), dist in route_distances.items()
)
+ "\n]"
)
def parse_yaml(travel_type: str, data_dir: str) -> TravelList: def parse_yaml(travel_type: str, data_dir: str) -> TravelList:
"""Parse flights YAML and return list of travel.""" """Parse flights YAML and return list of travel."""
filepath = os.path.join(data_dir, travel_type + ".yaml") filepath = os.path.join(data_dir, travel_type + ".yaml")
items: TravelList = yaml.safe_load(open(filepath)) return typing.cast(TravelList, yaml.safe_load(open(filepath)))
if not all(isinstance(item, dict) for item in items):
return items
for item in items:
price = item.get("price")
if price:
item["price"] = decimal.Decimal(price)
return items
def get_flights(data_dir: str) -> list[Event]: def get_flights(data_dir: str) -> list[Event]:
"""Get travel events.""" """Get travel events."""
bookings = parse_yaml("flights", data_dir) return [
events = [] Event(
for booking in bookings: date=item["depart"],
for item in booking["flights"]: end_date=item.get("arrive"),
if not item["depart"].date(): name="transport",
continue title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})',
e = Event( url=item.get("url"),
date=item["depart"], )
end_date=item.get("arrive"), for item in parse_yaml("flights", data_dir)
name="transport", if item["depart"].date()
title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})', ]
url=(item.get("url") if flask.g.user.is_authenticated else None),
)
events.append(e)
return events
def get_trains(data_dir: str) -> list[Event]: def get_trains(data_dir: str) -> list[Event]:
@ -86,7 +43,7 @@ def get_trains(data_dir: str) -> list[Event]:
end_date=leg["arrive"], end_date=leg["arrive"],
name="transport", name="transport",
title=f'🚆 {leg["from"]} to {leg["to"]}', title=f'🚆 {leg["from"]} to {leg["to"]}',
url=(item.get("url") if flask.g.user.is_authenticated else None), url=item.get("url"),
) )
for leg in item["legs"] for leg in item["legs"]
] ]
@ -105,43 +62,3 @@ def flight_number(flight: Leg) -> str:
def all_events(data_dir: str) -> list[Event]: def all_events(data_dir: str) -> list[Event]:
"""Get all flights and rail journeys.""" """Get all flights and rail journeys."""
return get_trains(data_dir) + get_flights(data_dir) return get_trains(data_dir) + get_flights(data_dir)
def train_leg_distance(geojson_data: StrDict) -> float:
"""Calculate the total length of a LineString in kilometers from GeoJSON data."""
# Extract coordinates
first_object = geojson_data["features"][0]["geometry"]
assert first_object["type"] in ("LineString", "MultiLineString")
if first_object["type"] == "LineString":
coord_list = [first_object["coordinates"]]
else:
first_object["type"] == "MultiLineString"
coord_list = first_object["coordinates"]
total_length_km = 0.0
for coordinates in coord_list:
total_length_km += sum(
float(geodesic(coordinates[i], coordinates[i + 1]).km)
for i in range(len(coordinates) - 1)
)
return total_length_km
def load_route_distances(data_dir: str) -> RouteDistances:
"""Load cache of route distances."""
route_distances: RouteDistances = {}
with open(os.path.join(data_dir, "route_distances.json")) as f:
for s1, s2, dist in json.load(f):
route_distances[(s1, s2)] = dist
return route_distances
def add_leg_route_distance(leg: StrDict, route_distances: RouteDistances) -> None:
s1, s2 = sorted([leg["from"], leg["to"]])
dist = route_distances.get((s1, s2))
if dist:
leg["distance"] = dist

View file

@ -1,411 +0,0 @@
"""Trips."""
import decimal
import os
import typing
from datetime import date, datetime, time
from zoneinfo import ZoneInfo
import flask
import yaml
from agenda import travel
from agenda.types import StrDict, Trip
def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]:
"""Read flight and train journeys."""
items = travel.parse_yaml(plural, data_dir)
for item in items:
item["type"] = travel_type
return items
def add_station_objects(item: StrDict, by_name: dict[str, StrDict]) -> None:
"""Lookup stations and add to train or leg."""
item["from_station"] = by_name[item["from"]]
item["to_station"] = by_name[item["to"]]
def load_trains(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load trains."""
trains = load_travel("train", "trains", data_dir)
stations = travel.parse_yaml("stations", data_dir)
by_name = {station["name"]: station for station in stations}
for train in trains:
add_station_objects(train, by_name)
for leg in train["legs"]:
add_station_objects(leg, by_name)
if route_distances:
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 trains
def load_ferries(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load ferries."""
ferries = load_travel("ferry", "ferries", data_dir)
terminals = travel.parse_yaml("ferry_terminals", data_dir)
by_name = {terminal["name"]: terminal for terminal in terminals}
for item in ferries:
assert item["from"] in by_name and item["to"] in by_name
from_terminal, to_terminal = by_name[item["from"]], by_name[item["to"]]
item["from_terminal"] = from_terminal
item["to_terminal"] = to_terminal
if route_distances:
travel.add_leg_route_distance(item, route_distances)
geojson = from_terminal["routes"].get(item["to"])
if geojson:
item["geojson_filename"] = geojson
return ferries
def depart_datetime(item: StrDict) -> datetime:
"""Return a datetime for this travel item.
If the travel item already has a datetime return that, otherwise if the
departure time is just a date return midnight UTC for that date.
"""
depart = item["depart"]
if isinstance(depart, datetime):
return depart
return datetime.combine(depart, time.min).replace(tzinfo=ZoneInfo("UTC"))
def process_flight(
flight: StrDict, iata: dict[str, str], airports: list[StrDict]
) -> None:
"""Add airport detail, airline name and distance to flight."""
if flight["from"] in airports:
flight["from_airport"] = airports[flight["from"]]
if flight["to"] in airports:
flight["to_airport"] = airports[flight["to"]]
if "airline" in flight:
flight["airline_name"] = iata.get(flight["airline"], "[unknown]")
flight["distance"] = travel.flight_distance(flight)
def load_flight_bookings(data_dir: str) -> list[StrDict]:
"""Load flight bookings."""
bookings = load_travel("flight", "flights", data_dir)
airlines = yaml.safe_load(open(os.path.join(data_dir, "airlines.yaml")))
iata = {a["iata"]: a["name"] for a in airlines}
airports = travel.parse_yaml("airports", data_dir)
for booking in bookings:
for flight in booking["flights"]:
process_flight(flight, iata, airports)
return bookings
def load_flights(flight_bookings: list[StrDict]) -> list[StrDict]:
"""Load flights."""
flights = []
for booking in flight_bookings:
for flight in booking["flights"]:
for f in "type", "trip", "booking_reference", "price", "currency":
if f in booking:
flight[f] = booking[f]
flights.append(flight)
return flights
def collect_travel_items(
flight_bookings: list[StrDict],
data_dir: str | None = None,
route_distances: travel.RouteDistances | None = None,
) -> list[StrDict]:
"""Generate list of trips."""
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
return sorted(
load_flights(load_flight_bookings(data_dir))
+ load_trains(data_dir, route_distances=route_distances)
+ load_ferries(data_dir, route_distances=route_distances),
key=depart_datetime,
)
def group_travel_items_into_trips(
data: StrDict, yaml_trip_list: list[StrDict]
) -> list[Trip]:
"""Group travel items into trips."""
trips: dict[date, Trip] = {}
yaml_trip_lookup = {item["trip"]: item for item in yaml_trip_list}
for key, item_list in data.items():
assert isinstance(item_list, list)
for item in item_list:
if not (start := item.get("trip")):
continue
if start not in trips:
from_yaml = yaml_trip_lookup.get(start, {})
trips[start] = Trip(
start=start, **{k: v for k, v in from_yaml.items() if k != "trip"}
)
getattr(trips[start], key).append(item)
return [trip for _, trip in sorted(trips.items())]
def build_trip_list(
data_dir: str | None = None,
route_distances: travel.RouteDistances | None = None,
) -> list[Trip]:
"""Generate list of trips."""
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
yaml_trip_list = travel.parse_yaml("trips", data_dir)
flight_bookings = load_flight_bookings(data_dir)
data = {
"flight_bookings": flight_bookings,
"travel": collect_travel_items(flight_bookings, data_dir, route_distances),
"accommodation": travel.parse_yaml("accommodation", data_dir),
"conferences": travel.parse_yaml("conferences", data_dir),
"events": travel.parse_yaml("events", data_dir),
}
for item in data["accommodation"]:
price = item.get("price")
if price:
item["price"] = decimal.Decimal(price)
return group_travel_items_into_trips(data, yaml_trip_list)
def add_coordinates_for_unbooked_flights(
routes: list[StrDict], coordinates: list[StrDict]
) -> None:
"""Add coordinates for flights that haven't been booked yet."""
if not (
any(route["type"] == "unbooked_flight" for route in routes)
and not any(pin["type"] == "airport" for pin in coordinates)
):
return
data_dir = flask.current_app.config["PERSONAL_DATA"]
airports = typing.cast(dict[str, StrDict], travel.parse_yaml("airports", data_dir))
lhr = airports["LHR"]
coordinates.append(
{
"name": lhr["name"],
"type": "airport",
"latitude": lhr["latitude"],
"longitude": lhr["longitude"],
}
)
def get_locations(trip: Trip) -> dict[str, StrDict]:
"""Collect locations of all travel locations in trip."""
locations: dict[str, StrDict] = {
"station": {},
"airport": {},
"ferry_terminal": {},
}
station_list = []
for t in trip.travel:
match t["type"]:
case "train":
station_list += [t["from_station"], t["to_station"]]
for leg in t["legs"]:
station_list.append(leg["from_station"])
station_list.append(leg["to_station"])
case "flight":
for field in "from_airport", "to_airport":
if field in t:
locations["airport"][t[field]["iata"]] = t[field]
case "ferry":
for field in "from_terminal", "to_terminal":
terminal = t[field]
locations["ferry_terminal"][terminal["name"]] = terminal
for s in station_list:
if s["name"] in locations["station"]:
continue
locations["station"][s["name"]] = s
return locations
def coordinate_dict(item: StrDict, coord_type: str) -> StrDict:
"""Build coodinate dict for item."""
return {
"name": item["name"],
"type": coord_type,
"latitude": item["latitude"],
"longitude": item["longitude"],
}
def collect_trip_coordinates(trip: Trip) -> list[StrDict]:
"""Extract and de-duplicate travel location coordinates from trip."""
coords = []
src = [
("accommodation", trip.accommodation),
("conference", trip.conferences),
("event", trip.events),
]
for coord_type, item_list in src:
coords += [
coordinate_dict(item, coord_type)
for item in item_list
if "latitude" in item and "longitude" in item
]
locations = get_locations(trip)
for coord_type, coord_dict in locations.items():
coords += [coordinate_dict(s, coord_type) for s in coord_dict.values()]
return coords
def latlon_tuple_prefer_airport(stop: StrDict, data_dir: str) -> tuple[float, float]:
airport_lookup = {
("Berlin", "de"): "BER",
("Hamburg", "de"): "HAM",
}
iata = airport_lookup.get((stop["location"], stop["country"]))
if not iata:
return latlon_tuple(stop)
airports = typing.cast(dict[str, StrDict], travel.parse_yaml("airports", data_dir))
return latlon_tuple(airports[iata])
def latlon_tuple(stop: StrDict) -> tuple[float, float]:
"""Given a transport stop return the lat/lon as a tuple."""
return (stop["latitude"], stop["longitude"])
def read_geojson(data_dir: str, filename: str) -> str:
"""Read GeoJSON from file."""
return open(os.path.join(data_dir, filename + ".geojson")).read()
def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]:
"""Get routes for given trip to show on map."""
routes: list[StrDict] = []
seen_geojson = set()
for t in trip.travel:
if t["type"] == "ferry":
ferry_from, ferry_to = t["from_terminal"], t["to_terminal"]
key = "_".join(["ferry"] + sorted([ferry_from["name"], ferry_to["name"]]))
filename = os.path.join("ferry_routes", t["geojson_filename"])
routes.append(
{
"type": "train",
"key": key,
"geojson_filename": filename,
}
)
continue
if t["type"] == "flight":
if "from_airport" not in t or "to_airport" not in t:
continue
fly_from, fly_to = t["from_airport"], t["to_airport"]
key = "_".join(["flight"] + sorted([fly_from["iata"], fly_to["iata"]]))
routes.append(
{
"type": "flight",
"key": key,
"from": latlon_tuple(fly_from),
"to": latlon_tuple(fly_to),
}
)
continue
assert t["type"] == "train"
for leg in t["legs"]:
train_from, train_to = leg["from_station"], leg["to_station"]
geojson_filename = train_from.get("routes", {}).get(train_to["name"])
key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]]))
if not geojson_filename:
routes.append(
{
"type": "train",
"key": key,
"from": latlon_tuple(train_from),
"to": latlon_tuple(train_to),
}
)
continue
if geojson_filename in seen_geojson:
continue
seen_geojson.add(geojson_filename)
routes.append(
{
"type": "train",
"key": key,
"geojson_filename": os.path.join("train_routes", geojson_filename),
}
)
if routes:
return routes
lhr = (51.4775, -0.461389)
return [
{
"type": "unbooked_flight",
"key": f'LHR_{item["location"]}_{item["country"]}',
"from": lhr,
"to": latlon_tuple_prefer_airport(item, data_dir),
}
for item in trip.conferences
if "latitude" in item
and "longitude" in item
and item["country"] not in ("gb", "be") # not flying to Belgium
]
def get_coordinates_and_routes(
trip_list: list[Trip], data_dir: str | None = None
) -> tuple[list[StrDict], list[StrDict]]:
"""Given a list of trips return the associated coordinates and routes."""
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
coordinates = []
seen_coordinates: set[tuple[str, str]] = set()
routes = []
seen_routes: set[str] = set()
for trip in trip_list:
for stop in collect_trip_coordinates(trip):
key = (stop["type"], stop["name"])
if key in seen_coordinates:
continue
coordinates.append(stop)
seen_coordinates.add(key)
for route in get_trip_routes(trip, data_dir):
if route["key"] in seen_routes:
continue
routes.append(route)
seen_routes.add(route["key"])
for route in routes:
if "geojson_filename" in route:
route["geojson"] = read_geojson(data_dir, route.pop("geojson_filename"))
return (coordinates, routes)

View file

@ -1,362 +1,104 @@
"""Types.""" """Types."""
import collections import dataclasses
import datetime import datetime
import functools
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 @dataclasses.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 utils.as_date(travel["depart"]) != self.start:
place = travel["from"]
if place not in titles:
titles.append(place)
if travel["depart"] and utils.as_date(travel["depart"]) != 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
@functools.cached_property
def show_flags(self) -> bool:
"""Show flags for international trips."""
return len(self.countries) != 1 or self.countries[0].name != "United Kingdom"
@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})" + (f" {c.flag}" if self.show_flags else "")
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: class Holiday:
"""Holiay.""" """Holiay."""
name: str name: str
country: str country: str
date: datetime.date date: datetime.date
local_name: str | None = None
@dataclasses.dataclass
class Event:
"""Event."""
name: str
date: datetime.date | datetime.datetime
end_date: datetime.date | datetime.datetime | None = None
title: str | None = None
url: str | None = None
going: bool | None = None
@property @property
def display_name(self) -> str: def as_datetime(self) -> datetime.datetime:
"""Format name for display.""" """Date/time of event."""
d = self.date
t0 = datetime.datetime.min.time()
return ( return (
f"{self.name} ({self.local_name})" d
if self.local_name and self.local_name != self.name if isinstance(d, datetime.datetime)
else self.name else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
) )
@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 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

View file

@ -3,38 +3,29 @@
import json import json
import os import os
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from time import time
import httpx import httpx
from dateutil.easter import easter from dateutil.easter import easter
from .types import Holiday, StrDict from .types import Holiday
url = "https://www.gov.uk/bank-holidays.json"
def json_filename(data_dir: str) -> str: async def bank_holiday_list(
"""Filename for cached bank holidays.""" start_date: date, end_date: date, data_dir: str
assert os.path.exists(data_dir) ) -> list[Holiday]:
return os.path.join(data_dir, "bank-holidays.json")
async def get_holiday_list(data_dir: str) -> list[StrDict]:
"""Download holiday list and save cache."""
filename = json_filename(data_dir)
async with httpx.AsyncClient() as client:
r = await client.get(url)
events: list[StrDict] = r.json()["england-and-wales"]["events"] # check valid
open(filename, "w").write(r.text)
return events
def bank_holiday_list(start_date: date, end_date: date, data_dir: str) -> list[Holiday]:
"""Date and name of the next UK bank holiday.""" """Date and name of the next UK bank holiday."""
filename = json_filename(data_dir) url = "https://www.gov.uk/bank-holidays.json"
filename = os.path.join(data_dir, "bank-holidays.json")
mtime = os.path.getmtime(filename)
if (time() - mtime) > 60 * 60 * 6: # six hours
async with httpx.AsyncClient() as client:
r = await client.get(url)
open(filename, "w").write(r.text)
events = json.load(open(filename))["england-and-wales"]["events"]
hols: list[Holiday] = [] hols: list[Holiday] = []
for event in json.load(open(filename))["england-and-wales"]["events"]: for event in events:
event_date = datetime.strptime(event["date"], "%Y-%m-%d").date() event_date = datetime.strptime(event["date"], "%Y-%m-%d").date()
if event_date < start_date: if event_date < start_date:
continue continue

View file

@ -1,120 +0,0 @@
"""Utility functions."""
import os
import typing
from datetime import date, datetime, timedelta, timezone
from time import time
def as_date(d: datetime | date) -> date:
"""Convert datetime to date."""
match d:
case datetime():
return d.date()
case date():
return d
case _:
raise TypeError(f"Unsupported type: {type(d)}")
def as_datetime(d: datetime | date) -> datetime:
"""Date/time of event."""
match d:
case datetime():
return d
case date():
return datetime.combine(d, datetime.min.time()).replace(tzinfo=timezone.utc)
case _:
raise TypeError(f"Unsupported type: {type(d)}")
def timedelta_display(delta: timedelta) -> str:
"""Format timedelta as a human readable string."""
total_seconds = int(delta.total_seconds())
days, remainder = divmod(total_seconds, 24 * 60 * 60)
hours, remainder = divmod(remainder, 60 * 60)
mins, secs = divmod(remainder, 60)
return " ".join(
f"{v} {label}"
for v, label in ((days, "days"), (hours, "hrs"), (mins, "mins"))
if v
)
def plural(value: int, unit: str) -> str:
"""Value + unit with unit written as singular or plural as appropriate."""
return f"{value} {unit}{'s' if value > 1 else ''}"
def human_readable_delta(future_date: date) -> str | None:
"""
Calculate the human-readable time delta for a given future date.
Args:
future_date (date): The future date as a datetime.date object.
Returns:
str: Human-readable time delta.
"""
# Ensure the input is a future date
if future_date <= date.today():
return None
# Calculate the delta
delta = future_date - date.today()
# Convert delta to a more human-readable format
months, days = divmod(delta.days, 30)
weeks, days = divmod(days, 7)
# Formatting the output
parts = [
plural(value, unit)
for value, unit in ((months, "month"), (weeks, "week"), (days, "days"))
if value > 0
]
return " ".join(parts) if parts else None
def filename_timestamp(filename: str, ext: str) -> tuple[datetime, str] | None:
"""Get datetime from filename."""
try:
ts = datetime.strptime(filename, f"%Y-%m-%d_%H:%M:%S.{ext}")
except ValueError:
return None
return (ts, filename)
def get_most_recent_file(directory: str, ext: str) -> str | None:
"""Get most recent file from directory."""
existing = [
x for x in (filename_timestamp(f, ext) for f in os.listdir(directory)) if x
]
if not existing:
return None
existing.sort(reverse=True)
return os.path.join(directory, existing[0][1])
def make_waste_dir(data_dir: str) -> None:
"""Make waste dir if missing."""
waste_dir = os.path.join(data_dir, "waste")
if not os.path.exists(waste_dir):
os.mkdir(waste_dir)
async def time_function(
name: str,
func: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, typing.Any]],
*args: typing.Any,
**kwargs: typing.Any,
) -> tuple[str, typing.Any, float, Exception | None]:
"""Time the execution of an asynchronous function."""
start_time, result, exception = time(), None, None
try:
result = await func(*args, **kwargs)
except Exception as e:
exception = e
end_time = time()
return name, result, end_time - start_time, exception

209
agenda/waste_schedule.py Normal file
View file

@ -0,0 +1,209 @@
"""Waste collection schedules."""
import json
import os
import re
import typing
from collections import defaultdict
from datetime import date, datetime, time, timedelta
import httpx
import lxml.html
from . import uk_time
from .types import Event
ttl_hours = 12
def make_waste_dir(data_dir: str) -> None:
"""Make waste dir if missing."""
waste_dir = os.path.join(data_dir, "waste")
if not os.path.exists(waste_dir):
os.mkdir(waste_dir)
async def get_html(data_dir: str, postcode: str, uprn: str) -> str:
"""Get waste schedule."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(".html")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, "%Y-%m-%d_%H:%M.html")
delta = now - recent
if existing and delta < timedelta(hours=ttl_hours):
return open(os.path.join(waste_dir, recent_filename)).read()
now_str = now.strftime("%Y-%m-%d_%H:%M")
filename = f"{waste_dir}/{now_str}.html"
forms_base_url = "https://forms.n-somerset.gov.uk"
# url2 = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule/ViewSchedule"
url = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule"
async with httpx.AsyncClient() as client:
r = await client.post(
url,
data={
"PreviousHouse": "",
"PreviousPostcode": "-",
"Postcode": postcode,
"SelectedUprn": uprn,
},
)
form_post_html = r.text
pattern = r'<h2>Object moved to <a href="([^"]*)">here<\/a>\.<\/h2>'
m = re.search(pattern, form_post_html)
if m:
r = await client.get(forms_base_url + m.group(1))
html = r.text
open(filename, "w").write(html)
return html
def parse_waste_schedule_date(day_and_month: str) -> date:
"""Parse waste schedule date."""
today = date.today()
this_year = today.year
date_format = "%A %d %B %Y"
d = datetime.strptime(f"{day_and_month} {this_year}", date_format).date()
if d < today:
d = datetime.strptime(f"{day_and_month} {this_year + 1}", date_format).date()
return d
def parse(root: lxml.html.HtmlElement) -> list[Event]:
"""Parse waste schedule."""
tbody = root.find(".//table/tbody")
assert tbody is not None
by_date = defaultdict(list)
for e_service, e_next_date, e_following in tbody:
assert e_service.text and e_next_date.text and e_following.text
service = e_service.text
next_date = parse_waste_schedule_date(e_next_date.text)
following_date = parse_waste_schedule_date(e_following.text)
by_date[next_date].append(service)
by_date[following_date].append(service)
return [
Event(
name="waste_schedule",
date=uk_time(d, time(6, 30)),
title="🗑️ Backwell: " + ", ".join(services),
)
for d, services in by_date.items()
]
BristolSchedule = list[dict[str, typing.Any]]
async def get_bristol_data(data_dir: str, uprn: str) -> BristolSchedule:
"""Get Bristol Waste schedule, with cache."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(f"_{uprn}.json")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H:%M_{uprn}.json")
delta = now - recent
def get_from_recent() -> BristolSchedule:
json_data = json.load(open(os.path.join(waste_dir, recent_filename)))
return typing.cast(BristolSchedule, json_data["data"])
if existing and delta < timedelta(hours=ttl_hours):
return get_from_recent()
try:
r = await get_bristol_gov_uk_data(uprn)
except httpx.ReadTimeout:
return get_from_recent()
with open(f'{waste_dir}/{now.strftime("%Y-%m-%d_%H:%M")}_{uprn}.json', "wb") as out:
out.write(r.content)
return typing.cast(BristolSchedule, r.json()["data"])
async def get_bristol_gov_uk_data(uprn: str) -> httpx.Response:
"""Get JSON from Bristol City Council."""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-GB,en;q=0.9",
"Connection": "keep-alive",
"Ocp-Apim-Subscription-Key": "47ffd667d69c4a858f92fc38dc24b150",
"Ocp-Apim-Trace": "true",
"Origin": "https://bristolcouncil.powerappsportals.com",
"Referer": "https://bristolcouncil.powerappsportals.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
"Sec-GPC": "1",
"User-Agent": UA,
}
_uprn = str(uprn).zfill(12)
async with httpx.AsyncClient(timeout=20) as client:
# Initialise form
payload = {"servicetypeid": "7dce896c-b3ba-ea11-a812-000d3a7f1cdc"}
response = await client.get(
"https://bristolcouncil.powerappsportals.com/completedynamicformunauth/",
headers=HEADERS,
params=payload,
)
host = "bcprdapidyna002.azure-api.net"
# Set the search criteria
payload = {"Uprn": "UPRN" + _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-llpg/DetailedLLPG",
headers=HEADERS,
json=payload,
)
# Retrieve the schedule
payload = {"uprn": _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-alloy/NextCollectionDates",
headers=HEADERS,
json=payload,
)
return response
async def get_bristol_gov_uk(start_date: date, data_dir: str, uprn: str) -> list[Event]:
"""Get waste collection schedule from Bristol City Council."""
data = await get_bristol_data(data_dir, uprn)
by_date: defaultdict[date, list[str]] = defaultdict(list)
for item in data:
service = item["containerName"]
service = "Recycling" if "Recycling" in service else service.partition(" ")[2]
for collection in item["collection"]:
for collection_date_key in ["nextCollectionDate", "lastCollectionDate"]:
d = date.fromisoformat(collection[collection_date_key][:10])
if d < start_date:
continue
if service not in by_date[d]:
by_date[d].append(service)
return [
Event(name="waste_schedule", date=d, title="🗑️ Bristol: " + ", ".join(services))
for d, services in by_date.items()
]

View file

View file

@ -1,28 +0,0 @@
{
"name": "agenda",
"version": "1.0.0",
"directories": {
"test": "tests"
},
"repository": {
"type": "git",
"url": "https://git.4angle.com/edward/agenda.git"
},
"license": "ISC",
"devDependencies": {
"copy-webpack-plugin": "^12.0.2",
"eslint": "^9.2.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@fullcalendar/core": "^6.1.11",
"@fullcalendar/daygrid": "^6.1.11",
"@fullcalendar/list": "^6.1.11",
"@fullcalendar/timegrid": "^6.1.11",
"bootstrap": "^5.3.3",
"es-module-shims": "^1.8.3",
"leaflet": "^1.9.4",
"leaflet.geodesic": "^2.7.1"
}
}

View file

@ -9,4 +9,3 @@ dateutil
ephem ephem
flask flask
requests requests
emoji

View file

@ -1,8 +1,8 @@
#!/usr/bin/python3 #!/usr/bin/python3
from flipflop import WSGIServer from flipflop import WSGIServer
import sys import sys
sys.path.append('/home/edward/src/agenda') # isort:skip sys.path.append('/home/edward/src/2021/agenda')
from web_view import app # isort:skip from web_view import app
if __name__ == '__main__': if __name__ == '__main__':
WSGIServer(app).run() WSGIServer(app).run()

View file

@ -1,177 +0,0 @@
if (![].at) {
Array.prototype.at = function(pos) { return this.slice(pos, pos + 1)[0] }
}
function emoji_icon(emoji) {
var iconStyle = "<div style='background-color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; justify-content: center; align-items: center; border: 1px solid black;'> <div style='font-size: 18px;'>" + emoji + "</div></div>";
return L.divIcon({
className: 'custom-div-icon',
html: iconStyle,
iconSize: [60, 60],
iconAnchor: [15, 15],
});
}
var icons = {
"station": emoji_icon("🚉"),
"airport": emoji_icon("✈️"),
"ferry_terminal": emoji_icon("🚢"),
"accommodation": emoji_icon("🏨"),
"conference": emoji_icon("🖥️"),
"event": emoji_icon("🍷"),
}
function build_map(map_id, coordinates, routes) {
var map = L.map(map_id).fitBounds(coordinates.map(station => [station.latitude, station.longitude]));
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
var markers = [];
var offset_lines = [];
function getIconBounds(latlng) {
let iconSize = 20; // Assuming the icon size as a square
if (!latlng) return null;
let pixel = map.project(latlng, map.getZoom());
let sw = map.unproject([pixel.x - iconSize / 2, pixel.y + iconSize / 2], map.getZoom());
let ne = map.unproject([pixel.x + iconSize / 2, pixel.y - iconSize / 2], map.getZoom());
return L.latLngBounds(sw, ne);
}
function calculateCentroid(markers) {
let latSum = 0, lngSum = 0, count = 0;
markers.forEach(marker => {
latSum += marker.getLatLng().lat;
lngSum += marker.getLatLng().lng;
count++;
});
return count > 0 ? L.latLng(latSum / count, lngSum / count) : null;
}
// Function to detect and group overlapping markers
function getOverlappingGroups() {
let groups = [];
let visited = new Set();
markers.forEach((marker, index) => {
if (visited.has(marker)) {
return;
}
let group = [];
let markerBounds = getIconBounds(marker.getLatLng());
markers.forEach((otherMarker) => {
if (marker !== otherMarker && markerBounds.intersects(getIconBounds(otherMarker.getLatLng()))) {
group.push(otherMarker);
visited.add(otherMarker);
}
});
if (group.length > 0) {
group.push(marker); // Add the original marker to the group
groups.push(group);
visited.add(marker);
}
});
return groups;
}
function displaceMarkers(group, zoom) {
const markerPixelSize = 30; // Width/height of the marker in pixels
let map = group[0]._map; // Assuming all markers are on the same map
let centroid = calculateCentroid(group);
let centroidPoint = map.project(centroid, zoom);
const radius = markerPixelSize; // Set radius for even distribution
const angleIncrement = (2 * Math.PI) / group.length; // Evenly space markers
group.forEach((marker, index) => {
let angle = index * angleIncrement;
let newX = centroidPoint.x + radius * Math.cos(angle);
let newY = centroidPoint.y + radius * Math.sin(angle);
let newPoint = L.point(newX, newY);
let newLatLng = map.unproject(newPoint, zoom);
// Store original position for polyline
let originalPos = marker.getLatLng();
marker.setLatLng(newLatLng);
marker.polyline = L.polyline([originalPos, newLatLng], {color: "gray", weight: 2}).addTo(map);
offset_lines.push(marker.polyline);
});
}
coordinates.forEach(function(item, index) {
let latlng = L.latLng(item.latitude, item.longitude);
let marker = L.marker(latlng, { icon: icons[item.type] }).addTo(map);
marker.bindPopup(item.name);
markers.push(marker);
});
map.on('zoomend', function() {
markers.forEach((marker, index) => {
marker.setLatLng([coordinates[index].latitude, coordinates[index].longitude]); // Reset position on zoom
if (marker.polyline) {
map.removeLayer(marker.polyline);
}
});
offset_lines.forEach(polyline => {
map.removeLayer(polyline);
});
let overlappingGroups = getOverlappingGroups();
// console.log(overlappingGroups); // Process or display groups as needed
overlappingGroups.forEach(group => displaceMarkers(group, map.getZoom()));
});
let overlappingGroups = getOverlappingGroups();
// console.log(overlappingGroups); // Process or display groups as needed
overlappingGroups.forEach(group => displaceMarkers(group, map.getZoom()));
// Draw routes
routes.forEach(function(route) {
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange"}[route.type];
var style = { weight: 3, opacity: 0.5, color: color };
if (route.geojson) {
L.geoJSON(JSON.parse(route.geojson), {
style: function(feature) { return style; }
}).addTo(map);
} else if (route.type === "flight" || route.type === "unbooked_flight") {
var flightPath = new L.Geodesic([[route.from, route.to]], style).addTo(map);
} else {
L.polyline([route.from, route.to], style).addTo(map);
}
});
var mapElement = document.getElementById(map_id);
document.getElementById('toggleMapSize').addEventListener('click', function() {
var mapElement = document.getElementById(map_id);
var isFullWindow = mapElement.classList.contains('full-window-map');
if (isFullWindow) {
mapElement.classList.remove('full-window-map');
mapElement.classList.add('half-map');
mapElement.style.position = 'relative';
} else {
mapElement.classList.add('full-window-map');
mapElement.classList.remove('half-map');
mapElement.style.position = '';
}
// Ensure the map adjusts to the new container size
map.invalidateSize();
});
return map;
}

View file

@ -1,12 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import trip_link, accommodation_row with context %}
{% block title %}Accommodation - Edward Betts{% endblock %}
{% block style %} {% block style %}
{% set column_count = 9 %}
<style> <style>
.grid-container { .grid-container {
display: grid; display: grid;
grid-template-columns: repeat({{ column_count }}, auto); grid-template-columns: repeat(6, auto);
gap: 10px; gap: 10px;
justify-content: start; justify-content: start;
} }
@ -16,18 +13,24 @@
} }
.heading { .heading {
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */ grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
} }
</style> </style>
{% endblock %} {% endblock %}
{% macro row(item, badge) %}
<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">{{ (item.to.date() - item.from.date()).days }}</div>
<div class="grid-item">{{ item.name }}</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.location }}</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 %} {% for item in item_list %}{{ row(item, badge) }}{% endfor %}
{{ accommodation_row(item, badge) }}
<div class="grid-item">{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}</div>
{% endfor %}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
@ -43,9 +46,7 @@
</ul> </ul>
<div class="grid-container"> <div class="grid-container">
{{ section("Current", current) }} {{ section("Accommodation", items) }}
{{ section("Future", future) }}
{{ section("Past", past) }}
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
{% block style %} {% block style %}
{% endblock %} {% endblock %}
@ -18,6 +18,6 @@
{% block nav %}{{ navbar() }}{% endblock %} {% block nav %}{{ navbar() }}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View file

@ -1,17 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import display_date %}
{% block title %}Birthdays - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Birthdays</h1>
<table class="w-auto table">
{% for event in items %}
<tr>
<td class="text-end">{{event.as_date.strftime("%a, %d, %b %Y")}}</td>
<td>{{ event.title }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -1,15 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import trip_link, conference_row with context %}
{% block title %}Conferences - Edward Betts{% endblock %}
{% block style %} {% block style %}
{% set column_count = 9 %}
<style> <style>
.grid-container { .grid-container {
display: grid; display: grid;
grid-template-columns: repeat({{ column_count }}, auto); /* 7 columns for each piece of information */ grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
gap: 10px; gap: 10px;
justify-content: start; justify-content: start;
} }
@ -19,31 +14,49 @@
} }
.heading { .heading {
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */ grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
} }
</style> </style>
{% endblock %} {% endblock %}
{% macro section(heading, item_list, badge) %} {% macro row(item, badge) %}
{% if item_list %} <div class="grid-item text-end">{{ item.start.strftime("%a, %d %b %Y") }}</div>
<div class="heading"><h2>{{ heading }}</h2></div> <div class="grid-item text-end">{{ item.end.strftime("%a, %d %b") }}</div>
{% for item in item_list %} <div class="grid-item">{{ item.name }}
{{ conference_row(item, badge) }} {% if item.going and not (item.accommodation_booked or item.travel_booked) %}
<div class="grid-item"> <span class="badge text-bg-primary">
{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %} {{ badge }}
</div> </span>
{% endfor %}
{% endif %} {% 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"><a href="{{ item.url }}">{{ item.url }}</a></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 %}
{% 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") }}
{{ section("Past", past|reverse|list, "went") }} {{ section("Past", past|reverse, "went") }}
</div> </div>
</div> </div>

View file

@ -1,175 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agenda - Edward Betts</title>
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<script async src="{{ url_for("static", filename="es-module-shims/es-module-shims.js") }}"></script>
</head>
{% set event_labels = {
"economist": "📰 The Economist",
"mothers_day": "Mothers' day",
"fathers_day": "Fathers' day",
"uk_financial_year_end": "End of financial year",
"bank_holiday": "UK bank holiday",
"us_holiday": "US holiday",
"uk_clock_change": "UK clock change",
"us_clock_change": "US clock change",
"us_presidential_election": "US pres. election",
"xmas_last_second": "Christmas last posting 2nd class",
"xmas_last_first": "Christmas last posting 1st class",
"up_series": "Up documentary",
"waste_schedule": "Waste schedule",
"gwr_advance_tickets": "GWR advance tickets",
"critical_mass": "Critical Mass",
}
%}
{%set class_map = {
"bank_holiday": "bg-success-subtle",
"conference": "bg-primary-subtle",
"us_holiday": "bg-secondary-subtle",
"birthday": "bg-info-subtle",
"waste_schedule": "bg-danger-subtle",
} %}
{% from "macros.html" import trip_link, display_date_no_year with context %}
{% from "navbar.html" import navbar with context %}
<body>
{{ navbar() }}
<div class="container-fluid mt-2">
<h1>Agenda</h1>
<p>
<a href="/tools">&larr; personal tools</a>
</p>
<ul>
<li>Today is {{now.strftime("%A, %-d %b %Y")}}</li>
{% if gbpusd %}
<li>GBPUSD: {{"{:,.3f}".format(gbpusd)}}</li>
{% endif %}
<li>GWR advance ticket furthest date:
{% if gwr_advance_tickets %}
{{ gwr_advance_tickets.strftime("%A, %-d %b %Y") }}
{% else %}
unknown
{% endif %}
</li>
<li>Bristol Sunrise: {{ sunrise.strftime("%H:%M:%S") }} /
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
</ul>
{% if errors %}
{% for error in errors %}
<div class="alert alert-danger" role="alert">
Error: {{ error }}
</div>
{% endfor %}
{% endif %}
<h3>Stock markets</h3>
{% for market in stock_markets %}
<p>{{ market }}</p>
{% endfor %}
{% if current_trip %}
{% set end = current_trip.end %}
<div>
<div>Current trip: {{ trip_link(current_trip) }}</div>
{% if end %}
<div>Dates: {{ display_date_no_year(current_trip.start) }} to {{ display_date_no_year(end) }}</div>
{% else %}
<div>Start: {{ display_date_no_year(current_trip.start) }} (end date missing)</div>
{% endif %}
</div>
{% endif %}
<h3>Agenda</h3>
<div>
Markets:
<a href="{{ url_for(request.endpoint) }}">Hide while away</a>
| <a href="{{ url_for(request.endpoint, markets="show") }}">Show all</a>
| <a href="{{ url_for(request.endpoint, markets="hide") }}">Hide all</a>
</div>
{% for event in events if start_event_list <= event.as_date <= end_event_list %}
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
<div class="row mt-2">
<div class="col">
<h4>{{ event.date.strftime("%B %Y") }}</h4>
</div>
</div>
{% endif %}
{% set delta = event.delta_days(today) %}
{% if event.name == "today" %}
<div class="row">
<div class="col bg-warning-subtle">
<h3>today</h3>
</div>
</div>
{% else %}
{% set cell_bg = " bg-warning-subtle" if delta == "today" else "" %}
<div class="row border border-1 {% if event.name in class_map %} {{ class_map[event.name]}}{% else %}{{ cell_bg }}{% endif %}">
<div class="col-md-2{{ cell_bg }}">
{{event.as_date.strftime("%a, %d, %b")}}
&nbsp;
&nbsp;
{{event.display_time or ""}}
&nbsp;
&nbsp;
{{event.display_timezone or ""}}
</div>
<div class="col-md-2{{ cell_bg }}">
{% if event.end_date %}
{% set duration = event.display_duration() %}
{% if duration %}
end: {{event.end_date.strftime("%H:%M") }}
(duration: {{duration}})
{% elif event.end_date != event.date %}
{{event.end_date}}
{% endif %}
{% endif %}
</div>
<div class="col-md-7 text-start">
{% if event.url %}<a href="{{ event.url }}">{% endif %}
{{ event_labels.get(event.name) or event.name }}
{%- if event.title -%}: {{ event.title_with_emoji }}{% endif %}
{% if event.url %}</a>{% endif %}
</div>
<div class="col-md-1{{ cell_bg }}">
{{ delta }}
</div>
</div>
{% endif %}
{% endfor %}
<div class="mt-2">
<h5>Page generation time</h5>
<ul>
<li>Data gather took {{ "%.1f" | format(data_gather_seconds) }} seconds</li>
<li>Stock market open/close took
{{ "%.1f" | format(stock_market_times_seconds) }} seconds</li>
{% for name, seconds in timings %}
<li>{{ name }} took {{ "%.1f" | format(seconds) }} seconds</li>
{% endfor %}
<li>Render time: {{ "%.1f" | format(render_time) }} seconds</li>
</ul>
</div>
</div>
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
</body>
</html>

View file

@ -1,5 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Gaps - Edward Betts{% endblock %}
{% block content %} {% block content %}
<div class="p-2"> <div class="p-2">
@ -18,31 +17,11 @@
<tbody> <tbody>
{% for gap in gaps %} {% for gap in gaps %}
<tr> <tr>
<td class="text-start"> <td>{% for event in gap.before %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
{% for event in gap.before %}
<div>
{% if event.url %}
<a href="{{event.url}}">{{ event.title_with_emoji }}</a>
{% else %}
{{ event.title_with_emoji }}
{% endif %}
</div>
{% endfor %}
</td>
<td class="text-end text-nowrap">{{ gap.start.strftime("%A, %-d %b %Y") }}</td> <td class="text-end text-nowrap">{{ gap.start.strftime("%A, %-d %b %Y") }}</td>
<td class="text-end text-nowrap">{{ (gap.end - gap.start).days }} days</td> <td class="text-end text-nowrap">{{ (gap.end - gap.start).days }} days</td>
<td class="text-end text-nowrap">{{ gap.end.strftime("%A, %-d %b %Y") }}</td> <td class="text-end text-nowrap">{{ gap.end.strftime("%A, %-d %b %Y") }}</td>
<td class="text-start"> <td>{% for event in gap.after %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
{% for event in gap.after %}
<div>
{% if event.url %}
<a href="{{event.url}}">{{ event.title_with_emoji }}</a>
{% else %}
{{ event.title_with_emoji }}
{% endif %}
</div>
{% endfor %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -1,24 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import display_date %}
{% block title %}Holidays - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Holidays</h1>
<table class="table table-hover w-auto">
{% for item in items %}
{% set country = get_country(item.country) %}
<tr>
{% if loop.first or item.date != loop.previtem.date %}
<td class="text-end">{{ display_date(item.date) }}</td>
<td>in {{ (item.date - today).days }} days</td>
{% else %}
<td colspan="2"></td>
{% endif %}
<td>{{ country.flag }} {{ country.name }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agenda - Edward Betts</title> <title>Agenda</title>
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<script async src="{{ url_for("static", filename="es-module-shims/es-module-shims.js") }}"></script> <script async src="https://unpkg.com/es-module-shims@1.8.2/dist/es-module-shims.js"></script>
<script type='importmap'> <script type='importmap'>
{ {
@ -111,37 +111,84 @@
<a href="/tools">&larr; personal tools</a> <a href="/tools">&larr; personal tools</a>
</p> </p>
{% if errors %} <ul>
{% for error in errors %} <li>Today is {{now.strftime("%A, %-d %b %Y")}}</li>
<div class="alert alert-danger" role="alert"> {% if gbpusd %}
Error: {{ error }} <li>GBPUSD: {{"{:,.3f}".format(gbpusd)}}</li>
</div> {% endif %}
{% endfor %} <li>GWR advance ticket furthest date:
{% endif %} {% if gwr_advance_tickets %}
{{ gwr_advance_tickets.strftime("%A, %-d %b %Y") }}
{% else %}
unknown
{% endif %}
</li>
<li>Bristol Sunrise: {{ sunrise.strftime("%H:%M:%S") }} /
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
</ul>
<div> <h3>Stock markets</h3>
Markets: {% for market in stock_markets %}
<a href="{{ url_for(request.endpoint) }}">Hide while away</a> <p>{{ market }}</p>
| <a href="{{ url_for(request.endpoint, markets="show") }}">Show all</a> {% endfor %}
| <a href="{{ url_for(request.endpoint, markets="hide") }}">Hide all</a>
</div>
<div class="mb-3" id="calendar"></div> <div class="mb-3" id="calendar"></div>
<div class="mt-2"> <h3>Agenda</h3>
<h5>Page generation time</h5>
<ul>
<li>Data gather took {{ "%.1f" | format(data_gather_seconds) }} seconds</li>
<li>Stock market open/close took
{{ "%.1f" | format(stock_market_times_seconds) }} seconds</li>
{% for name, seconds in timings %}
<li>{{ name }} took {{ "%.1f" | format(seconds) }} seconds</li>
{% endfor %}
</ul> {% for event in events if event.as_date >= two_weeks_ago %}
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
<div class="row mt-2">
<div class="col">
<h4>{{ event.date.strftime("%B %Y") }}</h4>
</div>
</div> </div>
{% endif %}
{% set delta = event.delta_days(today) %}
{% if event.name == "today" %}
<div class="row">
<div class="col bg-warning-subtle">
<h3>today</h3>
</div>
</div>
{% else %}
{% set cell_bg = " bg-warning-subtle" if delta == "today" else "" %}
<div class="row border border-1 {% if event.name in class_map %} {{ class_map[event.name]}}{% else %}{{ cell_bg }}{% endif %}">
<div class="col-md-2{{ cell_bg }}">
{{event.as_date.strftime("%a, %d, %b")}}
&nbsp;
&nbsp;
{{event.display_time or ""}}
&nbsp;
&nbsp;
{{event.display_timezone or ""}}
</div>
<div class="col-md-2{{ cell_bg }}">
{% if event.end_date %}
{% if event.end_as_date == event.as_date and event.has_time %}
end: {{event.end_date.strftime("%H:%M") }}
(duration: {{event.end_date - event.date}})
{% elif event.end_date != event.date %}
{{event.end_date}}
{% endif %}
{% endif %}
</div>
<div class="col-md-7 text-start">
{% if event.url %}<a href="{{ event.url }}">{% endif %}
{{ event_labels.get(event.name) or event.name }}
{%- if event.title -%}: {{ event.title }}{% endif %}
{% if event.url %}</a>{% endif %}
</div>
<div class="col-md-1{{ cell_bg }}">
{{ delta }}
</div>
</div>
{% endif %}
{% endfor %}
</div> </div>
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View file

@ -1,63 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Space launches - Edward Betts{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid mt-2"> <div class="container-fluid mt-2">
<h1>Space launches</h1> <h1>Space launches</h1>
<h4>Filters</h4> {% for launch in rockets %}
<div class="row">
<p>Mission type:
{% if request.args.type %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for t in mission_types | sort %}
{% if t == request.args.type %}
<strong>{{ t }}</strong>
{% else %}
<a href="?type={{ t }}" class="text-nowrap">
{{ t }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
<p>Vehicle:
{% if request.args.rocket %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for r in rockets | sort %}
{% if r == request.args.rockets %}
<strong>{{ r }}</strong>
{% else %}
<a href="?rocket={{ r }}" class="text-nowrap">
{{ r }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
<p>Orbit:
{% if request.args.orbit %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for name, abbrev in orbits | sort %}
{% if abbrev == request.args.orbit %}
<strong>{{ name }}</strong>
{% else %}
<a href="?orbit={{ abbrev }}" class="text-nowrap">
{{ name }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
{% for launch in launches %}
{% set highlight =" bg-primary-subtle" if launch.slug in config.FOLLOW_LAUNCHES else "" %}
{% set country = get_country(launch.country_code) %}
<div class="row{{highlight}}">
<div class="col-md-1 text-nowrap text-md-end">{{ launch.t0_date }} <div class="col-md-1 text-nowrap text-md-end">{{ launch.t0_date }}
<br class="d-none d-md-block"/> <br class="d-none d-md-block"/>
@ -68,12 +16,8 @@
<div class="col-md-1 text-md-nowrap"> <div class="col-md-1 text-md-nowrap">
<span class="d-md-none">launch status:</span> <span class="d-md-none">launch status:</span>
<abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr> <abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr>
{% if launch.probability %}{{ launch.probability }}%{% endif %}
</div> </div>
<div class="col"> <div class="col">{{ launch.rocket }}
<div>
<abbr title="{{ country.name }}">{{ country.flag }}</abbr>
{{ launch.rocket.full_name }}
&ndash; &ndash;
<strong>{{launch.mission.name }}</strong> <strong>{{launch.mission.name }}</strong>
&ndash; &ndash;
@ -86,29 +30,14 @@
({{ launch.launch_provider_type }}) ({{ launch.launch_provider_type }})
&mdash; &mdash;
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }}) {{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
&mdash; <br/>
{{ launch.mission.type }} {% if launch.pad_wikipedia_url %}
</div> <a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
<div> {% else %}
{% if launch.pad_wikipedia_url %} {{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
{% else %}
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
{% endif %}
&mdash; {{ launch.location }}
</div>
{% if launch.mission.agencies | count %}
<div>
{% for agency in launch.mission.agencies %}
{% set agency_country = get_country(agency.country_code) %}
{%- if not loop.first %}, {% endif %}
<a href="{{ agency.wiki_url }}">{{agency.name }}</a>
<abbr title="{{ agency_country.name }}">{{ agency_country.flag }}</abbr>
({{ agency.type }}) {# <img src="{{ agency.logo_url }}"/> #}
{% endfor %}
</div>
{% endif %} {% endif %}
<div> &mdash; {{ launch.location }}<br/>
{% if launch.mission %} {% if launch.mission %}
{% for line in launch.mission.description.splitlines() %} {% for line in launch.mission.description.splitlines() %}
<p>{{ line }}</p> <p>{{ line }}</p>
@ -116,13 +45,7 @@
{% else %} {% else %}
<p>No description.</p> <p>No description.</p>
{% endif %} {% endif %}
{% if launch.weather_concerns %}
<h4>Weather concerns</h4>
{% for line in launch.weather_concerns.splitlines() %}
<p>{{ line }}</p>
{% endfor %}
{% endif %}
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -1,267 +0,0 @@
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% macro display_time(dt) %}
{% if dt %}{{ dt.strftime("%H:%M %z") }}{% endif %}
{% endmacro %}
{% macro display_date(dt) %}{{ dt.strftime("%a %-d %b %Y") }}{% endmacro %}
{% macro display_date_no_year(dt) %}{{ dt.strftime("%a %-d %b") }}{% endmacro %}
{% macro format_distance(distance) %}
{{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }}
{% endmacro %}
{% macro trip_link(trip) %}
<a href="{{ url_for("trip_page", start=trip.start.isoformat()) }}">{{ trip.title }}</a>
{% endmacro %}
{% macro conference_row(item, badge, show_flags=True) %}
{% 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 text-end">
{% if item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,d}".format(item.price | int) }} {{ item.currency }}</span>
{% if item.currency != "GBP" and item.currency in fx_rate %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% elif item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% endif %}
</div>
<div class="grid-item">{{ item.topic }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item text-end">{{ display_date(item.cfp_end) if item.cfp_end else "" }}</div>
<div class="grid-item">
{% if country %}
{% if show_flags %}{{ country.flag }}{% endif %} {{ 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, show_flags=True) %}
{% 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 %}
{% if show_flags %}{{ country.flag }}{% endif %} {{ 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 g.user.is_authenticated and item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro flight_booking_row(booking, show_flags=True) %}
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ booking.booking_reference or "reference missing" }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and booking.price and booking.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(booking.price) }} {{ booking.currency }}</span>
{% if booking.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(booking.price / fx_rate[booking.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% for i in range(8) %}
<div class="grid-item"></div>
{% endfor %}
{% for item in booking.flights %}
{% set full_flight_number = item.airline + item.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<div class="grid-item"></div>
<div class="grid-item"></div>
<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">{{ full_flight_number }}</div>
<div class="grid-item">
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
{% endfor %}
{% endmacro %}
{% macro flight_row(item) %}
{% set full_flight_number = item.airline + item.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<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">{{ full_flight_number }}</div>
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ item.booking_reference }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item">
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro train_row(item) %}
{% set url = item.url %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">
{% if g.user.is_authenticated and item.url %}<a href="{{ url }}">{% endif %}
{{ item.from }} &rarr; {{ item.to }}
{% if g.user.is_authenticated and item.url %}</a>{% endif %}
</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.depart != item.arrive and 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">
{% if g.user.is_authenticated %}
{{ item.booking_reference }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item">
{% for leg in item.legs %}
{% if leg.url %}
<a href="{{ leg.url }}">[{{ loop.index }}]</a>
{% endif %}
{% endfor %}
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" and item.currency in fx_rate %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro ferry_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.depart != item.arrive and item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item"></div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{# <div class="grid-item">{{ item | pprint }}</div> #}
{% endmacro %}

View file

@ -2,21 +2,12 @@
{% set pages = [ {% set pages = [
{"endpoint": "index", "label": "Home" }, {"endpoint": "index", "label": "Home" },
{"endpoint": "recent", "label": "Recent" }, {"endpoint": "conference_list", "label": "Conference" },
{"endpoint": "calendar_page", "label": "Calendar" },
{"endpoint": "trip_future_list", "label": "Future trips" },
{"endpoint": "trip_past_list", "label": "Past trips" },
{"endpoint": "conference_list", "label": "Conferences" },
{"endpoint": "past_conference_list", "label": "Past conferences" },
{"endpoint": "travel_list", "label": "Travel" }, {"endpoint": "travel_list", "label": "Travel" },
{"endpoint": "accommodation_list", "label": "Accommodation" }, {"endpoint": "accommodation_list", "label": "Accommodation" },
{"endpoint": "gaps_page", "label": "Gaps" }, {"endpoint": "gaps_page", "label": "Gaps" },
{"endpoint": "weekends", "label": "Weekends" },
{"endpoint": "launch_list", "label": "Space launches" }, {"endpoint": "launch_list", "label": "Space launches" },
{"endpoint": "holiday_list", "label": "Holidays" }, ] %}
] + ([{"endpoint": "birthday_list", "label": "Birthdays" }]
if g.user.is_authenticated else [])
%}
<nav class="navbar navbar-expand-md bg-success" data-bs-theme="dark"> <nav class="navbar navbar-expand-md bg-success" data-bs-theme="dark">
@ -36,13 +27,6 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<ul class="navbar-nav ms-auto">
{% if g.user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for("logout", next=request.url) }}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{{ url_for("login", next=request.url) }}">Login</a></li>
{% endif %}
</ul>
</div> </div>
</div> </div>
</nav> </nav>

View file

@ -1,7 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Agenda error - Edward Betts{% endblock %}
{% block style %} {% block style %}
<link rel="stylesheet" href="{{url_for('static', filename='css/exception.css')}}" /> <link rel="stylesheet" href="{{url_for('static', filename='css/exception.css')}}" />
{% endblock %} {% endblock %}

View file

@ -1,23 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import flight_booking_row, train_row with context %}
{% block title %}Travel - Edward Betts{% endblock %} {% block travel %}
{% endblock %}
{% set flight_column_count = 10 %} {% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% set column_count = 10 %} {% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
{% block style %} {% block style %}
<style> <style>
.grid-container { .grid-container {
display: grid; display: grid;
grid-template-columns: repeat({{ flight_column_count }}, auto); grid-template-columns: repeat(7, auto); /* 7 columns for each piece of information */
gap: 10px; gap: 10px;
justify-content: start; justify-content: start;
} }
.train-grid-container { .train-grid-container {
display: grid; display: grid;
grid-template-columns: repeat({{ column_count }}, auto); grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
gap: 10px; gap: 10px;
justify-content: start; justify-content: start;
} }
@ -37,19 +37,27 @@
<h3>flights</h3> <h3>flights</h3>
<div class="grid-container"> <div class="grid-container">
<div class="grid-item">reference</div>
<div class="grid-item">price</div>
<div class="grid-item text-end">date</div> <div class="grid-item text-end">date</div>
<div class="grid-item">route</div> <div class="grid-item">route</div>
<div class="grid-item">take-off</div> <div class="grid-item">take-off</div>
<div class="grid-item">land</div> <div class="grid-item">land</div>
<div class="grid-item">duration</div> <div class="grid-item">duration</div>
<div class="grid-item">flight</div> <div class="grid-item">flight</div>
<div class="grid-item">tracking</div> <div class="grid-item">reference</div>
<div class="grid-item">distance</div>
{% for item in flights %} {% for item in flights | sort(attribute="depart") if item.arrive %}
{{ flight_booking_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>
{% endfor %} {% endfor %}
</div> </div>
@ -60,15 +68,21 @@
<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>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item"></div>
{% for item in trains | sort(attribute="depart") %} {% for item in trains | sort(attribute="depart") if item.arrive %}
{{ 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.operator }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,230 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, display_date_no_year, display_date, display_datetime, display_time, format_distance with context %}
{% block title %}{{ heading }} - Edward Betts{% endblock %}
{% block style %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
<style>
body, html {
height: 100%;
margin: 0;
}
.container-fluid {
height: calc(100% - 56px); /* Subtracting the height of the navbar */
}
.text-content {
overflow-y: scroll;
height: 100%;
}
.map-container {
position: sticky;
top: 56px; /* Adjust to be below the navbar */
height: calc(100vh - 56px); /* Subtracting the height of the navbar */
}
#map {
height: 100%;
}
@media (max-width: 767.98px) {
.container-fluid {
display: block;
height: auto;
}
.map-container {
position: relative;
top: 0;
height: 50vh; /* Adjust as needed */
}
.text-content {
height: auto;
overflow-y: auto;
}
}
</style>
{% endblock %}
{% macro flag(trip, flag) %}{% if trip.show_flags %}{{ flag }}{% endif %}{% endmacro %}
{% macro section(heading, item_list) %}
{% if item_list %}
{% set items = item_list | list %}
<div class="heading"><h2>{{ heading }}</h2></div>
<p><a href="{{ url_for("trip_stats") }}">Trip statistics</a></p>
<p>{{ items | count }} trips</p>
<div>Total distance: {{ format_distance(total_distance) }}</div>
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% for trip in items %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% set total_distance = trip.total_distance() %}
{% set end = trip.end %}
<div class="border border-2 rounded mb-2 p-2">
<h3>
{{ trip_link(trip) }}
<small class="text-muted">({{ display_date(trip.start) }})</small></h3>
<ul class="list-unstyled">
{% for c in trip.countries %}
<li>
{{ c.name }}
{{ c.flag }}
</li>
{% endfor %}
</ul>
{% if end %}
<div>Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
{% if g.user.is_authenticated and trip.start <= today %}
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{trip.start}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{end}}T23%3A59%3A59.999Z%22%7D">photos</a>
{% endif %}
</div>
{% else %}
<div>Start: {{ display_date_no_year(trip.start) }} (end date missing)</div>
{% endif %}
{% if total_distance %}
<div>
Total distance:
{{ format_distance(total_distance) }}
</div>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% endif %}
{{ conference_list(trip) }}
{% for day, elements in trip.elements_grouped_by_day() %}
<h4>{{ display_date_no_year(day) }}
{% if g.user.is_authenticated and day <= today %}
<span class="lead">
<a href="https://photos.4angle.com/search?query=%7B%22takenAfter%22%3A%22{{day}}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22{{day}}T23%3A59%3A59.999Z%22%7D">photos</a>
</span>
{% endif %}
</h4>
{% set accommodation_label = {"check-in": "check-in from", "check-out": "check-out by"} %}
{% for e in elements %}
{% if e.element_type in accommodation_label %}
{% set c = get_country(e.detail.country) %}
<div>
{{ e.get_emoji() }} {{ e.title }} {{ flag(trip, c.flag) }}
({{ accommodation_label[e.element_type] }} {{ display_time(e.start_time) }})
</div>
{% else %}
<div>
{{ e.get_emoji() }}
{{ display_time(e.start_time) }}
&ndash;
{{ e.start_loc }} {{ flag(trip, e.start_country.flag) }}
{{ display_time(e.end_time) }}
&ndash;
{{ e.end_loc }} {{ flag(trip, e.end_country.flag) }}
{% if e.element_type == "flight" %}
{% set full_flight_number = e.detail.airline + e.detail.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<span class="text-nowrap"><strong>airline:</strong> {{ e.detail.airline_name }}</span>
<span class="text-nowrap"><strong>flight number:</strong> {{ e.detail.airline }}{{ e.detail.flight_number }}</span>
{% if e.detail.duration %}
<span class="text-nowrap"><strong>duration:</strong> {{ e.detail.duration }}</span>
{% endif %}
{# <pre>{{ e.detail | pprint }}</pre> #}
{% endif %}
{% if e.detail.distance %}
<span class="text-nowrap"><strong>distance:</strong> {{ format_distance(e.detail.distance) }}</span>
{% endif %}
{% if e.element_type == "flight" %}
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro conference_list(trip) %}
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }}
</small>
</h5>
<p class="card-text">
Topic: {{ item.topic }}
| Venue: {{ item.venue }}
| Location: {{ item.location }}
{% if country %}
{{ country.flag }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if item.free %}
| <span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
| <span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% endmacro %}
{% block content %}
<div class="container-fluid d-flex flex-column flex-md-row">
<div class="map-container col-12 col-md-6 order-1 order-md-2">
<div id="map" class="map"></div>
</div>
<div class="text-content col-12 col-md-6 order-2 order-md-1 pe-3">
{{ section(heading, trips) }}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var coordinates = {{ coordinates | tojson }};
var routes = {{ routes | tojson }};
build_map("map", coordinates, routes);
</script>
{% endblock %}

View file

@ -1,50 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import format_distance with context %}
{% set heading = "Trip statistics" %}
{% block title %}{{ heading }} - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid">
<h1>Trip statistics</h1>
<div>Trips: {{ count }}</div>
<div>Conferences: {{ conferences }}</div>
<div>Total distance: {{ format_distance(total_distance) }}</div>
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% for year, year_stats in yearly_stats | dictsort %}
{% set countries = year_stats.countries | sort(attribute="name") %}
<h4>{{ year }}</h4>
<div>Trips in {{ year }}: {{ year_stats.count }}</div>
<div>Conferences in {{ year }}: {{ year_stats.conferences }}</div>
<div>{{ countries | count }} countries visited in {{ year }}:
{% for c in countries %}
<span class="text-nowrap">{{ c.flag }} {{ c.name }}</span>
{% endfor %}
</div>
<div>
Flight segments in {{ year }}: {{ year_stats.flight_count }}
[ by airline:
{% for airline, count in year_stats.airlines.most_common() %}
{{ airline }}: {{ count }}{% if not loop.last %},{% endif %}
{% endfor %} ]
</div>
<div>Trains segments in {{ year }}: {{ year_stats.train_count }}</div>
<div>Total distance in {{ year}}: {{ format_distance(year_stats.total_distance) }}</div>
{% for transport_type, distance in year_stats.distances_by_transport_type.items() %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% endfor %}
</div>
{% endblock %}

View file

@ -1,67 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row with context %}
{% set row = { "flight": flight_row, "train": train_row } %}
{% block style %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
{% set conference_column_count = 7 %}
{% set accommodation_column_count = 7 %}
{% set travel_column_count = 8 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
.map {
height: 80vh;
}
</style>
{% endblock %}
{% block content %}
<div class="p-2">
<h1>Trips</h1>
<p>{{ future | count }} trips</p>
{% for trip in future %}
{% set end = trip.end %}
<div>
{{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}:
{{ trip.title }} &mdash; {{ trip.locations_str }}
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -1,336 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ trip.title }} ({{ display_date(trip.start) }}) - Edward Betts{% endblock %}
{% from "macros.html" import trip_link, display_datetime, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row with context %}
{% set row = { "flight": flight_row, "train": train_row } %}
{% macro next_and_previous() %}
<p>
{% if prev_trip %}
previous: {{ trip_link(prev_trip) }} ({{ (trip.start - prev_trip.end).days }} days)
{% endif %}
{% if next_trip %}
next: {{ trip_link(next_trip) }} ({{ (next_trip.start - trip.end).days }} days)
{% endif %}
</p>
{% endmacro %}
{% block style %}
{% if coordinates %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
{% endif %}
{% set conference_column_count = 7 %}
{% set accommodation_column_count = 7 %}
{% set travel_column_count = 9 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
.half-map {
height: 90vh;
}
.full-window-map {
position: fixed; /* Make the map fixed position */
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 9999; /* Make sure it sits on top */
}
#toggleMapSize {
position: fixed; /* Fixed position */
top: 66px; /* 10px from the top */
right: 10px; /* 10px from the right */
z-index: 10000; /* Higher than the map's z-index */
}
</style>
{% endblock %}
{% set end = trip.end %}
{% set total_distance = trip.total_distance() %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% block content %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="m-3">
{{ next_and_previous() }}
<h1>{{ trip.title }}</h1>
<p class="lead">
{% if end %}
{{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
({{ (end - trip.start).days }} nights)
{% else %}
{{ display_date_no_year(trip.start) }} (end date missing)
{% endif %}
</p>
<div class="mb-3">
{# <div>Countries: {{ trip.countries_str }}</div> #}
<div>Locations: {{ trip.locations_str }}</div>
{% if total_distance %}
<div>Total distance:
{{ "{:,.0f} km / {:,.0f} miles".format(total_distance, total_distance / 1.60934) }}
</div>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>{{ transport_type | title }} distance:
{{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }}
</div>
{% endfor %}
{% endif %}
{% set delta = human_readable_delta(trip.start) %}
{% if delta %}
<div>How long until trip: {{ delta }}</div>
{% endif %}
</div>
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }}
</small>
</h5>
<p class="card-text">
<strong>Topic:</strong> {{ item.topic }}
<strong>Venue:</strong> {{ item.venue }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% for item in trip.accommodation %}
{% set country = get_country(item.country) if item.country else None %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.operator %}{{ item.operator }}: {% endif %}
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.from) }} to {{ display_date_no_year(item.to) }}
({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %})
</small>
</h5>
<p class="card-text">
<strong>Address:</strong> {{ item.address }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% if trip.flight_bookings %}
<h3>Flight bookings</h3>
{% for item in trip.flight_bookings %}
<div>
{{ item.flights | map(attribute="airline_name") | unique | join(" + ") }}
{% if g.user.is_authenticated and item.booking_reference %}
<strong>booking reference:</strong> {{ item.booking_reference }}
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% for item in trip.events %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.title }}</a>
<small class="text-muted">{{ display_date_no_year(item.date) }}</small>
</h5>
<p class="card-text">
Address: {{ item.address }}
| Location: {{ item.location }}
{% if country %}
{{ country.flag if trip.show_flags }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
| <span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% for item in trip.travel %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.type == "flight" %}
✈️
{{ item.from_airport.name }} ({{ item.from_airport.iata}})
&rarr;
{{ item.to_airport.name }} ({{item.to_airport.iata}})
{% elif item.type == "train" %}
🚆
{{ item.from }}
&rarr;
{{ item.to }}
{% endif %}
</h5>
<p class="card-text">
{% if item.type == "flight" %}
<div>
<span>{{ item.airline_name }} ({{ item.airline }})</span>
{{ display_datetime(item.depart) }}
{% if item.arrive %}
&rarr;
{{ item.arrive.strftime("%H:%M %z") }}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% endif %}
<span>{{ item.airline }}{{ item.flight_number }}</span>
{% if item.distance %}
<span>
🌍distance:
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "train" %}
<div>
{{ display_datetime(item.depart) }}
&rarr;
{{ item.arrive.strftime("%H:%M %z") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.distance %}
<span>
🛤️
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% endif %}
</p>
</div>
</div>
{% endfor %}
<div class="mt-3">
<h4>Holidays</h4>
{% if holidays %}
<table class="table table-hover w-auto">
{% for item in holidays %}
{% set country = get_country(item.country) %}
<tr>
{% if loop.first or item.date != loop.previtem.date %}
<td class="text-end">{{ display_date(item.date) }}</td>
{% else %}
<td></td>
{% endif %}
<td>{{ country.flag if trip.show_flags }} {{ country.name }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No public holidays during trip.</p>
{% endif %}
</div>
{{ next_and_previous() }}
</div>
</div>
<div class="col-md-6 col-sm-12">
<button id="toggleMapSize" class="btn btn-primary mb-2">Toggle map size</button>
<div id="map" class="half-map">
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var coordinates = {{ coordinates | tojson }};
var routes = {{ routes | tojson }};
build_map("map", coordinates, routes);
</script>
{% endblock %}

View file

@ -1,45 +0,0 @@
{% extends "base.html" %}
{% block title %}Weekends - Edward Betts{% endblock %}
{% block content %}
<div class="p-2">
<h1>Weekends</h1>
<table class="table table-hover w-auto">
<thead>
<tr>
<th class="text-end">Week</th>
<th class="text-end">Date</th>
<th>Saturday</th>
<th>Sunday</th>
</tr>
</thead>
<tbody>
{% for weekend in items %}
<tr>
<td class="text-end">
{{ weekend.date.isocalendar().week }}
</td>
<td class="text-end text-nowrap">
{{ weekend.date.strftime("%-d %b %Y") }}
</td>
{% for day in "saturday", "sunday" %}
<td>
{% if weekend[day] %}
{% for event in weekend[day] %}
<a href="{{ event.url }}">{{ event.title }}</a>{% if not loop.last %},{%endif %}
{% endfor %}
{% else %}
<strong>free</strong>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -1,10 +1,9 @@
"""Tests for agenda."""
import datetime import datetime
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from agenda import ( from agenda import (
get_gbpusd,
get_next_bank_holiday, get_next_bank_holiday,
get_next_timezone_transition, get_next_timezone_transition,
next_economist, next_economist,
@ -13,67 +12,66 @@ from agenda import (
timedelta_display, timedelta_display,
uk_financial_year_end, uk_financial_year_end,
) )
from agenda.fx import get_gbpusd
@pytest.fixture @pytest.fixture
def mock_today() -> datetime.date: def mock_today():
"""Mock the current date for testing purposes.""" # Mock the current date for testing purposes
return datetime.date(2023, 10, 5) return datetime.date(2023, 10, 5)
@pytest.fixture @pytest.fixture
def mock_now() -> datetime.datetime: def mock_now():
"""Mock the current date and time for testing purposes.""" # Mock the current date and time for testing purposes
return datetime.datetime(2023, 10, 5, 12, 0, 0) return datetime.datetime(2023, 10, 5, 12, 0, 0)
def test_next_uk_mothers_day(mock_today: datetime.date) -> None: def test_next_uk_mothers_day(mock_today):
"""Test next_uk_mothers_day function.""" # Test next_uk_mothers_day function
next_mothers_day = next_uk_mothers_day(mock_today) next_mothers_day = next_uk_mothers_day(mock_today)
assert next_mothers_day == datetime.date(2024, 4, 21) assert next_mothers_day == datetime.date(2024, 4, 21)
def test_next_uk_fathers_day(mock_today: datetime.date) -> None: def test_next_uk_fathers_day(mock_today):
"""Test next_uk_fathers_day function.""" # Test next_uk_fathers_day function
next_fathers_day = next_uk_fathers_day(mock_today) next_fathers_day = next_uk_fathers_day(mock_today)
assert next_fathers_day == datetime.date(2024, 6, 21) assert next_fathers_day == datetime.date(2024, 6, 21)
def test_get_next_timezone_transition(mock_now: datetime.date) -> None: def test_get_next_timezone_transition(mock_now) -> None:
"""Test get_next_timezone_transition function.""" # Test get_next_timezone_transition function
next_transition = get_next_timezone_transition(mock_now, "Europe/London") next_transition = get_next_timezone_transition(mock_now, "Europe/London")
assert next_transition == datetime.date(2023, 10, 29) assert next_transition == datetime.date(2023, 10, 29)
def test_get_next_bank_holiday(mock_today: datetime.date) -> None: def test_get_next_bank_holiday(mock_today) -> None:
"""Test get_next_bank_holiday function.""" # Test get_next_bank_holiday function
next_holiday = get_next_bank_holiday(mock_today)[0] next_holiday = get_next_bank_holiday(mock_today)[0]
assert next_holiday.date == datetime.date(2023, 12, 25) assert next_holiday.date == datetime.date(2023, 12, 25)
assert next_holiday.title == "Christmas Day" assert next_holiday.title == "Christmas Day"
def test_get_gbpusd(mock_now: datetime.datetime) -> None: def test_get_gbpusd(mock_now):
"""Test get_gbpusd function.""" # Test get_gbpusd function
gbpusd = get_gbpusd() gbpusd = get_gbpusd()
assert isinstance(gbpusd, Decimal) assert isinstance(gbpusd, Decimal)
# You can add more assertions based on your specific use case. # You can add more assertions based on your specific use case.
def test_next_economist(mock_today: datetime.date) -> None: def test_next_economist(mock_today):
"""Test next_economist function.""" # Test next_economist function
next_publication = next_economist(mock_today) next_publication = next_economist(mock_today)
assert next_publication == datetime.date(2023, 10, 5) assert next_publication == datetime.date(2023, 10, 5)
def test_uk_financial_year_end() -> None: def test_uk_financial_year_end():
"""Test uk_financial_year_end function.""" # Test uk_financial_year_end function
financial_year_end = uk_financial_year_end(datetime.date(2023, 4, 1)) financial_year_end = uk_financial_year_end(datetime.date(2023, 4, 1))
assert financial_year_end == datetime.date(2023, 4, 5) assert financial_year_end == datetime.date(2023, 4, 5)
def test_timedelta_display() -> None: def test_timedelta_display():
"""Test timedelta_display function.""" # Test timedelta_display function
delta = datetime.timedelta(days=2, hours=5, minutes=30) delta = datetime.timedelta(days=2, hours=5, minutes=30)
display = timedelta_display(delta) display = timedelta_display(delta)
assert display == " 2 days 5 hrs 30 mins" assert display == " 2 days 5 hrs 30 mins"

View file

@ -1,96 +0,0 @@
"""Test utility functions."""
from datetime import date, datetime, timedelta, timezone
import pytest
from agenda.utils import as_date, as_datetime, human_readable_delta
from freezegun import freeze_time
def test_as_date_with_datetime() -> None:
"""Test converting a datetime object to a date."""
dt = datetime(2024, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
result = as_date(dt)
assert result == date(2024, 7, 7)
def test_as_date_with_date() -> None:
"""Test passing a date object through as_date."""
d = date(2024, 7, 7)
result = as_date(d)
assert result == d
def test_as_date_with_invalid_type() -> None:
"""Test as_date with an invalid type, expecting a TypeError."""
with pytest.raises(TypeError):
as_date("2024-07-07")
def test_as_datetime_with_datetime() -> None:
"""Test passing a datetime object through as_datetime."""
dt = datetime(2024, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
result = as_datetime(dt)
assert result == dt
def test_as_datetime_with_date() -> None:
"""Test converting a date object to a datetime."""
d = date(2024, 7, 7)
result = as_datetime(d)
expected = datetime(2024, 7, 7, 0, 0, 0, tzinfo=timezone.utc)
assert result == expected
def test_as_datetime_with_invalid_type() -> None:
"""Test as_datetime with an invalid type, expecting a TypeError."""
with pytest.raises(TypeError):
as_datetime("2024-07-07")
@freeze_time("2024-07-01")
def test_human_readable_delta_future_date() -> None:
"""Test human_readable_delta with a future date 45 days from today."""
future_date = date.today() + timedelta(days=45)
result = human_readable_delta(future_date)
assert result == "1 month 2 weeks 1 day"
@freeze_time("2024-07-01")
def test_human_readable_delta_today() -> None:
"""Test human_readable_delta with today's date, expecting None."""
today = date.today()
result = human_readable_delta(today)
assert result is None
@freeze_time("2024-07-01")
def test_human_readable_delta_past_date() -> None:
"""Test human_readable_delta with a past date, expecting None."""
past_date = date.today() - timedelta(days=1)
result = human_readable_delta(past_date)
assert result is None
@freeze_time("2024-07-01")
def test_human_readable_delta_months_only() -> None:
"""Test human_readable_delta with a future date 60 days from today."""
future_date = date.today() + timedelta(days=60)
result = human_readable_delta(future_date)
assert result == "2 months"
@freeze_time("2024-07-01")
def test_human_readable_delta_weeks_only() -> None:
"""Test human_readable_delta with a future date 14 days from today."""
future_date = date.today() + timedelta(days=14)
result = human_readable_delta(future_date)
assert result == "2 weeks"
@freeze_time("2024-07-01")
def test_human_readable_delta_days_only() -> None:
"""Test human_readable_delta with a future date 3 days from today."""
future_date = date.today() + timedelta(days=3)
result = human_readable_delta(future_date)
assert result == "3 days"

211
update.py
View file

@ -1,211 +0,0 @@
#!/usr/bin/python3
"""Combined update script for various data sources."""
import asyncio
import os
import sys
import typing
from datetime import date, datetime
from time import time
import deepdiff # type: ignore
import flask
import requests
import yaml
import agenda.bristol_waste
import agenda.fx
import agenda.geomob
import agenda.gwr
import agenda.mail
import agenda.thespacedevs
import agenda.types
import agenda.uk_holiday
from agenda.types import StrDict
from web_view import app
async def update_bank_holidays(config: flask.config.Config) -> None:
"""Update cached copy of UK Bank holidays."""
t0 = time()
events = await agenda.uk_holiday.get_holiday_list(config["DATA_DIR"])
time_taken = time() - t0
if not sys.stdin.isatty():
return
print(len(events), "bank holidays in list")
print(f"took {time_taken:.1f} seconds")
async def update_bristol_bins(config: flask.config.Config) -> None:
"""Update waste schedule from Bristol City Council."""
t0 = time()
events = await agenda.bristol_waste.get(
date.today(),
config["DATA_DIR"],
config["BRISTOL_UPRN"],
cache="refresh",
)
time_taken = time() - t0
if not sys.stdin.isatty():
return
for event in events:
print(event)
print(f"took {time_taken:.1f} seconds")
def update_gwr_advance_ticket_date(config: flask.config.Config) -> None:
"""Update GWR advance ticket date cache."""
filename = os.path.join(config["DATA_DIR"], "advance-tickets.html")
existing_html = open(filename).read()
existing_dates = agenda.gwr.extract_dates(existing_html)
assert existing_dates
assert list(existing_dates.keys()) == ["Weekdays", "Saturdays", "Sundays"]
new_html = requests.get(agenda.gwr.url).text
new_dates = agenda.gwr.extract_dates(new_html)
if not new_dates:
subject = "Error parsing GWR advance ticket booking dates"
body = new_html
agenda.mail.send_mail(config, subject, body)
return
assert new_dates
assert list(new_dates.keys()) == ["Weekdays", "Saturdays", "Sundays"]
if existing_dates == new_dates:
if sys.stdin.isatty():
print(filename)
print(agenda.gwr.url)
print("dates haven't changed:", existing_dates)
return
open(filename, "w").write(new_html)
subject = (
"New GWR advance ticket booking date: "
+ f'{new_dates["Weekdays"].strftime("%d %b %Y")} (Weekdays)'
)
body = f"""
{"\n".join(f'{key}: {when.strftime("%d %b %Y")}' for key, when in new_dates.items())}
{agenda.gwr.url}
Agenda: https://edwardbetts.com/agenda/
"""
if sys.stdin.isatty():
print(filename)
print(agenda.gwr.url)
print()
print("dates have changed")
print("old:", existing_dates)
print("new:", new_dates)
print()
print(subject)
print(body)
agenda.mail.send_mail(config, subject, body)
def report_space_launch_change(
config: flask.config.Config, prev_launch: StrDict | None, cur_launch: StrDict | None
) -> None:
"""Send mail to announce change to space launch data."""
if cur_launch:
name = cur_launch["name"]
else:
assert prev_launch
name = prev_launch["name"]
subject = f"Change to {name}"
differences = deepdiff.DeepDiff(prev_launch, cur_launch)
body = f"""
A space launch of interest was updated.
{yaml.dump(differences)}
https://edwardbetts.com/agenda/launches
"""
agenda.mail.send_mail(config, subject, body)
def get_launch_by_slug(data: StrDict, slug: str) -> StrDict | None:
"""Find last update for space launch."""
return {item["slug"]: typing.cast(StrDict, item) for item in data["results"]}.get(
slug
)
def update_thespacedevs(config: flask.config.Config) -> None:
"""Update cache of space launch API."""
rocket_dir = os.path.join(config["DATA_DIR"], "thespacedevs")
existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir)
assert existing_data
prev_launches = {
slug: get_launch_by_slug(existing_data, slug)
for slug in config["FOLLOW_LAUNCHES"]
}
t0 = time()
data = agenda.thespacedevs.next_launch_api_data(rocket_dir)
if not data:
return # thespacedevs API call failed
cur_launches = {
slug: get_launch_by_slug(data, slug) for slug in config["FOLLOW_LAUNCHES"]
}
for slug in config["FOLLOW_LAUNCHES"]:
prev, cur = prev_launches[slug], cur_launches[slug]
if prev is None and cur is None:
continue
if prev and cur and prev["last_updated"] == cur["last_updated"]:
continue
report_space_launch_change(config, prev, cur)
time_taken = time() - t0
if not sys.stdin.isatty():
return
rockets = [agenda.thespacedevs.summarize_launch(item) for item in data["results"]]
print(len(rockets), "launches")
print(f"took {time_taken:.1f} seconds")
def update_gandi(config: flask.config.Config) -> None:
"""Retrieve list of domains from gandi.net."""
url = "https://api.gandi.net/v5/domain/domains"
headers = {"authorization": "Bearer " + config["GANDI_TOKEN"]}
filename = os.path.join(config["DATA_DIR"], "gandi_domains.json")
r = requests.request("GET", url, headers=headers)
items = r.json()
assert isinstance(items, list)
assert all(item["fqdn"] and item["dates"]["registry_ends_at"] for item in items)
with open(filename, "w") as out:
out.write(r.text)
def main() -> None:
"""Update caches."""
now = datetime.now()
hour = now.hour
with app.app_context():
if hour % 3 == 0:
asyncio.run(update_bank_holidays(app.config))
asyncio.run(update_bristol_bins(app.config))
update_gwr_advance_ticket_date(app.config)
update_gandi(app.config)
agenda.geomob.update(app.config)
agenda.fx.get_rates(app.config)
update_thespacedevs(app.config)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/python3
"""Update GWR advance ticket date cache."""
import os.path
import smtplib
import sys
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import requests
from agenda import gwr
config = __import__("config.default", fromlist=[""])
def send_mail(subject: str, body: str) -> None:
"""Send an e-mail."""
msg = EmailMessage()
msg["Subject"] = subject
msg["To"] = f"{config.NAME} <{config.MAIL_TO}>"
msg["From"] = f"{config.NAME} <{config.MAIL_FROM}>"
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid()
msg.set_content(body)
s = smtplib.SMTP(config.SMTP_HOST)
s.sendmail(config.MAIL_TO, [config.MAIL_TO], msg.as_string())
s.quit()
def main() -> None:
"""Get date from web page and compare with existing."""
filename = os.path.join(config.DATA_DIR, "advance-tickets.html")
existing_html = open(filename).read()
existing_date = gwr.extract_weekday_date(existing_html)
new_html = requests.get(gwr.url).text
open(filename, "w").write(new_html)
new_date = gwr.extract_weekday_date(new_html)
if existing_date == new_date:
if sys.stdin.isatty():
print("date has't changed:", existing_date)
return
subject = f"New GWR advance ticket booking date: {new_date}"
body = f"""Old date: {existing_date}
New date: {new_date}
{gwr.url}
Agenda: https://edwardbetts.com/agenda/
"""
send_mail(subject, body)
if __name__ == "__main__":
main()

View file

@ -1,164 +0,0 @@
#!/usr/bin/python3
"""Load YAML data to ensure validity."""
import os
import sys
import typing
from datetime import date, timedelta
import yaml
from rich.pretty import pprint
import agenda
import agenda.conference
import agenda.data
import agenda.travel
import agenda.trip
import agenda.types
config = __import__("config.default", fromlist=[""])
data_dir = config.PERSONAL_DATA
currencies = set(config.CURRENCIES + ["GBP"])
def check_currency(item: agenda.types.StrDict) -> None:
"""Throw error if currency is not in config."""
currency = item.get("currency")
if not currency or currency in currencies:
return None
pprint(item)
print(f"currency {currency!r} not in {currencies!r}")
sys.exit(-1)
def check_trips() -> None:
"""Check trips."""
trip_list = agenda.trip.build_trip_list(data_dir)
print(len(trip_list), "trips")
coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir)
print(len(coords), "coords")
print(len(routes), "routes")
def check_flights(airlines: set[str]) -> None:
"""Check flights."""
bookings = agenda.travel.parse_yaml("flights", data_dir)
for booking in bookings:
assert all(flight["airline"] in airlines for flight in booking["flights"])
for booking in bookings:
check_currency(booking)
print(len(bookings), "flights")
def check_trains() -> None:
"""Check trains."""
trains = agenda.travel.parse_yaml("trains", data_dir)
print(len(trains), "trains")
def check_conferences() -> None:
"""Check conferences."""
filepath = os.path.join(data_dir, "conferences.yaml")
conferences = [
agenda.conference.Conference(**conf)
for conf in yaml.safe_load(open(filepath, "r"))
]
for conf in conferences:
if not conf.currency or conf.currency in currencies:
continue
pprint(conf)
print(f"currency {conf.currency!r} not in {currencies!r}")
sys.exit(-1)
print(len(conferences), "conferences")
def check_events() -> None:
"""Check events."""
today = date.today()
last_year = today - timedelta(days=365)
next_year = today + timedelta(days=2 * 365)
events = agenda.events_yaml.read(data_dir, last_year, next_year)
print(len(events), "events")
def check_coordinates(item: agenda.types.StrDict) -> None:
"""Check coordinate are valid."""
if "latitude" not in item and "longitude" not in item:
return
assert "latitude" in item and "longitude" in item
assert all(isinstance(item[key], (int, float)) for key in ("latitude", "longitude"))
def check_accommodation() -> None:
"""Check accommodation."""
filepath = os.path.join(data_dir, "accommodation.yaml")
accommodation_list = yaml.safe_load(open(filepath))
required_fields = ["type", "name", "country", "location", "trip", "from", "to"]
for stay in accommodation_list:
try:
assert all(field in stay for field in required_fields)
check_coordinates(stay)
except AssertionError:
pprint(stay)
raise
check_currency(stay)
print(len(accommodation_list), "stays")
def check_airports() -> None:
"""Check airports."""
airports = typing.cast(
dict[str, agenda.types.StrDict], agenda.travel.parse_yaml("airports", data_dir)
)
print(len(airports), "airports")
for airport in airports.values():
assert "country" in airport
assert agenda.get_country(airport["country"])
def check_stations() -> None:
"""Check stations."""
stations = agenda.travel.parse_yaml("stations", data_dir)
print(len(stations), "stations")
for station in stations:
assert "country" in station
assert agenda.get_country(station["country"])
def check_airlines() -> list[agenda.types.StrDict]:
"""Check airlines."""
airlines = agenda.travel.parse_yaml("airlines", data_dir)
print(len(airlines), "airlines")
for airline in airlines:
assert airline.keys() == {"icao", "iata", "name"}
assert len(airline["icao"]) == 3
assert len(airline["iata"]) == 2
return airlines
def check() -> None:
"""Validate personal data YAML files."""
airlines = check_airlines()
check_trips()
check_flights({airline["iata"] for airline in airlines})
check_trains()
check_conferences()
check_events()
check_accommodation()
check_airports()
check_stations()
if __name__ == "__main__":
check()

View file

@ -2,32 +2,22 @@
"""Web page to show upcoming events.""" """Web page to show upcoming events."""
import decimal
import inspect import inspect
import operator import operator
import os.path import os.path
import sys import sys
import time
import traceback import traceback
from collections import defaultdict from datetime import date, datetime
from datetime import date, datetime, timedelta
import flask import flask
import UniAuth.auth
import werkzeug import werkzeug
import werkzeug.debug.tbtools import werkzeug.debug.tbtools
import yaml import yaml
import agenda.data import agenda.data
import agenda.error_mail import agenda.error_mail
import agenda.fx
import agenda.holidays
import agenda.stats
import agenda.thespacedevs import agenda.thespacedevs
import agenda.trip import agenda.travel
import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip
app = flask.Flask(__name__) app = flask.Flask(__name__)
app.debug = False app.debug = False
@ -36,12 +26,6 @@ app.config.from_object("config.default")
agenda.error_mail.setup_error_mail(app) agenda.error_mail.setup_error_mail(app)
@app.before_request
def handle_auth() -> None:
"""Handle authentication and set global user."""
flask.g.user = UniAuth.auth.get_current_user()
@app.errorhandler(werkzeug.exceptions.InternalServerError) @app.errorhandler(werkzeug.exceptions.InternalServerError)
def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, int]: def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, int]:
"""Handle exception.""" """Handle exception."""
@ -69,255 +53,73 @@ def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str,
) )
def get_current_trip(today: date) -> Trip | None:
"""Get current trip."""
trip_list = get_trip_list(route_distances=None)
current = [
item
for item in trip_list
if item.start <= today and (item.end or item.start) >= today
]
assert len(current) < 2
return current[0] if current else None
@app.route("/") @app.route("/")
async def index() -> str: async def index() -> str:
"""Index page.""" """Index page."""
t0 = time.time()
now = datetime.now() now = datetime.now()
data = await agenda.data.get_data(now, app.config) data = await agenda.data.get_data(now, app.config)
events = data.pop("events") return flask.render_template("index.html", today=now.date(), **data)
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,
current_trip=get_current_trip(now.date()),
fullcalendar_events=calendar.build_events(events),
start_event_list=date.today() - timedelta(days=1),
end_event_list=date.today() + timedelta(days=365 * 2),
render_time=(time.time() - t0),
**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"])
return flask.render_template(
"calendar.html",
today=now.date(),
events=events,
fullcalendar_events=calendar.build_events(events),
**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,
fullcalendar_events=calendar.build_events(events),
start_event_list=date.today() - timedelta(days=14),
end_event_list=date.today(),
render_time=(time.time() - t0),
**data,
)
@app.route("/launches") @app.route("/launches")
def launch_list() -> str: async def launch_list() -> str:
"""Web page showing List of space launches.""" """Web page showing List of space launches."""
now = datetime.now() now = datetime.now()
data_dir = app.config["DATA_DIR"] data_dir = app.config["DATA_DIR"]
rocket_dir = os.path.join(data_dir, "thespacedevs") rocket_dir = os.path.join(data_dir, "thespacedevs")
launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100) rockets = await agenda.thespacedevs.get_launches(rocket_dir, limit=100)
assert launches
mission_type_filter = flask.request.args.get("type") return flask.render_template("launches.html", rockets=rockets, now=now)
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("/gaps") @app.route("/gaps")
async def gaps_page() -> str: async def gaps_page() -> str:
"""List of available gaps.""" """List of available gaps."""
now = datetime.now() now = datetime.now()
trip_list = agenda.trip.build_trip_list() data = await agenda.data.get_data(now, app.config)
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list) return flask.render_template("gaps.html", today=now.date(), gaps=data["gaps"])
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 gaps."""
now = datetime.now()
trip_list = agenda.trip.build_trip_list()
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list)
weekends = agenda.busy.weekends(busy_events)
return flask.render_template("weekends.html", today=now.date(), items=weekends)
@app.route("/travel") @app.route("/travel")
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.trip.load_flight_bookings(data_dir) flights = agenda.travel.parse_yaml("flights", data_dir)
trains = [ trains = agenda.travel.parse_yaml("trains", data_dir)
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"]) return flask.render_template("travel.html", flights=flights, trains=trains)
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),
)
def build_conference_list() -> list[StrDict]: def as_date(d: date | datetime) -> date:
"""Build conference list.""" """Date of event."""
data_dir = app.config["PERSONAL_DATA"] return d.date() if isinstance(d, datetime) else d
filepath = os.path.join(data_dir, "conferences.yaml")
items: list[StrDict] = yaml.safe_load(open(filepath))
conference_trip_lookup = {}
for trip in agenda.trip.build_trip_list():
for trip_conf in trip.conferences:
key = (trip_conf["start"], trip_conf["name"])
conference_trip_lookup[key] = trip
for conf in items:
conf["start_date"] = agenda.utils.as_date(conf["start"])
conf["end_date"] = agenda.utils.as_date(conf["end"])
price = conf.get("price")
if price:
conf["price"] = decimal.Decimal(price)
key = (conf["start"], conf["name"])
if this_trip := conference_trip_lookup.get(key):
conf["linked_trip"] = this_trip
items.sort(key=operator.itemgetter("start_date"))
return items
@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."""
data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml")
item_list = yaml.safe_load(open(filepath))["conferences"]
today = date.today() today = date.today()
items = build_conference_list() for conf in item_list:
conf["start_date"] = as_date(conf["start"])
conf["end_date"] = as_date(conf["end"])
item_list.sort(key=operator.itemgetter("start_date"))
current = [ current = [
conf conf
for conf in items for conf in item_list
if conf["start_date"] <= today and conf["end_date"] >= today if conf["start_date"] <= today and conf["end_date"] >= today
] ]
future = [conf for conf in items if conf["start_date"] > today]
past = [conf for conf in item_list if conf["end_date"] < today]
future = [conf for conf in item_list if conf["start_date"] > today]
return flask.render_template( return flask.render_template(
"conference_list.html", "conference_list.html", current=current, past=past, future=future, today=today
current=current,
future=future,
today=today,
get_country=agenda.get_country,
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()
return flask.render_template(
"conference_list.html",
past=[conf for conf in build_conference_list() if conf["end_date"] < today],
today=today,
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
) )
@ -325,7 +127,7 @@ def past_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 = travel.parse_yaml("accommodation", data_dir) items = agenda.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(
@ -338,267 +140,13 @@ def accommodation_list() -> str:
if stay["country"] != "gb" if stay["country"] != "gb"
) )
trip_lookup = {}
for trip in agenda.trip.build_trip_list():
for trip_stay in trip.accommodation:
key = (trip_stay["from"], trip_stay["name"])
trip_lookup[key] = trip
for item in items:
key = (item["from"], item["name"])
if this_trip := trip_lookup.get(key):
item["linked_trip"] = this_trip
now = uk_tz.localize(datetime.now())
past = [conf for conf in items if conf["to"] < now]
current = [conf for conf in items if conf["from"] <= now and conf["to"] >= now]
future = [conf for conf in items if conf["from"] > now]
return flask.render_template( return flask.render_template(
"accommodation.html", "accommodation.html",
past=past, items=items,
current=current,
future=future,
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=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
) )
def get_trip_list(
route_distances: agenda.travel.RouteDistances | None = None,
) -> list[Trip]:
"""Get list of trips respecting current authentication status."""
return [
trip
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
if flask.g.user.is_authenticated or not trip.private
]
@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"))
def calc_total_distance(trips: list[Trip]) -> float:
"""Total distance for trips."""
total = 0.0
for item in trips:
dist = item.total_distance()
if dist:
total += dist
return total
def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float]]:
"""Sum distances by transport type."""
distances_by_transport_type: defaultdict[str, float] = defaultdict(float)
for trip in trips:
for transport_type, dist in trip.distances_by_transport_type():
distances_by_transport_type[transport_type] += dist
return list(distances_by_transport_type.items())
@app.route("/trip/past")
def trip_past_list() -> str:
"""Page showing a list of past trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
past = [item for item in 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),
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=calc_total_distance(past),
distances_by_transport_type=sum_distances_by_transport_type(past),
)
@app.route("/trip/future")
def trip_future_list() -> str:
"""Page showing a list of future trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
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)
return flask.render_template(
"trip/list.html",
heading="Future trips",
trips=current + future,
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=calc_total_distance(current + future),
distances_by_transport_type=sum_distances_by_transport_type(current + future),
)
@app.route("/trip/text")
def trip_list_text() -> str:
"""Page showing a list of trips."""
trip_list = 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."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list)
if not trip:
flask.abort(404)
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)
for route in routes:
if "geojson_filename" in route:
route["geojson"] = agenda.trip.read_geojson(
app.config["PERSONAL_DATA"], route.pop("geojson_filename")
)
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),
human_readable_delta=agenda.utils.human_readable_delta,
)
@app.route("/holidays")
def holiday_list() -> str:
"""List of holidays."""
today = date.today()
data_dir = app.config["DATA_DIR"]
next_year = today + timedelta(days=1 * 365)
items = agenda.holidays.get_all(today - timedelta(days=2), next_year, data_dir)
items.sort(key=lambda item: (item.date, item.country))
return flask.render_template(
"holiday_list.html", items=items, 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."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
conferences = sum(len(item.conferences) for item in trip_list)
yearly_stats = agenda.stats.calculate_yearly_stats(trip_list)
return flask.render_template(
"trip/stats.html",
count=len(trip_list),
total_distance=calc_total_distance(trip_list),
distances_by_transport_type=sum_distances_by_transport_type(trip_list),
yearly_stats=yearly_stats,
conferences=conferences,
)
@app.route("/callback")
def auth_callback() -> tuple[str, int] | werkzeug.Response:
"""Process the authentication callback."""
return UniAuth.auth.auth_callback()
@app.route("/login")
def login() -> werkzeug.Response:
"""Login."""
next_url = flask.request.args["next"]
return UniAuth.auth.redirect_to_login(next_url)
@app.route("/logout")
def logout() -> werkzeug.Response:
"""Logout."""
return UniAuth.auth.redirect_to_logout(flask.request.args["next"])
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0") app.run(host="0.0.0.0")

View file

@ -1,18 +0,0 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'development',
entry: './frontend/index.js', // Ensure this entry point exists and is valid.
plugins: [
new CopyPlugin({
patterns: [
// Copy Bootstrap's CSS and JS from node_modules to your desired location
{ from: 'node_modules/bootstrap/dist', to: path.resolve(__dirname, 'static/bootstrap5') },
{ from: 'node_modules/leaflet/dist', to: path.resolve(__dirname, 'static/leaflet') },
{ from: 'node_modules/leaflet.geodesic/dist', to: path.resolve(__dirname, 'static/leaflet-geodesic'), },
{ from: 'node_modules/es-module-shims/dist', to: path.resolve(__dirname, 'static/es-module-shims') }
],
}),
]
};