Compare commits
323 commits
launch-pag
...
main
Author | SHA1 | Date | |
---|---|---|---|
Edward Betts | f4fc839926 | ||
Edward Betts | 53f6d05e52 | ||
Edward Betts | 40bac83790 | ||
Edward Betts | b3e7070b84 | ||
Edward Betts | 7a08c4b56d | ||
Edward Betts | 42066f9dde | ||
Edward Betts | 1caf233075 | ||
Edward Betts | 6d1b01485a | ||
Edward Betts | 67b1adf956 | ||
Edward Betts | 3c3939c525 | ||
Edward Betts | eaa6369dc9 | ||
Edward Betts | 9f1f64708f | ||
Edward Betts | 1ce82858ae | ||
Edward Betts | 6333587cc2 | ||
Edward Betts | 38792a1721 | ||
Edward Betts | aec1d0e140 | ||
Edward Betts | ef624f83dd | ||
Edward Betts | 34d1ee3b30 | ||
Edward Betts | 662808b545 | ||
Edward Betts | 3c0694de19 | ||
Edward Betts | 9ad2ba9462 | ||
Edward Betts | a1cdf62eef | ||
Edward Betts | 81b0234794 | ||
Edward Betts | 7587c3d3b7 | ||
Edward Betts | aad968a174 | ||
Edward Betts | 5f13bff9bd | ||
Edward Betts | 237db73b5d | ||
Edward Betts | 868c1407b5 | ||
Edward Betts | 7d803e0267 | ||
Edward Betts | 0b23f71aa6 | ||
Edward Betts | 8cbfb745c4 | ||
Edward Betts | a324046332 | ||
Edward Betts | 02fd6dbbe6 | ||
Edward Betts | d2c6a778e3 | ||
Edward Betts | 96ec2b7d89 | ||
Edward Betts | 488ecf8b71 | ||
Edward Betts | 197f6e5bfd | ||
Edward Betts | 4b449d0d98 | ||
Edward Betts | 9856a609bd | ||
Edward Betts | 9f6ed9c372 | ||
Edward Betts | 20e8515bb7 | ||
Edward Betts | 82b2f69005 | ||
Edward Betts | d1d7079056 | ||
Edward Betts | cf4508719a | ||
Edward Betts | 259642ff52 | ||
Edward Betts | 6e9604e4c1 | ||
Edward Betts | cf298f261f | ||
Edward Betts | 23aa70bb84 | ||
Edward Betts | 8032cd2ed4 | ||
Edward Betts | bd129aea5c | ||
Edward Betts | b5188771be | ||
Edward Betts | b9adb3d15e | ||
Edward Betts | 15c5053e44 | ||
Edward Betts | 11bc0419b3 | ||
Edward Betts | 7169d1ba27 | ||
Edward Betts | fb65b4d6fb | ||
Edward Betts | 7cdb6903fc | ||
Edward Betts | f423fcdcbe | ||
Edward Betts | a130a85a48 | ||
Edward Betts | 9f54c3ac03 | ||
Edward Betts | 5b9a481bb2 | ||
Edward Betts | f63b4d6b08 | ||
Edward Betts | ace517c482 | ||
Edward Betts | ef695af7af | ||
Edward Betts | 28ad4950fd | ||
Edward Betts | 5c4eac60ee | ||
Edward Betts | 0f3f596cb3 | ||
Edward Betts | 17eca6a95a | ||
Edward Betts | efae1b9b14 | ||
Edward Betts | b38ec99628 | ||
Edward Betts | d1898686e9 | ||
Edward Betts | 0c36591e2f | ||
Edward Betts | 0683c98e6f | ||
Edward Betts | 07cf7dee3c | ||
Edward Betts | ff51bb9ff9 | ||
Edward Betts | 19a9015dba | ||
Edward Betts | 5e70cbd633 | ||
Edward Betts | 8e34ceb458 | ||
Edward Betts | 089f569fd3 | ||
Edward Betts | 4ef47374c2 | ||
Edward Betts | e677082560 | ||
Edward Betts | 128d7ac282 | ||
Edward Betts | e38e357f63 | ||
Edward Betts | e7ae7123f6 | ||
Edward Betts | a873060949 | ||
Edward Betts | e66945a825 | ||
Edward Betts | e814e1b135 | ||
Edward Betts | 1e39e75117 | ||
Edward Betts | 52810ca676 | ||
Edward Betts | 4e328d401e | ||
Edward Betts | b65d79cb63 | ||
Edward Betts | c41bcc3304 | ||
Edward Betts | 01b42845c3 | ||
Edward Betts | 0e49d18721 | ||
Edward Betts | b8ed1d5d65 | ||
Edward Betts | f4557d14e8 | ||
Edward Betts | 672948ed4d | ||
Edward Betts | 2e87d0f120 | ||
Edward Betts | b28d5241c6 | ||
Edward Betts | d91eab02ad | ||
Edward Betts | fcf935271c | ||
Edward Betts | 895bf7c972 | ||
Edward Betts | 0079f46a80 | ||
Edward Betts | 40578196bc | ||
Edward Betts | d5bf004912 | ||
Edward Betts | 1e14a99419 | ||
Edward Betts | 12a2b739e8 | ||
Edward Betts | 31cd7e8b97 | ||
Edward Betts | e400360697 | ||
Edward Betts | ce07ae65b1 | ||
Edward Betts | 882fa52ae5 | ||
Edward Betts | 964bbb1162 | ||
Edward Betts | 6c76610cdb | ||
Edward Betts | 4c651198f3 | ||
Edward Betts | 733608bc2f | ||
Edward Betts | 5de5e22883 | ||
Edward Betts | ade6989300 | ||
Edward Betts | 537a84ff67 | ||
Edward Betts | 75242c2952 | ||
Edward Betts | 38f2e10c6d | ||
Edward Betts | cd8dfb74a4 | ||
Edward Betts | f8c523c674 | ||
Edward Betts | 093000bbc3 | ||
Edward Betts | 8181dfbe3b | ||
Edward Betts | a96aefe22b | ||
Edward Betts | 5758d3f1d0 | ||
Edward Betts | 1948ab8ff5 | ||
Edward Betts | 277e991869 | ||
Edward Betts | d4dda44768 | ||
Edward Betts | 448d59514b | ||
Edward Betts | 34d7655ace | ||
Edward Betts | 7e8d156126 | ||
Edward Betts | 455528125c | ||
Edward Betts | 7d376b38f3 | ||
Edward Betts | ab74ebab34 | ||
Edward Betts | 85ebaf7c84 | ||
Edward Betts | 5b2d248955 | ||
Edward Betts | f1a472a944 | ||
Edward Betts | d5a92c9a8e | ||
Edward Betts | a253f720dd | ||
Edward Betts | 2e1cf0ce84 | ||
Edward Betts | c9fcf1d5e7 | ||
Edward Betts | afb96bc855 | ||
Edward Betts | cd16b857a0 | ||
Edward Betts | 3ec7f5c18a | ||
Edward Betts | dd59c809e1 | ||
Edward Betts | 7bb6110f45 | ||
Edward Betts | 18d8fa6b7c | ||
Edward Betts | 78c90b0164 | ||
Edward Betts | 096e0a371e | ||
Edward Betts | dd82470835 | ||
Edward Betts | c65e60a1f1 | ||
Edward Betts | ca7c449410 | ||
Edward Betts | 4fa7647584 | ||
Edward Betts | afa2a2e934 | ||
Edward Betts | b9b849802d | ||
Edward Betts | a5d1290491 | ||
Edward Betts | 2b822e28a0 | ||
Edward Betts | 6d6e416df3 | ||
Edward Betts | 19732a3ef1 | ||
Edward Betts | 5ab9d93484 | ||
Edward Betts | dbc12adb3d | ||
Edward Betts | 66ca6c0744 | ||
Edward Betts | b0381db3b5 | ||
Edward Betts | 0fcaf76104 | ||
Edward Betts | 32e07d4ce4 | ||
Edward Betts | d28e172a8c | ||
Edward Betts | e2afe0ffa4 | ||
Edward Betts | dbffd60937 | ||
Edward Betts | 875f50e684 | ||
Edward Betts | e1688629a3 | ||
Edward Betts | dce8fde29a | ||
Edward Betts | ab60721e15 | ||
Edward Betts | b1507702cf | ||
Edward Betts | 291b545915 | ||
Edward Betts | 37be85593b | ||
Edward Betts | eb3be4cb51 | ||
Edward Betts | 87aaba64b2 | ||
Edward Betts | a7296c943b | ||
Edward Betts | fe4bde32ba | ||
Edward Betts | b5c1e16901 | ||
Edward Betts | 48549ce009 | ||
Edward Betts | 8ef67e0cee | ||
Edward Betts | 5964899a00 | ||
Edward Betts | a607f29259 | ||
Edward Betts | e5325a0392 | ||
Edward Betts | 7208e10cb2 | ||
Edward Betts | ae630a8f68 | ||
Edward Betts | f0c28d2440 | ||
Edward Betts | 748ec3a1bc | ||
Edward Betts | d813bff812 | ||
Edward Betts | ebd46a7a21 | ||
Edward Betts | efc660b0ac | ||
Edward Betts | 422cd8aa9d | ||
Edward Betts | 1e90df76dd | ||
Edward Betts | 6018f0217d | ||
Edward Betts | cff981eb8b | ||
Edward Betts | 826eafbc86 | ||
Edward Betts | d690442f0f | ||
Edward Betts | e3cae68d2f | ||
Edward Betts | 9d691bee40 | ||
Edward Betts | 4ebb08f68e | ||
Edward Betts | f1338e5970 | ||
Edward Betts | 1ed6c50ad8 | ||
Edward Betts | 96ab89b42f | ||
Edward Betts | ff15f380fa | ||
Edward Betts | a37af733cd | ||
Edward Betts | 4ade643de6 | ||
Edward Betts | 7d5cfe859a | ||
Edward Betts | 0e7a4c2386 | ||
Edward Betts | 5fdfd9d533 | ||
Edward Betts | 8f749c8e35 | ||
Edward Betts | f3f9ee5bf9 | ||
Edward Betts | 5ffb389c53 | ||
Edward Betts | 38dccc1529 | ||
Edward Betts | 7a9fbcec7b | ||
Edward Betts | f19e4e4dd4 | ||
Edward Betts | b66f852256 | ||
Edward Betts | 3163bca99b | ||
Edward Betts | f54c9cfbb7 | ||
Edward Betts | f3304d0ffe | ||
Edward Betts | 8b777e64fc | ||
Edward Betts | 6c8e1bf48d | ||
Edward Betts | 89ff92c533 | ||
Edward Betts | 14c25e16ed | ||
Edward Betts | 6c1c638104 | ||
Edward Betts | d6ebd86232 | ||
Edward Betts | f76f9e03da | ||
Edward Betts | 6475692db1 | ||
Edward Betts | 72e7945fbe | ||
Edward Betts | fc36647d49 | ||
Edward Betts | 5f0d2e884f | ||
Edward Betts | 7e51a32210 | ||
Edward Betts | b7d655a21e | ||
Edward Betts | f028e40df8 | ||
Edward Betts | b4a79cae69 | ||
Edward Betts | cc3dc81bdb | ||
Edward Betts | bdaad42eba | ||
Edward Betts | 389092cbb4 | ||
Edward Betts | d41d53367f | ||
Edward Betts | 533c7767e8 | ||
Edward Betts | ac32b4fe89 | ||
Edward Betts | 2b89ff7ff9 | ||
Edward Betts | 6d65f5045e | ||
Edward Betts | 566b09f888 | ||
Edward Betts | 073f452356 | ||
Edward Betts | cd60ebdea2 | ||
Edward Betts | e16e04ab51 | ||
Edward Betts | e475f98dd6 | ||
Edward Betts | 98b7c4a89d | ||
Edward Betts | a998e456eb | ||
Edward Betts | ed36170eb7 | ||
Edward Betts | 8a98f4d2db | ||
Edward Betts | ec99289cfa | ||
Edward Betts | 6748f8338c | ||
Edward Betts | 549ddd3b60 | ||
Edward Betts | 44bf744361 | ||
Edward Betts | 4b6f4231b7 | ||
Edward Betts | 3a7784bb25 | ||
Edward Betts | 39f9c98a51 | ||
Edward Betts | b33da8485c | ||
Edward Betts | 75d18aed2b | ||
Edward Betts | 4638069e51 | ||
Edward Betts | 8047cb67fe | ||
Edward Betts | 2a1e2429d7 | ||
Edward Betts | 1e9ae2091e | ||
Edward Betts | 322b65237d | ||
Edward Betts | 69e10db8ef | ||
Edward Betts | 8df94aaafb | ||
Edward Betts | 3cb03a787c | ||
Edward Betts | c6cc3fc558 | ||
Edward Betts | b061262120 | ||
Edward Betts | a6a78d18e5 | ||
Edward Betts | e6cffdd3d5 | ||
Edward Betts | f8658a7850 | ||
Edward Betts | 1f8d465c6d | ||
Edward Betts | 7ca5eafd1d | ||
Edward Betts | f60a1a329c | ||
Edward Betts | 283a9d0b27 | ||
Edward Betts | 31e8197c79 | ||
Edward Betts | 36168843d6 | ||
Edward Betts | 7883c89b76 | ||
Edward Betts | f3a4f1dcd1 | ||
Edward Betts | e86bd69ddb | ||
Edward Betts | fbee775f5b | ||
Edward Betts | 36b5d38274 | ||
Edward Betts | bd61b1bccd | ||
Edward Betts | fab478dc61 | ||
Edward Betts | fd34190368 | ||
Edward Betts | 4a990a9fe5 | ||
Edward Betts | e0735b4185 | ||
Edward Betts | cb2a2c7fb8 | ||
Edward Betts | e2fdd1d198 | ||
Edward Betts | 4e719a07ab | ||
Edward Betts | 4b62ec96dc | ||
Edward Betts | e993329939 | ||
Edward Betts | 4b8b1f7556 | ||
Edward Betts | 0c02d9c899 | ||
Edward Betts | 60070d07fd | ||
Edward Betts | a9c9c719a4 | ||
Edward Betts | 2744f67987 | ||
Edward Betts | ad47f291f8 | ||
Edward Betts | 8504a3a022 | ||
Edward Betts | 82de51109f | ||
Edward Betts | 199eb82bce | ||
Edward Betts | 7456f72325 | ||
Edward Betts | 1453c4015c | ||
Edward Betts | cd0ffb3390 | ||
Edward Betts | 3d16e30aa8 | ||
Edward Betts | acbad39df7 | ||
Edward Betts | 7a5319aa83 | ||
Edward Betts | 50127417f0 | ||
Edward Betts | fd6d3b674b | ||
Edward Betts | 21b67bdc64 | ||
Edward Betts | ea33722f69 | ||
Edward Betts | 3dddc52430 | ||
Edward Betts | ce9faa654f | ||
Edward Betts | d9b1d77872 | ||
Edward Betts | 5786e3d575 | ||
Edward Betts | b1139b79d2 | ||
Edward Betts | 824285a4cf | ||
Edward Betts | 17036d849f | ||
Edward Betts | fd46f0a405 |
17
.eslintrc.js
Normal file
17
.eslintrc.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
module.exports = {
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"globals": {
|
||||||
|
"Atomics": "readonly",
|
||||||
|
"SharedArrayBuffer": "readonly"
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 14,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
}
|
||||||
|
};
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
|
import pycountry
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
uk_tz = pytz.timezone("Europe/London")
|
uk_tz = pytz.timezone("Europe/London")
|
||||||
|
@ -10,3 +11,31 @@ 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
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
"""Accomodation"""
|
"""Accommodation."""
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .types import Event
|
from .event import Event
|
||||||
|
|
||||||
|
|
||||||
def get_events(filepath: str) -> list[Event]:
|
def get_events(filepath: str) -> list[Event]:
|
||||||
"""Get accomodation from YAML."""
|
"""Get accommodation 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"]
|
||||||
|
|
|
@ -4,7 +4,7 @@ from datetime import date
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .types import Event
|
from .event 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})',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
131
agenda/bristol_waste.py
Normal file
131
agenda/bristol_waste.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
"""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])
|
160
agenda/busy.py
Normal file
160
agenda/busy.py
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
"""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
|
||||||
|
]
|
|
@ -3,7 +3,7 @@
|
||||||
import typing
|
import typing
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from .types import Event
|
from .event 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.display_title,
|
"title": e.title_with_emoji,
|
||||||
"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(hours=1)
|
end = e.end_date or e.date + timedelta(minutes=30)
|
||||||
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.display_title,
|
"title": e.title_with_emoji,
|
||||||
"start": e.date.isoformat(),
|
"start": e.date.isoformat(),
|
||||||
"end": end.isoformat(),
|
"end": end.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
33
agenda/carnival.py
Normal file
33
agenda/carnival.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
"""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
|
|
@ -6,7 +6,10 @@ from datetime import date, datetime
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .types import Event
|
from . import utils
|
||||||
|
from .event import Event
|
||||||
|
|
||||||
|
MAX_CONF_DAYS = 20
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
|
@ -18,6 +21,8 @@ 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
|
||||||
|
@ -29,6 +34,15 @@ 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:
|
||||||
|
@ -42,17 +56,28 @@ 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."""
|
||||||
return [
|
events: list[Event] = []
|
||||||
Event(
|
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))):
|
||||||
|
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=f"🎤 {conf.display_name}",
|
title=conf.display_name,
|
||||||
url=conf.url,
|
url=conf.url,
|
||||||
going=conf.going,
|
going=conf.going,
|
||||||
)
|
)
|
||||||
for conf in (
|
events.append(event)
|
||||||
Conference(**conf)
|
if not conf.cfp_end:
|
||||||
for conf in yaml.safe_load(open(filepath, "r"))["conferences"]
|
continue
|
||||||
|
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
|
||||||
|
|
505
agenda/data.py
505
agenda/data.py
|
@ -1,51 +1,50 @@
|
||||||
"""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,
|
||||||
calendar,
|
bristol_waste,
|
||||||
|
busy,
|
||||||
|
carnival,
|
||||||
conference,
|
conference,
|
||||||
domains,
|
domains,
|
||||||
economist,
|
economist,
|
||||||
fx,
|
events_yaml,
|
||||||
|
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 .types import Event, Holiday
|
from .event import Event
|
||||||
|
from .types import StrDict
|
||||||
StrDict = dict[str, typing.Any]
|
from .utils import time_function
|
||||||
|
|
||||||
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 availablity calendar
|
# chalet availability calendar
|
||||||
|
|
||||||
# starlink visible
|
# starlink visible
|
||||||
|
|
||||||
|
@ -62,298 +61,114 @@ def timezone_transition(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def us_holidays(start_date: date, end_date: date) -> list[Holiday]:
|
async def n_somerset_waste_collection_events(
|
||||||
"""Get US holidays."""
|
data_dir: str, postcode: str, uprn: str, force_cache: bool = False
|
||||||
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."""
|
||||||
postcode = "BS48 3HG"
|
html = await n_somerset_waste.get_html(data_dir, postcode, uprn, force_cache)
|
||||||
uprn = "24071046"
|
|
||||||
|
|
||||||
html = await waste_schedule.get_html(data_dir, postcode, uprn)
|
|
||||||
root = lxml.html.fromstring(html)
|
root = lxml.html.fromstring(html)
|
||||||
events = waste_schedule.parse(root)
|
events = n_somerset_waste.parse(root)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
async def bristol_waste_collection_events(
|
async def bristol_waste_collection_events(
|
||||||
data_dir: str, start_date: date
|
data_dir: str, start_date: date, uprn: str, force_cache: bool = False
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
"""Waste colllection events."""
|
"""Waste colllection events."""
|
||||||
uprn = "358335"
|
cache = "force" if force_cache else "recent"
|
||||||
|
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 combine_holidays(holidays: list[Holiday]) -> list[Event]:
|
def find_events_during_stay(
|
||||||
"""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_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]
|
accommodation_events: list[Event], markets: list[Event]
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
"""Market events that happen during accommodation stays."""
|
"""Market events that happen during accommodation stays."""
|
||||||
overlapping_markets = []
|
overlapping_markets = []
|
||||||
for market in markets:
|
for market in markets:
|
||||||
|
market_date = market.as_date
|
||||||
|
assert isinstance(market_date, date)
|
||||||
for e in accommodation_events:
|
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.
|
# Check if the market date is within the accommodation dates.
|
||||||
if e.as_date <= market.as_date <= e.end_as_date:
|
if start <= market_date <= end:
|
||||||
overlapping_markets.append(market)
|
overlapping_markets.append(market)
|
||||||
break # Breaks the inner loop if overlap is found.
|
break # Breaks the inner loop if overlap is found.
|
||||||
return overlapping_markets
|
return overlapping_markets
|
||||||
|
|
||||||
|
|
||||||
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
|
def hide_markets_while_away(
|
||||||
"""Gaps of at least `min_gap_days` between events in a list of events."""
|
events: list[Event], accommodation_events: list[Event]
|
||||||
# Sort events by start date
|
) -> None:
|
||||||
|
"""Hide markets that happen while away."""
|
||||||
gaps: list[tuple[date, date]] = []
|
optional = [
|
||||||
previous_event_end = None
|
e
|
||||||
|
for e in events
|
||||||
by_start_date = {
|
if e.name == "market" or (e.title and "LHG Run Club" in e.title)
|
||||||
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
|
|
||||||
]
|
]
|
||||||
|
going = [e for e in events if e.going]
|
||||||
|
|
||||||
|
overlapping_markets = find_events_during_stay(
|
||||||
|
accommodation_events + going, optional
|
||||||
|
)
|
||||||
|
for market in overlapping_markets:
|
||||||
|
events.remove(market)
|
||||||
|
|
||||||
|
|
||||||
def busy_event(e: Event) -> bool:
|
class AgendaData(typing.TypedDict, total=False):
|
||||||
"""Busy."""
|
"""Agenda Data."""
|
||||||
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"):
|
now: datetime
|
||||||
return False
|
stock_markets: list[str]
|
||||||
|
rockets: list[thespacedevs.Summary]
|
||||||
if e.name == "conference" and not e.going:
|
gwr_advance_tickets: date | None
|
||||||
return False
|
data_gather_seconds: float
|
||||||
if not e.title:
|
stock_market_times_seconds: float
|
||||||
return True
|
timings: list[tuple[str, float]]
|
||||||
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
|
events: list[Event]
|
||||||
return False
|
accommodation_events: list[Event]
|
||||||
|
gaps: list[StrDict]
|
||||||
lc_title = e.title.lower()
|
sunrise: datetime
|
||||||
return "rebels" not in lc_title and "south west data social" not in lc_title
|
sunset: datetime
|
||||||
|
last_week: date
|
||||||
|
two_weeks_ago: date
|
||||||
|
errors: list[tuple[str, Exception]]
|
||||||
|
|
||||||
|
|
||||||
async def get_data(
|
def rocket_launch_events(rockets: list[thespacedevs.Summary]) -> list[Event]:
|
||||||
now: datetime, config: flask.config.Config
|
"""Rocket launch events."""
|
||||||
) -> typing.Mapping[str, str | object]:
|
events: list[Event] = []
|
||||||
|
for launch in rockets:
|
||||||
|
dt = None
|
||||||
|
|
||||||
|
net_precision = launch["net_precision"]
|
||||||
|
skip = {"Year", "Month", "Quarter", "Fiscal Year"}
|
||||||
|
if net_precision == "Day":
|
||||||
|
dt = datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ").date()
|
||||||
|
elif (
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rocket_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)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def get_data(now: datetime, config: flask.config.Config) -> AgendaData:
|
||||||
"""Get data to display on agenda dashboard."""
|
"""Get data to display on agenda dashboard."""
|
||||||
data_dir = config["DATA_DIR"]
|
data_dir = config["DATA_DIR"]
|
||||||
|
|
||||||
|
@ -367,28 +182,51 @@ async def get_data(
|
||||||
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()
|
||||||
gbpusd,
|
offline_mode = bool(config.get("OFFLINE_MODE"))
|
||||||
gwr_advance_tickets,
|
result_list = await asyncio.gather(
|
||||||
bank_holiday,
|
time_function(
|
||||||
rockets,
|
"gwr_advance_tickets", gwr.advance_ticket_date, data_dir, offline_mode
|
||||||
backwell_bins,
|
),
|
||||||
bristol_bins,
|
time_function(
|
||||||
) = await asyncio.gather(
|
"backwell_bins",
|
||||||
fx.get_gbpusd(config),
|
n_somerset_waste_collection_events,
|
||||||
gwr.advance_ticket_date(data_dir),
|
data_dir,
|
||||||
uk_holiday.bank_holiday_list(last_year, next_year, data_dir),
|
config["BACKWELL_POSTCODE"],
|
||||||
thespacedevs.get_launches(rocket_dir, limit=40),
|
config["BACKWELL_UPRN"],
|
||||||
waste_collection_events(data_dir),
|
offline_mode,
|
||||||
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)
|
||||||
|
|
||||||
reply: dict[str, typing.Any] = {
|
results = {call[0]: call[1] for call in result_list}
|
||||||
|
|
||||||
|
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,
|
||||||
"gbpusd": gbpusd,
|
"stock_markets": stock_market_times,
|
||||||
"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"]
|
||||||
|
@ -405,85 +243,43 @@ async def get_data(
|
||||||
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 = us_holidays(last_year, next_year)
|
us_hols = holidays.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")
|
||||||
)
|
)
|
||||||
|
|
||||||
events += combine_holidays(holidays)
|
holiday_list = holidays.get_all(last_year, next_year, data_dir)
|
||||||
events += birthday.get_birthdays(last_year, os.path.join(my_data, "entities.yaml"))
|
events += holidays.combine_holidays(holiday_list)
|
||||||
|
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"))
|
||||||
events += backwell_bins + bristol_bins
|
for key in "backwell_bins", "bristol_bins":
|
||||||
events += read_events_yaml(my_data, last_year, next_year)
|
if results[key]:
|
||||||
|
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 += domains.renewal_dates(my_data)
|
events += rocket_launch_events(rockets)
|
||||||
|
|
||||||
# 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_event(e)
|
if e.as_date > today and e.as_date < next_year and busy.busy_event(e)
|
||||||
]
|
]
|
||||||
|
|
||||||
gaps = find_gaps(busy_events)
|
gaps = busy.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
|
||||||
|
@ -501,9 +297,10 @@ async def get_data(
|
||||||
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["fullcalendar_events"] = calendar.build_events(events)
|
reply["errors"] = errors
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
"""Accomodation."""
|
"""Domain renewal dates."""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from .types import Event
|
from .event import Event
|
||||||
|
|
||||||
url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/"
|
url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/"
|
||||||
|
|
||||||
|
|
|
@ -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 .types import Event
|
from .event import Event
|
||||||
|
|
||||||
|
|
||||||
def publication_dates(start_date: date, end_date: date) -> list[Event]:
|
def publication_dates(start_date: date, end_date: date) -> list[Event]:
|
||||||
|
|
149
agenda/event.py
Normal file
149
agenda/event.py
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
"""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
|
85
agenda/events_yaml.py
Normal file
85
agenda/events_yaml.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
"""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
|
69
agenda/fx.py
69
agenda/fx.py
|
@ -42,3 +42,72 @@ 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"]
|
||||||
|
}
|
||||||
|
|
27
agenda/gandi.py
Normal file
27
agenda/gandi.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""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
|
||||||
|
]
|
107
agenda/geomob.py
Normal file
107
agenda/geomob.py
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
"""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)
|
|
@ -10,6 +10,30 @@ 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
|
||||||
|
@ -18,22 +42,19 @@ def extract_weekday_date(html: str) -> date | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Search the HTML for the pattern
|
# Search the HTML for the pattern
|
||||||
if not (match := pattern.search(html)):
|
if 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(data_dir: str, ttl: int = 60 * 60 * 6) -> str:
|
async def advance_tickets_page_html(
|
||||||
|
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 (time() - mtime) < ttl: # use cache
|
if force_cache or (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)
|
||||||
|
@ -42,7 +63,7 @@ async def advance_tickets_page_html(data_dir: str, ttl: int = 60 * 60 * 6) -> st
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
async def advance_ticket_date(data_dir: str) -> date | None:
|
async def advance_ticket_date(data_dir: str, force_cache: bool = False) -> date | None:
|
||||||
"""Get GWR advance tickets date with cache."""
|
"""Get GWR advance tickets date with cache."""
|
||||||
html = await advance_tickets_page_html(data_dir)
|
html = await advance_tickets_page_html(data_dir, force_cache=force_cache)
|
||||||
return extract_weekday_date(html)
|
return extract_weekday_date(html)
|
||||||
|
|
|
@ -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 .types import Event
|
from .event import Event
|
||||||
|
|
||||||
eastern_time = pytz.timezone("America/New_York")
|
eastern_time = pytz.timezone("America/New_York")
|
||||||
|
|
||||||
|
|
179
agenda/holidays.py
Normal file
179
agenda/holidays.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
"""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
|
28
agenda/mail.py
Normal file
28
agenda/mail.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"""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()
|
|
@ -4,7 +4,7 @@ import json
|
||||||
import os.path
|
import os.path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from .types import Event
|
from .event 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)
|
||||||
|
|
93
agenda/n_somerset_waste.py
Normal file
93
agenda/n_somerset_waste.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
"""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()
|
||||||
|
]
|
60
agenda/stats.py
Normal file
60
agenda/stats.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
"""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)
|
|
@ -3,26 +3,14 @@
|
||||||
from datetime import timedelta, timezone
|
from datetime import timedelta, timezone
|
||||||
|
|
||||||
import dateutil.tz
|
import dateutil.tz
|
||||||
import exchange_calendars
|
import exchange_calendars # type: ignore
|
||||||
import pandas
|
import pandas # type: ignore
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -40,11 +28,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 = timedelta_display(next_close - now_local)
|
delta_close = utils.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 = timedelta_display(now_local - prev_open)
|
delta_open = utils.timedelta_display(now_local - prev_open)
|
||||||
|
|
||||||
msg = (
|
msg = (
|
||||||
f"{label:>6} market opened {delta_open} ago, "
|
f"{label:>6} market opened {delta_open} ago, "
|
||||||
|
@ -54,7 +42,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 = timedelta_display(ts - now_local)
|
delta = utils.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 ""
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .types import Event
|
from .event import Event
|
||||||
|
|
||||||
|
|
||||||
def get_events(filepath: str) -> list[Event]:
|
def get_events(filepath: str) -> list[Event]:
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import typing
|
import typing
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import ephem
|
import ephem # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def bristol() -> ephem.Observer:
|
def bristol() -> ephem.Observer:
|
||||||
|
|
|
@ -5,33 +5,41 @@ import os
|
||||||
import typing
|
import typing
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import requests
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
async def next_launch_api(rocket_dir: str, limit: int = 200) -> list[Launch]:
|
LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
async with httpx.AsyncClient() as client:
|
r = requests.get(url, params=params)
|
||||||
r = await client.get(url, params=params)
|
|
||||||
open(filename, "w").write(r.text)
|
|
||||||
data = r.json()
|
|
||||||
return [summarize_launch(launch) for launch in data["results"]]
|
|
||||||
|
|
||||||
|
|
||||||
def filename_timestamp(filename: str) -> tuple[datetime, str] | None:
|
|
||||||
"""Get datetime from filename."""
|
|
||||||
try:
|
try:
|
||||||
ts = datetime.strptime(filename, "%Y-%m-%d_%H:%M:%S.json")
|
data: StrDict = r.json()
|
||||||
except ValueError:
|
except requests.exceptions.JSONDecodeError:
|
||||||
return None
|
return None
|
||||||
return (ts, filename)
|
open(filename, "w").write(r.text)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
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"]]
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
|
@ -116,6 +124,7 @@ 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,
|
||||||
|
@ -126,7 +135,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"]["full_name"],
|
"rocket": launch["rocket"]["configuration"],
|
||||||
"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"],
|
||||||
|
@ -134,21 +143,39 @@ 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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_launches(rocket_dir: str, limit: int = 200) -> list[Summary]:
|
def load_cached_launches(rocket_dir: str) -> StrDict | None:
|
||||||
|
"""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 = [x for x in (filename_timestamp(f) for f in os.listdir(rocket_dir)) if x]
|
existing = [
|
||||||
|
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 not existing or (now - existing[0][0]).seconds > 3600: # one hour
|
if refresh or not existing or (now - existing[0][0]).seconds > ttl:
|
||||||
try:
|
try:
|
||||||
return await next_launch_api(rocket_dir, limit=limit)
|
return next_launch_api(rocket_dir, limit=limit)
|
||||||
except httpx.ReadTimeout:
|
except Exception:
|
||||||
pass
|
pass # fallback to cached version
|
||||||
|
|
||||||
f = existing[0][1]
|
f = existing[0][1]
|
||||||
|
|
||||||
|
|
101
agenda/travel.py
101
agenda/travel.py
|
@ -1,36 +1,79 @@
|
||||||
"""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 .types import Event
|
from .event 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")
|
||||||
return typing.cast(TravelList, yaml.safe_load(open(filepath)))
|
items: 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."""
|
||||||
return [
|
bookings = parse_yaml("flights", data_dir)
|
||||||
Event(
|
events = []
|
||||||
|
for booking in bookings:
|
||||||
|
for item in booking["flights"]:
|
||||||
|
if not item["depart"].date():
|
||||||
|
continue
|
||||||
|
e = Event(
|
||||||
date=item["depart"],
|
date=item["depart"],
|
||||||
end_date=item.get("arrive"),
|
end_date=item.get("arrive"),
|
||||||
name="transport",
|
name="transport",
|
||||||
title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})',
|
title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})',
|
||||||
url=item.get("url"),
|
url=(item.get("url") if flask.g.user.is_authenticated else None),
|
||||||
)
|
)
|
||||||
for item in parse_yaml("flights", data_dir)
|
events.append(e)
|
||||||
if item["depart"].date()
|
return events
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_trains(data_dir: str) -> list[Event]:
|
def get_trains(data_dir: str) -> list[Event]:
|
||||||
|
@ -43,7 +86,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"),
|
url=(item.get("url") if flask.g.user.is_authenticated else None),
|
||||||
)
|
)
|
||||||
for leg in item["legs"]
|
for leg in item["legs"]
|
||||||
]
|
]
|
||||||
|
@ -62,3 +105,43 @@ 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
|
||||||
|
|
411
agenda/trip.py
Normal file
411
agenda/trip.py
Normal file
|
@ -0,0 +1,411 @@
|
||||||
|
"""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)
|
436
agenda/types.py
436
agenda/types.py
|
@ -1,104 +1,362 @@
|
||||||
"""Types."""
|
"""Types."""
|
||||||
|
|
||||||
import dataclasses
|
import collections
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@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 as_datetime(self) -> datetime.datetime:
|
def display_name(self) -> str:
|
||||||
"""Date/time of event."""
|
"""Format name for display."""
|
||||||
d = self.date
|
|
||||||
t0 = datetime.datetime.min.time()
|
|
||||||
return (
|
return (
|
||||||
d
|
f"{self.name} ({self.local_name})"
|
||||||
if isinstance(d, datetime.datetime)
|
if self.local_name and self.local_name != self.name
|
||||||
else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
|
else self.name
|
||||||
)
|
)
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
|
@ -3,29 +3,38 @@
|
||||||
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
|
from .types import Holiday, StrDict
|
||||||
|
|
||||||
|
|
||||||
async def bank_holiday_list(
|
|
||||||
start_date: date, end_date: date, data_dir: str
|
|
||||||
) -> list[Holiday]:
|
|
||||||
"""Date and name of the next UK bank holiday."""
|
|
||||||
url = "https://www.gov.uk/bank-holidays.json"
|
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
|
def json_filename(data_dir: str) -> str:
|
||||||
|
"""Filename for cached bank holidays."""
|
||||||
|
assert os.path.exists(data_dir)
|
||||||
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
|
events: list[StrDict] = r.json()["england-and-wales"]["events"] # check valid
|
||||||
open(filename, "w").write(r.text)
|
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."""
|
||||||
|
filename = json_filename(data_dir)
|
||||||
|
|
||||||
events = json.load(open(filename))["england-and-wales"]["events"]
|
|
||||||
hols: list[Holiday] = []
|
hols: list[Holiday] = []
|
||||||
for event in events:
|
for event in json.load(open(filename))["england-and-wales"]["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
|
||||||
|
|
120
agenda/utils.py
Normal file
120
agenda/utils.py
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
"""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
|
|
@ -1,209 +0,0 @@
|
||||||
"""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()
|
|
||||||
]
|
|
0
frontend/index.js
Normal file
0
frontend/index.js
Normal file
28
package.json
Normal file
28
package.json
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,3 +9,4 @@ dateutil
|
||||||
ephem
|
ephem
|
||||||
flask
|
flask
|
||||||
requests
|
requests
|
||||||
|
emoji
|
||||||
|
|
4
run.fcgi
4
run.fcgi
|
@ -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/2021/agenda')
|
sys.path.append('/home/edward/src/agenda') # isort:skip
|
||||||
from web_view import app
|
from web_view import app # isort:skip
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
WSGIServer(app).run()
|
WSGIServer(app).run()
|
||||||
|
|
177
static/js/map.js
Normal file
177
static/js/map.js
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
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: '© <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;
|
||||||
|
}
|
|
@ -1,9 +1,12 @@
|
||||||
{% 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(6, auto);
|
grid-template-columns: repeat({{ column_count }}, auto);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
@ -13,24 +16,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
|
grid-column: 1 / {{ column_count + 1 }}; /* 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 %}{{ row(item, badge) }}{% endfor %}
|
{% for item in item_list %}
|
||||||
|
{{ 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 %}
|
||||||
|
|
||||||
|
@ -46,7 +43,9 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="grid-container">
|
<div class="grid-container">
|
||||||
{{ section("Accommodation", items) }}
|
{{ section("Current", current) }}
|
||||||
|
{{ section("Future", future) }}
|
||||||
|
{{ section("Past", past) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -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="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
|
||||||
|
|
||||||
{% 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="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
17
templates/birthday_list.html
Normal file
17
templates/birthday_list.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{% 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 %}
|
|
@ -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</title>
|
<title>Agenda - Edward Betts</title>
|
||||||
<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 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>">
|
<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="https://unpkg.com/es-module-shims@1.8.2/dist/es-module-shims.js"></script>
|
<script async src="{{ url_for("static", filename="es-module-shims/es-module-shims.js") }}"></script>
|
||||||
|
|
||||||
<script type='importmap'>
|
<script type='importmap'>
|
||||||
{
|
{
|
||||||
|
@ -111,84 +111,37 @@
|
||||||
<a href="/tools">← personal tools</a>
|
<a href="/tools">← personal tools</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
{% if errors %}
|
||||||
<li>Today is {{now.strftime("%A, %-d %b %Y")}}</li>
|
{% for error in errors %}
|
||||||
{% if gbpusd %}
|
<div class="alert alert-danger" role="alert">
|
||||||
<li>GBPUSD: {{"{:,.3f}".format(gbpusd)}}</li>
|
Error: {{ error }}
|
||||||
{% endif %}
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<h3>Stock markets</h3>
|
|
||||||
{% for market in stock_markets %}
|
|
||||||
<p>{{ market }}</p>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="mb-3" id="calendar"></div>
|
<div class="mb-3" id="calendar"></div>
|
||||||
|
|
||||||
<h3>Agenda</h3>
|
<div class="mt-2">
|
||||||
|
<h5>Page generation time</h5>
|
||||||
{% for event in events if event.as_date >= two_weeks_ago %}
|
<ul>
|
||||||
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
|
<li>Data gather took {{ "%.1f" | format(data_gather_seconds) }} seconds</li>
|
||||||
<div class="row mt-2">
|
<li>Stock market open/close took
|
||||||
<div class="col">
|
{{ "%.1f" | format(stock_market_times_seconds) }} seconds</li>
|
||||||
<h4>{{ event.date.strftime("%B %Y") }}</h4>
|
{% for name, seconds in timings %}
|
||||||
</div>
|
<li>{{ name }} took {{ "%.1f" | format(seconds) }} seconds</li>
|
||||||
</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")}}
|
|
||||||
|
|
||||||
|
|
||||||
{{event.display_time or ""}}
|
|
||||||
|
|
||||||
|
|
||||||
{{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 %}
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
|
||||||
|
</div>
|
||||||
|
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -1,10 +1,15 @@
|
||||||
{% 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(6, auto); /* 7 columns for each piece of information */
|
grid-template-columns: repeat({{ column_count }}, auto); /* 7 columns for each piece of information */
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
@ -14,49 +19,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
|
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro row(item, badge) %}
|
|
||||||
<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">{{ item.name }}
|
|
||||||
{% if item.going and not (item.accommodation_booked or item.travel_booked) %}
|
|
||||||
<span class="badge text-bg-primary">
|
|
||||||
{{ badge }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.accommodation_booked %}
|
|
||||||
<span class="badge text-bg-success">accommodation</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.transport_booked %}
|
|
||||||
<span class="badge text-bg-success">transport</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="grid-item">{{ item.topic }}</div>
|
|
||||||
<div class="grid-item">{{ item.location }}</div>
|
|
||||||
<div class="grid-item"><a href="{{ item.url }}">{{ item.url }}</a></div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% macro section(heading, item_list, badge) %}
|
{% macro section(heading, item_list, badge) %}
|
||||||
{% if item_list %}
|
{% if item_list %}
|
||||||
<div class="heading"><h2>{{ heading }}</h2></div>
|
<div class="heading"><h2>{{ heading }}</h2></div>
|
||||||
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
|
{% for item in item_list %}
|
||||||
|
{{ conference_row(item, badge) }}
|
||||||
|
<div class="grid-item">
|
||||||
|
{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container-fluid mt-2">
|
<div class="container-fluid mt-2">
|
||||||
|
|
||||||
<h1>Conferences</h1>
|
<h1>Conferences</h1>
|
||||||
|
|
||||||
<div class="grid-container">
|
<div class="grid-container">
|
||||||
{{ section("Current", current, "attending") }}
|
{{ section("Current", current, "attending") }}
|
||||||
{{ section("Future", future, "going") }}
|
{{ section("Future", future, "going") }}
|
||||||
{{ section("Past", past|reverse, "went") }}
|
{{ section("Past", past|reverse|list, "went") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
175
templates/event_list.html
Normal file
175
templates/event_list.html
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
<!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">← 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")}}
|
||||||
|
|
||||||
|
|
||||||
|
{{event.display_time or ""}}
|
||||||
|
|
||||||
|
|
||||||
|
{{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>
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gaps - Edward Betts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
|
@ -17,11 +18,31 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for gap in gaps %}
|
{% for gap in gaps %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{% for event in gap.before %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
|
<td class="text-start">
|
||||||
|
{% 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>{% for event in gap.after %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
|
<td class="text-start">
|
||||||
|
{% 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>
|
||||||
|
|
24
templates/holiday_list.html
Normal file
24
templates/holiday_list.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% 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 %}
|
|
@ -1,11 +1,63 @@
|
||||||
{% 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>
|
||||||
|
|
||||||
{% for launch in rockets %}
|
<h4>Filters</h4>
|
||||||
<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"/>
|
||||||
|
@ -16,8 +68,12 @@
|
||||||
<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">{{ launch.rocket }}
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<abbr title="{{ country.name }}">{{ country.flag }}</abbr>
|
||||||
|
{{ launch.rocket.full_name }}
|
||||||
–
|
–
|
||||||
<strong>{{launch.mission.name }}</strong>
|
<strong>{{launch.mission.name }}</strong>
|
||||||
–
|
–
|
||||||
|
@ -30,14 +86,29 @@
|
||||||
({{ launch.launch_provider_type }})
|
({{ launch.launch_provider_type }})
|
||||||
—
|
—
|
||||||
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
|
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
|
||||||
<br/>
|
—
|
||||||
|
{{ launch.mission.type }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
{% if launch.pad_wikipedia_url %}
|
{% if launch.pad_wikipedia_url %}
|
||||||
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
|
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
|
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
— {{ launch.location }}<br/>
|
— {{ 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 %}
|
||||||
|
<div>
|
||||||
{% 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>
|
||||||
|
@ -45,7 +116,13 @@
|
||||||
{% 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 %}
|
||||||
|
|
267
templates/macros.html
Normal file
267
templates/macros.html
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
{% 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 }} → {{ 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 }} → {{ 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 }} → {{ 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 }} → {{ 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 %}
|
|
@ -2,12 +2,21 @@
|
||||||
|
|
||||||
{% set pages = [
|
{% set pages = [
|
||||||
{"endpoint": "index", "label": "Home" },
|
{"endpoint": "index", "label": "Home" },
|
||||||
{"endpoint": "conference_list", "label": "Conference" },
|
{"endpoint": "recent", "label": "Recent" },
|
||||||
|
{"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">
|
||||||
|
@ -27,6 +36,13 @@
|
||||||
</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>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
{% 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 %}
|
||||||
|
|
|
@ -1,23 +1,23 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros.html" import flight_booking_row, train_row with context %}
|
||||||
|
|
||||||
{% block travel %}
|
{% block title %}Travel - Edward Betts{% endblock %}
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
|
{% set flight_column_count = 10 %}
|
||||||
{% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
|
{% set column_count = 10 %}
|
||||||
|
|
||||||
{% block style %}
|
{% block style %}
|
||||||
<style>
|
<style>
|
||||||
.grid-container {
|
.grid-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, auto); /* 7 columns for each piece of information */
|
grid-template-columns: repeat({{ flight_column_count }}, auto);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.train-grid-container {
|
.train-grid-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
|
grid-template-columns: repeat({{ column_count }}, auto);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
@ -37,27 +37,19 @@
|
||||||
<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">reference</div>
|
<div class="grid-item">tracking</div>
|
||||||
|
<div class="grid-item">distance</div>
|
||||||
|
|
||||||
{% for item in flights | sort(attribute="depart") if item.arrive %}
|
{% for item in flights %}
|
||||||
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
|
{{ flight_booking_row(item) }}
|
||||||
<div class="grid-item">{{ item.from }} → {{ item.to }}</div>
|
|
||||||
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
|
|
||||||
<div class="grid-item">
|
|
||||||
{% if item.arrive %}
|
|
||||||
{{ item.arrive.strftime("%H:%M") }}
|
|
||||||
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="grid-item">{{ item.duration }}</div>
|
|
||||||
<div class="grid-item">{{ item.airline }}{{ item.flight_number }}</div>
|
|
||||||
<div class="grid-item">{{ item.booking_reference }}</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -68,21 +60,15 @@
|
||||||
<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") if item.arrive %}
|
{% for item in trains | sort(attribute="depart") %}
|
||||||
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
|
{{ train_row(item) }}
|
||||||
<div class="grid-item">{{ item.from }} → {{ 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>
|
||||||
|
|
||||||
|
|
230
templates/trip/list.html
Normal file
230
templates/trip/list.html
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
{% 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) }}
|
||||||
|
–
|
||||||
|
{{ e.start_loc }} {{ flag(trip, e.start_country.flag) }}
|
||||||
|
→
|
||||||
|
{{ display_time(e.end_time) }}
|
||||||
|
–
|
||||||
|
{{ 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 %}
|
50
templates/trip/stats.html
Normal file
50
templates/trip/stats.html
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
{% 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 %}
|
67
templates/trip_list_text.html
Normal file
67
templates/trip_list_text.html
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{% 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 }} — {{ trip.locations_str }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
336
templates/trip_page.html
Normal file
336
templates/trip_page.html
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
{% 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}})
|
||||||
|
→
|
||||||
|
{{ item.to_airport.name }} ({{item.to_airport.iata}})
|
||||||
|
{% elif item.type == "train" %}
|
||||||
|
🚆
|
||||||
|
{{ item.from }}
|
||||||
|
→
|
||||||
|
{{ 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 %}
|
||||||
|
→
|
||||||
|
{{ 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) }}
|
||||||
|
→
|
||||||
|
{{ 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 %}
|
45
templates/weekends.html
Normal file
45
templates/weekends.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{% 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 %}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
|
"""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,
|
||||||
|
@ -12,66 +13,67 @@ 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():
|
def mock_today() -> datetime.date:
|
||||||
# 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():
|
def mock_now() -> datetime.datetime:
|
||||||
# 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):
|
def test_next_uk_mothers_day(mock_today: datetime.date) -> None:
|
||||||
# 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):
|
def test_next_uk_fathers_day(mock_today: datetime.date) -> None:
|
||||||
# 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) -> None:
|
def test_get_next_timezone_transition(mock_now: datetime.date) -> 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) -> None:
|
def test_get_next_bank_holiday(mock_today: datetime.date) -> 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):
|
def test_get_gbpusd(mock_now: datetime.datetime) -> None:
|
||||||
# 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):
|
def test_next_economist(mock_today: datetime.date) -> None:
|
||||||
# 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():
|
def test_uk_financial_year_end() -> None:
|
||||||
# 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():
|
def test_timedelta_display() -> None:
|
||||||
# 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"
|
||||||
|
|
96
tests/test_utils.py
Normal file
96
tests/test_utils.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
"""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"
|
205
update.py
Executable file
205
update.py
Executable file
|
@ -0,0 +1,205 @@
|
||||||
|
#!/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)
|
||||||
|
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()
|
|
@ -1,62 +0,0 @@
|
||||||
#!/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()
|
|
164
validate_yaml.py
Executable file
164
validate_yaml.py
Executable file
|
@ -0,0 +1,164 @@
|
||||||
|
#!/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()
|
510
web_view.py
510
web_view.py
|
@ -2,22 +2,32 @@
|
||||||
|
|
||||||
"""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 datetime import date, datetime
|
from collections import defaultdict
|
||||||
|
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.travel
|
import agenda.trip
|
||||||
|
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
|
||||||
|
@ -26,6 +36,12 @@ 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."""
|
||||||
|
@ -53,73 +69,255 @@ 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."""
|
||||||
|
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,
|
||||||
|
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."""
|
"""Index page."""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
data = await agenda.data.get_data(now, app.config)
|
data = await agenda.data.get_data(now, app.config)
|
||||||
|
|
||||||
return flask.render_template("index.html", today=now.date(), **data)
|
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")
|
||||||
async def launch_list() -> str:
|
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")
|
||||||
rockets = await agenda.thespacedevs.get_launches(rocket_dir, limit=100)
|
launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
|
||||||
|
assert launches
|
||||||
|
|
||||||
return flask.render_template("launches.html", rockets=rockets, now=now)
|
mission_type_filter = flask.request.args.get("type")
|
||||||
|
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()
|
||||||
data = await agenda.data.get_data(now, app.config)
|
trip_list = agenda.trip.build_trip_list()
|
||||||
return flask.render_template("gaps.html", today=now.date(), gaps=data["gaps"])
|
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list)
|
||||||
|
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.travel.parse_yaml("flights", data_dir)
|
flights = agenda.trip.load_flight_bookings(data_dir)
|
||||||
trains = agenda.travel.parse_yaml("trains", data_dir)
|
trains = [
|
||||||
|
item
|
||||||
|
for item in travel.parse_yaml("trains", data_dir)
|
||||||
|
if isinstance(item["depart"], datetime)
|
||||||
|
]
|
||||||
|
|
||||||
return flask.render_template("travel.html", flights=flights, trains=trains)
|
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
|
||||||
|
|
||||||
|
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 as_date(d: date | datetime) -> date:
|
def build_conference_list() -> list[StrDict]:
|
||||||
"""Date of event."""
|
"""Build conference list."""
|
||||||
return d.date() if isinstance(d, datetime) else d
|
data_dir = app.config["PERSONAL_DATA"]
|
||||||
|
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()
|
||||||
for conf in item_list:
|
items = build_conference_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 item_list
|
for conf in items
|
||||||
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", current=current, past=past, future=future, today=today
|
"conference_list.html",
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -127,7 +325,7 @@ def conference_list() -> str:
|
||||||
def accommodation_list() -> str:
|
def accommodation_list() -> str:
|
||||||
"""Page showing a list of past, present and future accommodation."""
|
"""Page showing a list of past, present and future accommodation."""
|
||||||
data_dir = app.config["PERSONAL_DATA"]
|
data_dir = app.config["PERSONAL_DATA"]
|
||||||
items = agenda.travel.parse_yaml("accommodation", data_dir)
|
items = travel.parse_yaml("accommodation", data_dir)
|
||||||
|
|
||||||
stays_in_2024 = [item for item in items if item["from"].year == 2024]
|
stays_in_2024 = [item for item in items if item["from"].year == 2024]
|
||||||
total_nights_2024 = sum(
|
total_nights_2024 = sum(
|
||||||
|
@ -140,13 +338,267 @@ 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",
|
||||||
items=items,
|
past=past,
|
||||||
|
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")
|
||||||
|
|
18
webpack.config.js
Normal file
18
webpack.config.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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') }
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
};
|
Loading…
Reference in a new issue