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
|
||||
|
||||
import pycountry
|
||||
import pytz
|
||||
|
||||
uk_tz = pytz.timezone("Europe/London")
|
||||
|
@ -10,3 +11,31 @@ uk_tz = pytz.timezone("Europe/London")
|
|||
def uk_time(d: date, t: time) -> datetime:
|
||||
"""Combine time and date for UK timezone."""
|
||||
return uk_tz.localize(datetime.combine(d, t))
|
||||
|
||||
|
||||
def format_list_with_ampersand(items: list[str]) -> str:
|
||||
"""Join a list of strings with commas and an ampersand."""
|
||||
if len(items) > 1:
|
||||
return ", ".join(items[:-1]) + " & " + items[-1]
|
||||
elif items:
|
||||
return items[0]
|
||||
return ""
|
||||
|
||||
|
||||
def get_country(alpha_2: str) -> pycountry.db.Country | None:
|
||||
"""Lookup country by alpha-2 country code."""
|
||||
if 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
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
|
||||
def get_events(filepath: str) -> list[Event]:
|
||||
"""Get accomodation from YAML."""
|
||||
"""Get accommodation from YAML."""
|
||||
with open(filepath) as f:
|
||||
return [
|
||||
Event(
|
||||
date=item["from"],
|
||||
end_date=item["to"],
|
||||
name="accommodation",
|
||||
title="🧳"
|
||||
+ (
|
||||
title=(
|
||||
f'{item["location"]} Airbnb'
|
||||
if item.get("operator") == "airbnb"
|
||||
else item["name"]
|
||||
|
|
|
@ -4,7 +4,7 @@ from datetime import date
|
|||
|
||||
import yaml
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
YEAR_NOT_KNOWN = 1900
|
||||
|
||||
|
@ -42,7 +42,7 @@ def get_birthdays(from_date: date, filepath: str) -> list[Event]:
|
|||
Event(
|
||||
date=bday.replace(year=bday.year + offset),
|
||||
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
|
||||
from datetime import timedelta
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
event_type_color_map = {
|
||||
"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
|
||||
item = {
|
||||
"allDay": True,
|
||||
"title": e.display_title,
|
||||
"title": e.title_with_emoji,
|
||||
"start": e.as_date.isoformat(),
|
||||
"end": (e.end_as_date + one_day).isoformat(),
|
||||
"url": e.url,
|
||||
|
@ -61,12 +61,12 @@ def build_events(events: list[Event]) -> list[dict[str, typing.Any]]:
|
|||
continue
|
||||
|
||||
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:
|
||||
end = (e.end_as_date if e.end_date else e.as_date) + one_day
|
||||
item = {
|
||||
"allDay": not e.has_time,
|
||||
"title": e.display_title,
|
||||
"title": e.title_with_emoji,
|
||||
"start": e.date.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
|
||||
|
||||
from .types import Event
|
||||
from . import utils
|
||||
from .event import Event
|
||||
|
||||
MAX_CONF_DAYS = 20
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
@ -18,6 +21,8 @@ class Conference:
|
|||
location: str
|
||||
start: date | datetime
|
||||
end: date | datetime
|
||||
trip: date | None = None
|
||||
country: str | None = None
|
||||
venue: str | None = None
|
||||
address: str | None = None
|
||||
url: str | None = None
|
||||
|
@ -29,6 +34,15 @@ class Conference:
|
|||
online: bool = False
|
||||
price: decimal.Decimal | 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
|
||||
def display_name(self) -> str:
|
||||
|
@ -42,17 +56,28 @@ class Conference:
|
|||
|
||||
def get_list(filepath: str) -> list[Event]:
|
||||
"""Read conferences from a YAML file and return a list of Event objects."""
|
||||
return [
|
||||
Event(
|
||||
events: list[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",
|
||||
date=conf.start,
|
||||
end_date=conf.end,
|
||||
title=f"🎤 {conf.display_name}",
|
||||
title=conf.display_name,
|
||||
url=conf.url,
|
||||
going=conf.going,
|
||||
)
|
||||
for conf in (
|
||||
Conference(**conf)
|
||||
for conf in yaml.safe_load(open(filepath, "r"))["conferences"]
|
||||
events.append(event)
|
||||
if not conf.cfp_end:
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import itertools
|
||||
import os
|
||||
import typing
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import time
|
||||
|
||||
import dateutil.rrule
|
||||
import dateutil.tz
|
||||
import flask
|
||||
import holidays
|
||||
import isodate # type: ignore
|
||||
import lxml
|
||||
import pytz
|
||||
import yaml
|
||||
|
||||
from . import (
|
||||
accommodation,
|
||||
birthday,
|
||||
calendar,
|
||||
bristol_waste,
|
||||
busy,
|
||||
carnival,
|
||||
conference,
|
||||
domains,
|
||||
economist,
|
||||
fx,
|
||||
events_yaml,
|
||||
gandi,
|
||||
gwr,
|
||||
hn,
|
||||
holidays,
|
||||
meetup,
|
||||
n_somerset_waste,
|
||||
stock_market,
|
||||
subscription,
|
||||
sun,
|
||||
thespacedevs,
|
||||
travel,
|
||||
uk_holiday,
|
||||
uk_tz,
|
||||
waste_schedule,
|
||||
)
|
||||
from .types import Event, Holiday
|
||||
|
||||
StrDict = dict[str, typing.Any]
|
||||
from .event import Event
|
||||
from .types import StrDict
|
||||
from .utils import time_function
|
||||
|
||||
here = dateutil.tz.tzlocal()
|
||||
|
||||
# deadline to file tax return
|
||||
# credit card expiry dates
|
||||
# morzine ski lifts
|
||||
# chalet availablity calendar
|
||||
# chalet availability calendar
|
||||
|
||||
# starlink visible
|
||||
|
||||
|
@ -62,298 +61,114 @@ def timezone_transition(
|
|||
]
|
||||
|
||||
|
||||
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]
|
||||
async def n_somerset_waste_collection_events(
|
||||
data_dir: str, postcode: str, uprn: str, force_cache: bool = False
|
||||
) -> 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."""
|
||||
postcode = "BS48 3HG"
|
||||
uprn = "24071046"
|
||||
|
||||
html = await waste_schedule.get_html(data_dir, postcode, uprn)
|
||||
html = await n_somerset_waste.get_html(data_dir, postcode, uprn, force_cache)
|
||||
root = lxml.html.fromstring(html)
|
||||
events = waste_schedule.parse(root)
|
||||
events = n_somerset_waste.parse(root)
|
||||
return 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]:
|
||||
"""Waste colllection events."""
|
||||
uprn = "358335"
|
||||
|
||||
return await waste_schedule.get_bristol_gov_uk(start_date, data_dir, uprn)
|
||||
cache = "force" if force_cache else "recent"
|
||||
return await bristol_waste.get(start_date, data_dir, uprn, cache)
|
||||
|
||||
|
||||
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_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(
|
||||
def find_events_during_stay(
|
||||
accommodation_events: list[Event], markets: list[Event]
|
||||
) -> list[Event]:
|
||||
"""Market events that happen during accommodation stays."""
|
||||
overlapping_markets = []
|
||||
for market in markets:
|
||||
market_date = market.as_date
|
||||
assert isinstance(market_date, date)
|
||||
for e in accommodation_events:
|
||||
start, end = e.as_date, e.end_as_date
|
||||
assert start and end and all(isinstance(i, date) for i in (start, end))
|
||||
# Check if the market date is within the accommodation dates.
|
||||
if e.as_date <= market.as_date <= e.end_as_date:
|
||||
if start <= market_date <= end:
|
||||
overlapping_markets.append(market)
|
||||
break # Breaks the inner loop if overlap is found.
|
||||
return overlapping_markets
|
||||
|
||||
|
||||
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
|
||||
"""Gaps of at least `min_gap_days` between events in a list of events."""
|
||||
# Sort events by start date
|
||||
|
||||
gaps: list[tuple[date, date]] = []
|
||||
previous_event_end = None
|
||||
|
||||
by_start_date = {
|
||||
d: list(on_day)
|
||||
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
|
||||
}
|
||||
|
||||
by_end_date = {
|
||||
d: list(on_day)
|
||||
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
|
||||
}
|
||||
|
||||
for event in events:
|
||||
# Use start date for current event
|
||||
start_date = event.as_date
|
||||
|
||||
# If previous event exists, calculate the gap
|
||||
if previous_event_end:
|
||||
gap_days = (start_date - previous_event_end).days
|
||||
if gap_days >= (min_gap_days + 2):
|
||||
start_end = (
|
||||
previous_event_end + timedelta(days=1),
|
||||
start_date - timedelta(days=1),
|
||||
)
|
||||
gaps.append(start_end)
|
||||
|
||||
# Update previous event end date
|
||||
end = event.end_as_date
|
||||
if not previous_event_end or end > previous_event_end:
|
||||
previous_event_end = end
|
||||
|
||||
return [
|
||||
{
|
||||
"start": gap_start,
|
||||
"end": gap_end,
|
||||
"after": by_start_date[gap_end + timedelta(days=1)],
|
||||
"before": by_end_date[gap_start - timedelta(days=1)],
|
||||
}
|
||||
for gap_start, gap_end in gaps
|
||||
def hide_markets_while_away(
|
||||
events: list[Event], accommodation_events: list[Event]
|
||||
) -> None:
|
||||
"""Hide markets that happen while away."""
|
||||
optional = [
|
||||
e
|
||||
for e in events
|
||||
if e.name == "market" or (e.title and "LHG Run Club" in e.title)
|
||||
]
|
||||
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:
|
||||
"""Busy."""
|
||||
if e.name not in {
|
||||
"event",
|
||||
"accommodation",
|
||||
"conference",
|
||||
"dodainville",
|
||||
"transport",
|
||||
"meetup",
|
||||
"party",
|
||||
}:
|
||||
return False
|
||||
class AgendaData(typing.TypedDict, total=False):
|
||||
"""Agenda Data."""
|
||||
|
||||
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
|
||||
now: datetime
|
||||
stock_markets: list[str]
|
||||
rockets: list[thespacedevs.Summary]
|
||||
gwr_advance_tickets: date | None
|
||||
data_gather_seconds: float
|
||||
stock_market_times_seconds: float
|
||||
timings: list[tuple[str, float]]
|
||||
events: list[Event]
|
||||
accommodation_events: list[Event]
|
||||
gaps: list[StrDict]
|
||||
sunrise: datetime
|
||||
sunset: datetime
|
||||
last_week: date
|
||||
two_weeks_ago: date
|
||||
errors: list[tuple[str, Exception]]
|
||||
|
||||
|
||||
async def get_data(
|
||||
now: datetime, config: flask.config.Config
|
||||
) -> typing.Mapping[str, str | object]:
|
||||
def rocket_launch_events(rockets: list[thespacedevs.Summary]) -> list[Event]:
|
||||
"""Rocket launch events."""
|
||||
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."""
|
||||
data_dir = config["DATA_DIR"]
|
||||
|
||||
|
@ -367,28 +182,51 @@ async def get_data(
|
|||
minus_365 = now - timedelta(days=365)
|
||||
plus_365 = now + timedelta(days=365)
|
||||
|
||||
(
|
||||
gbpusd,
|
||||
gwr_advance_tickets,
|
||||
bank_holiday,
|
||||
rockets,
|
||||
backwell_bins,
|
||||
bristol_bins,
|
||||
) = await asyncio.gather(
|
||||
fx.get_gbpusd(config),
|
||||
gwr.advance_ticket_date(data_dir),
|
||||
uk_holiday.bank_holiday_list(last_year, next_year, data_dir),
|
||||
thespacedevs.get_launches(rocket_dir, limit=40),
|
||||
waste_collection_events(data_dir),
|
||||
bristol_waste_collection_events(data_dir, today),
|
||||
t0 = time()
|
||||
offline_mode = bool(config.get("OFFLINE_MODE"))
|
||||
result_list = await asyncio.gather(
|
||||
time_function(
|
||||
"gwr_advance_tickets", gwr.advance_ticket_date, data_dir, offline_mode
|
||||
),
|
||||
time_function(
|
||||
"backwell_bins",
|
||||
n_somerset_waste_collection_events,
|
||||
data_dir,
|
||||
config["BACKWELL_POSTCODE"],
|
||||
config["BACKWELL_UPRN"],
|
||||
offline_mode,
|
||||
),
|
||||
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,
|
||||
"gbpusd": gbpusd,
|
||||
"stock_markets": stock_market.open_and_close(),
|
||||
"stock_markets": stock_market_times,
|
||||
"rockets": rockets,
|
||||
"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"]
|
||||
|
@ -405,85 +243,43 @@ async def get_data(
|
|||
if gwr_advance_tickets:
|
||||
events.append(Event(name="gwr_advance_tickets", date=gwr_advance_tickets))
|
||||
|
||||
us_hols = us_holidays(last_year, next_year)
|
||||
|
||||
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)
|
||||
us_hols = holidays.us_holidays(last_year, next_year)
|
||||
events += holidays.get_nyse_holidays(last_year, next_year, us_hols)
|
||||
|
||||
accommodation_events = accommodation.get_events(
|
||||
os.path.join(my_data, "accommodation.yaml")
|
||||
)
|
||||
|
||||
events += combine_holidays(holidays)
|
||||
events += birthday.get_birthdays(last_year, os.path.join(my_data, "entities.yaml"))
|
||||
holiday_list = holidays.get_all(last_year, next_year, data_dir)
|
||||
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 += travel.all_events(my_data)
|
||||
events += conference.get_list(os.path.join(my_data, "conferences.yaml"))
|
||||
events += backwell_bins + bristol_bins
|
||||
events += read_events_yaml(my_data, last_year, next_year)
|
||||
for key in "backwell_bins", "bristol_bins":
|
||||
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 += gandi.get_events(data_dir)
|
||||
events += economist.publication_dates(last_week, next_year)
|
||||
events += meetup.get_events(my_data)
|
||||
events += hn.whoishiring(last_year, next_year)
|
||||
|
||||
events += domains.renewal_dates(my_data)
|
||||
|
||||
# hide markets that happen while away
|
||||
markets = [e for e in events if e.name == "market"]
|
||||
going = [e for e in events if e.going]
|
||||
|
||||
overlapping_markets = find_markets_during_stay(
|
||||
accommodation_events + going, markets
|
||||
)
|
||||
for market in overlapping_markets:
|
||||
events.remove(market)
|
||||
|
||||
for launch in rockets:
|
||||
dt = None
|
||||
|
||||
if launch["net_precision"] == "Day":
|
||||
dt = datetime.strptime(launch["net"], "%Y-%m-%dT00:00:00Z").date()
|
||||
elif launch["t0_time"]:
|
||||
dt = pytz.utc.localize(
|
||||
datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
)
|
||||
|
||||
if not dt:
|
||||
continue
|
||||
|
||||
rocket_name = f'🚀{launch["rocket"]}: {launch["mission_name"] or "[no mission]"}'
|
||||
e = Event(name="rocket", date=dt, title=rocket_name)
|
||||
events.append(e)
|
||||
|
||||
events += carnival.rio_carnival_events(last_year, next_year)
|
||||
events += rocket_launch_events(rockets)
|
||||
events += [Event(name="today", date=today)]
|
||||
|
||||
busy_events = [
|
||||
e
|
||||
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 += [
|
||||
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["sunset"] = sun.sunset(observer)
|
||||
reply["events"] = events
|
||||
reply["accommodation_events"] = accommodation_events
|
||||
reply["last_week"] = last_week
|
||||
reply["two_weeks_ago"] = two_weeks_ago
|
||||
|
||||
reply["fullcalendar_events"] = calendar.build_events(events)
|
||||
reply["errors"] = errors
|
||||
|
||||
return reply
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
"""Accomodation."""
|
||||
"""Domain renewal dates."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
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 . import uk_time
|
||||
from .types import Event
|
||||
from .event import 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)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
"""Furthest date of GWR advance ticket booking."""
|
||||
# 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
|
||||
if not (match := pattern.search(html)):
|
||||
if match := pattern.search(html):
|
||||
return parse_date_string(match.group(1))
|
||||
else:
|
||||
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."""
|
||||
filename = os.path.join(data_dir, "advance-tickets.html")
|
||||
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()
|
||||
async with httpx.AsyncClient() as client:
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
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)
|
||||
|
|
|
@ -5,7 +5,7 @@ from datetime import date, datetime, time, timedelta
|
|||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
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
|
||||
from datetime import datetime
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
|
||||
def get_events(data_dir: str) -> list[Event]:
|
||||
|
@ -21,7 +21,7 @@ def get_events(data_dir: str) -> list[Event]:
|
|||
date=start,
|
||||
end_date=end,
|
||||
name="meetup",
|
||||
title="👥" + item_event["title"],
|
||||
title=item_event["title"],
|
||||
url=item_event["eventUrl"],
|
||||
)
|
||||
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
|
||||
|
||||
import dateutil.tz
|
||||
import exchange_calendars
|
||||
import pandas
|
||||
import exchange_calendars # type: ignore
|
||||
import pandas # type: ignore
|
||||
|
||||
from . import utils
|
||||
|
||||
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]:
|
||||
"""Stock markets open and close times."""
|
||||
# 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):
|
||||
next_close = cal.next_close(now).tz_convert(here)
|
||||
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 = 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 = (
|
||||
f"{label:>6} market opened {delta_open} ago, "
|
||||
|
@ -54,7 +42,7 @@ def open_and_close() -> list[str]:
|
|||
ts = cal.next_open(now)
|
||||
ts = ts.replace(minute=round(ts.minute, -1))
|
||||
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}" + (
|
||||
f" ({ts:%H:%M})" if (ts - now_local) < timedelta(days=1) else ""
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import yaml
|
||||
|
||||
from .types import Event
|
||||
from .event import Event
|
||||
|
||||
|
||||
def get_events(filepath: str) -> list[Event]:
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
import ephem
|
||||
import ephem # type: ignore
|
||||
|
||||
|
||||
def bristol() -> ephem.Observer:
|
||||
|
|
|
@ -5,33 +5,41 @@ import os
|
|||
import typing
|
||||
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]
|
||||
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."""
|
||||
now = datetime.now()
|
||||
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/"
|
||||
|
||||
params: dict[str, str | int] = {"limit": limit}
|
||||
async with httpx.AsyncClient() as client:
|
||||
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."""
|
||||
r = requests.get(url, params=params)
|
||||
try:
|
||||
ts = datetime.strptime(filename, "%Y-%m-%d_%H:%M:%S.json")
|
||||
except ValueError:
|
||||
data: StrDict = r.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
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]:
|
||||
|
@ -116,6 +124,7 @@ def summarize_launch(launch: Launch) -> Summary:
|
|||
|
||||
return {
|
||||
"name": launch.get("name"),
|
||||
"slug": launch["slug"],
|
||||
"status": launch.get("status"),
|
||||
"net": launch.get("net"),
|
||||
"net_precision": net_precision,
|
||||
|
@ -126,7 +135,7 @@ def summarize_launch(launch: Launch) -> Summary:
|
|||
"launch_provider": launch_provider,
|
||||
"launch_provider_abbrev": launch_provider_abbrev,
|
||||
"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_name": get_nested(launch, ["mission", "name"]),
|
||||
"pad_name": launch["pad"]["name"],
|
||||
|
@ -134,21 +143,39 @@ def summarize_launch(launch: Launch) -> Summary:
|
|||
"location": launch["pad"]["location"]["name"],
|
||||
"country_code": launch["pad"]["country_code"],
|
||||
"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."""
|
||||
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)
|
||||
|
||||
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:
|
||||
return await next_launch_api(rocket_dir, limit=limit)
|
||||
except httpx.ReadTimeout:
|
||||
pass
|
||||
return next_launch_api(rocket_dir, limit=limit)
|
||||
except Exception:
|
||||
pass # fallback to cached version
|
||||
|
||||
f = existing[0][1]
|
||||
|
||||
|
|
101
agenda/travel.py
101
agenda/travel.py
|
@ -1,36 +1,79 @@
|
|||
"""Travel."""
|
||||
|
||||
import decimal
|
||||
import json
|
||||
import os
|
||||
import typing
|
||||
|
||||
import flask
|
||||
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]
|
||||
|
||||
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:
|
||||
"""Parse flights YAML and return list of travel."""
|
||||
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]:
|
||||
"""Get travel events."""
|
||||
return [
|
||||
Event(
|
||||
bookings = parse_yaml("flights", data_dir)
|
||||
events = []
|
||||
for booking in bookings:
|
||||
for item in booking["flights"]:
|
||||
if not item["depart"].date():
|
||||
continue
|
||||
e = Event(
|
||||
date=item["depart"],
|
||||
end_date=item.get("arrive"),
|
||||
name="transport",
|
||||
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)
|
||||
if item["depart"].date()
|
||||
]
|
||||
events.append(e)
|
||||
return events
|
||||
|
||||
|
||||
def get_trains(data_dir: str) -> list[Event]:
|
||||
|
@ -43,7 +86,7 @@ def get_trains(data_dir: str) -> list[Event]:
|
|||
end_date=leg["arrive"],
|
||||
name="transport",
|
||||
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"]
|
||||
]
|
||||
|
@ -62,3 +105,43 @@ def flight_number(flight: Leg) -> str:
|
|||
def all_events(data_dir: str) -> list[Event]:
|
||||
"""Get all flights and rail journeys."""
|
||||
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."""
|
||||
|
||||
import dataclasses
|
||||
import collections
|
||||
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:
|
||||
"""Holiay."""
|
||||
|
||||
name: str
|
||||
country: str
|
||||
date: datetime.date
|
||||
|
||||
|
||||
@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
|
||||
local_name: str | None = None
|
||||
|
||||
@property
|
||||
def as_datetime(self) -> datetime.datetime:
|
||||
"""Date/time of event."""
|
||||
d = self.date
|
||||
t0 = datetime.datetime.min.time()
|
||||
def display_name(self) -> str:
|
||||
"""Format name for display."""
|
||||
return (
|
||||
d
|
||||
if isinstance(d, datetime.datetime)
|
||||
else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
|
||||
f"{self.name} ({self.local_name})"
|
||||
if self.local_name and self.local_name != self.name
|
||||
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 os
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import time
|
||||
|
||||
import httpx
|
||||
from dateutil.easter import easter
|
||||
|
||||
from .types import Holiday
|
||||
from .types import Holiday, StrDict
|
||||
|
||||
url = "https://www.gov.uk/bank-holidays.json"
|
||||
|
||||
|
||||
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"
|
||||
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:
|
||||
r = await client.get(url)
|
||||
events: list[StrDict] = r.json()["england-and-wales"]["events"] # check valid
|
||||
open(filename, "w").write(r.text)
|
||||
return events
|
||||
|
||||
|
||||
def bank_holiday_list(start_date: date, end_date: date, data_dir: str) -> list[Holiday]:
|
||||
"""Date and name of the next UK bank holiday."""
|
||||
filename = json_filename(data_dir)
|
||||
|
||||
events = json.load(open(filename))["england-and-wales"]["events"]
|
||||
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()
|
||||
if event_date < start_date:
|
||||
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
|
||||
flask
|
||||
requests
|
||||
emoji
|
||||
|
|
4
run.fcgi
4
run.fcgi
|
@ -1,8 +1,8 @@
|
|||
#!/usr/bin/python3
|
||||
from flipflop import WSGIServer
|
||||
import sys
|
||||
sys.path.append('/home/edward/src/2021/agenda')
|
||||
from web_view import app
|
||||
sys.path.append('/home/edward/src/agenda') # isort:skip
|
||||
from web_view import app # isort:skip
|
||||
|
||||
if __name__ == '__main__':
|
||||
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" %}
|
||||
{% from "macros.html" import trip_link, accommodation_row with context %}
|
||||
{% block title %}Accommodation - Edward Betts{% endblock %}
|
||||
{% block style %}
|
||||
{% set column_count = 9 %}
|
||||
<style>
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-template-columns: repeat({{ column_count }}, auto);
|
||||
gap: 10px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
@ -13,24 +16,18 @@
|
|||
}
|
||||
|
||||
.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>
|
||||
{% 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) %}
|
||||
{% if item_list %}
|
||||
<div class="heading"><h2>{{heading}}</h2></div>
|
||||
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
|
||||
{% for item in item_list %}
|
||||
{{ accommodation_row(item, badge) }}
|
||||
<div class="grid-item">{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
@ -46,7 +43,9 @@
|
|||
</ul>
|
||||
|
||||
<div class="grid-container">
|
||||
{{ section("Accommodation", items) }}
|
||||
{{ section("Current", current) }}
|
||||
{{ section("Future", future) }}
|
||||
{{ section("Past", past) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<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 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 %}
|
||||
{% endblock %}
|
||||
|
@ -18,6 +18,6 @@
|
|||
{% block nav %}{{ navbar() }}{% endblock %}
|
||||
{% block content %}{% 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>
|
||||
</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>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Agenda</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">
|
||||
<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="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'>
|
||||
{
|
||||
|
@ -111,84 +111,37 @@
|
|||
<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>
|
||||
|
||||
<h3>Stock markets</h3>
|
||||
{% for market in stock_markets %}
|
||||
<p>{{ market }}</p>
|
||||
{% if errors %}
|
||||
{% for error in errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
Error: {{ error }}
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
<h3>Agenda</h3>
|
||||
|
||||
{% for event in events if event.as_date >= two_weeks_ago %}
|
||||
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
|
||||
<div class="row mt-2">
|
||||
<div class="col">
|
||||
<h4>{{ event.date.strftime("%B %Y") }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
|
||||
</ul>
|
||||
</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>
|
||||
</html>
|
|
@ -1,10 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% from "macros.html" import trip_link, conference_row with context %}
|
||||
|
||||
{% block title %}Conferences - Edward Betts{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
{% set column_count = 9 %}
|
||||
<style>
|
||||
.grid-container {
|
||||
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;
|
||||
justify-content: start;
|
||||
}
|
||||
|
@ -14,49 +19,31 @@
|
|||
}
|
||||
|
||||
.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>
|
||||
{% 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) %}
|
||||
{% if item_list %}
|
||||
<div class="heading"><h2>{{heading}}</h2></div>
|
||||
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
|
||||
{% endif %}
|
||||
{% if item_list %}
|
||||
<div class="heading"><h2>{{ heading }}</h2></div>
|
||||
{% for item in item_list %}
|
||||
{{ conference_row(item, badge) }}
|
||||
<div class="grid-item">
|
||||
{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid mt-2">
|
||||
|
||||
<h1>Conferences</h1>
|
||||
|
||||
<div class="grid-container">
|
||||
{{ section("Current", current, "attending") }}
|
||||
{{ section("Future", future, "going") }}
|
||||
{{ section("Past", past|reverse, "went") }}
|
||||
{{ section("Past", past|reverse|list, "went") }}
|
||||
</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" %}
|
||||
{% block title %}Gaps - Edward Betts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-2">
|
||||
|
@ -17,11 +18,31 @@
|
|||
<tbody>
|
||||
{% for gap in gaps %}
|
||||
<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.end - gap.start).days }} days</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>
|
||||
{% endfor %}
|
||||
</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" %}
|
||||
|
||||
{% block title %}Space launches - Edward Betts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-2">
|
||||
<h1>Space launches</h1>
|
||||
|
||||
{% for launch in rockets %}
|
||||
<div class="row">
|
||||
<h4>Filters</h4>
|
||||
|
||||
<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 }}
|
||||
|
||||
<br class="d-none d-md-block"/>
|
||||
|
@ -16,8 +68,12 @@
|
|||
<div class="col-md-1 text-md-nowrap">
|
||||
<span class="d-md-none">launch status:</span>
|
||||
<abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr>
|
||||
{% if launch.probability %}{{ launch.probability }}%{% endif %}
|
||||
</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>
|
||||
–
|
||||
|
@ -30,14 +86,29 @@
|
|||
({{ launch.launch_provider_type }})
|
||||
—
|
||||
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
|
||||
<br/>
|
||||
—
|
||||
{{ launch.mission.type }}
|
||||
</div>
|
||||
<div>
|
||||
{% if launch.pad_wikipedia_url %}
|
||||
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
|
||||
{% else %}
|
||||
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
|
||||
{% endif %}
|
||||
— {{ 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 %}
|
||||
{% for line in launch.mission.description.splitlines() %}
|
||||
<p>{{ line }}</p>
|
||||
|
@ -45,7 +116,13 @@
|
|||
{% else %}
|
||||
<p>No description.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if launch.weather_concerns %}
|
||||
<h4>Weather concerns</h4>
|
||||
{% for line in launch.weather_concerns.splitlines() %}
|
||||
<p>{{ line }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 = [
|
||||
{"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": "accommodation_list", "label": "Accommodation" },
|
||||
{"endpoint": "gaps_page", "label": "Gaps" },
|
||||
{"endpoint": "weekends", "label": "Weekends" },
|
||||
{"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">
|
||||
|
@ -27,6 +36,13 @@
|
|||
</li>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</nav>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Agenda error - Edward Betts{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='css/exception.css')}}" />
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros.html" import flight_booking_row, train_row with context %}
|
||||
|
||||
{% block travel %}
|
||||
{% endblock %}
|
||||
{% block title %}Travel - Edward Betts{% endblock %}
|
||||
|
||||
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
|
||||
{% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
|
||||
{% set flight_column_count = 10 %}
|
||||
{% set column_count = 10 %}
|
||||
|
||||
{% block style %}
|
||||
<style>
|
||||
.grid-container {
|
||||
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;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.train-grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
|
||||
grid-template-columns: repeat({{ column_count }}, auto);
|
||||
gap: 10px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
@ -37,27 +37,19 @@
|
|||
<h3>flights</h3>
|
||||
|
||||
<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">route</div>
|
||||
<div class="grid-item">take-off</div>
|
||||
<div class="grid-item">land</div>
|
||||
<div class="grid-item">duration</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 %}
|
||||
<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">{{ item.airline }}{{ item.flight_number }}</div>
|
||||
<div class="grid-item">{{ item.booking_reference }}</div>
|
||||
{% for item in flights %}
|
||||
{{ flight_booking_row(item) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
@ -68,21 +60,15 @@
|
|||
<div class="grid-item">route</div>
|
||||
<div class="grid-item">depart</div>
|
||||
<div class="grid-item">arrive</div>
|
||||
<div class="grid-item">duration</div>
|
||||
<div class="grid-item">operator</div>
|
||||
<div class="grid-item">reference</div>
|
||||
<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 %}
|
||||
<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.operator }}</div>
|
||||
<div class="grid-item">{{ item.booking_reference }}</div>
|
||||
{% for item in trains | sort(attribute="depart") %}
|
||||
{{ train_row(item) }}
|
||||
{% endfor %}
|
||||
</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
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from agenda import (
|
||||
get_gbpusd,
|
||||
get_next_bank_holiday,
|
||||
get_next_timezone_transition,
|
||||
next_economist,
|
||||
|
@ -12,66 +13,67 @@ from agenda import (
|
|||
timedelta_display,
|
||||
uk_financial_year_end,
|
||||
)
|
||||
from agenda.fx import get_gbpusd
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_today():
|
||||
# Mock the current date for testing purposes
|
||||
def mock_today() -> datetime.date:
|
||||
"""Mock the current date for testing purposes."""
|
||||
return datetime.date(2023, 10, 5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_now():
|
||||
# Mock the current date and time for testing purposes
|
||||
def mock_now() -> datetime.datetime:
|
||||
"""Mock the current date and time for testing purposes."""
|
||||
return datetime.datetime(2023, 10, 5, 12, 0, 0)
|
||||
|
||||
|
||||
def test_next_uk_mothers_day(mock_today):
|
||||
# Test next_uk_mothers_day function
|
||||
def test_next_uk_mothers_day(mock_today: datetime.date) -> None:
|
||||
"""Test next_uk_mothers_day function."""
|
||||
next_mothers_day = next_uk_mothers_day(mock_today)
|
||||
assert next_mothers_day == datetime.date(2024, 4, 21)
|
||||
|
||||
|
||||
def test_next_uk_fathers_day(mock_today):
|
||||
# Test next_uk_fathers_day function
|
||||
def test_next_uk_fathers_day(mock_today: datetime.date) -> None:
|
||||
"""Test next_uk_fathers_day function."""
|
||||
next_fathers_day = next_uk_fathers_day(mock_today)
|
||||
assert next_fathers_day == datetime.date(2024, 6, 21)
|
||||
|
||||
|
||||
def test_get_next_timezone_transition(mock_now) -> None:
|
||||
# Test get_next_timezone_transition function
|
||||
def test_get_next_timezone_transition(mock_now: datetime.date) -> None:
|
||||
"""Test get_next_timezone_transition function."""
|
||||
next_transition = get_next_timezone_transition(mock_now, "Europe/London")
|
||||
assert next_transition == datetime.date(2023, 10, 29)
|
||||
|
||||
|
||||
def test_get_next_bank_holiday(mock_today) -> None:
|
||||
# Test get_next_bank_holiday function
|
||||
def test_get_next_bank_holiday(mock_today: datetime.date) -> None:
|
||||
"""Test get_next_bank_holiday function."""
|
||||
next_holiday = get_next_bank_holiday(mock_today)[0]
|
||||
assert next_holiday.date == datetime.date(2023, 12, 25)
|
||||
assert next_holiday.title == "Christmas Day"
|
||||
|
||||
|
||||
def test_get_gbpusd(mock_now):
|
||||
# Test get_gbpusd function
|
||||
def test_get_gbpusd(mock_now: datetime.datetime) -> None:
|
||||
"""Test get_gbpusd function."""
|
||||
gbpusd = get_gbpusd()
|
||||
assert isinstance(gbpusd, Decimal)
|
||||
# You can add more assertions based on your specific use case.
|
||||
|
||||
|
||||
def test_next_economist(mock_today):
|
||||
# Test next_economist function
|
||||
def test_next_economist(mock_today: datetime.date) -> None:
|
||||
"""Test next_economist function."""
|
||||
next_publication = next_economist(mock_today)
|
||||
assert next_publication == datetime.date(2023, 10, 5)
|
||||
|
||||
|
||||
def test_uk_financial_year_end():
|
||||
# Test uk_financial_year_end function
|
||||
def test_uk_financial_year_end() -> None:
|
||||
"""Test uk_financial_year_end function."""
|
||||
financial_year_end = uk_financial_year_end(datetime.date(2023, 4, 1))
|
||||
assert financial_year_end == datetime.date(2023, 4, 5)
|
||||
|
||||
|
||||
def test_timedelta_display():
|
||||
# Test timedelta_display function
|
||||
def test_timedelta_display() -> None:
|
||||
"""Test timedelta_display function."""
|
||||
delta = datetime.timedelta(days=2, hours=5, minutes=30)
|
||||
display = timedelta_display(delta)
|
||||
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."""
|
||||
|
||||
import decimal
|
||||
import inspect
|
||||
import operator
|
||||
import os.path
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import date, datetime
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import flask
|
||||
import UniAuth.auth
|
||||
import werkzeug
|
||||
import werkzeug.debug.tbtools
|
||||
import yaml
|
||||
|
||||
import agenda.data
|
||||
import agenda.error_mail
|
||||
import agenda.fx
|
||||
import agenda.holidays
|
||||
import agenda.stats
|
||||
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.debug = False
|
||||
|
@ -26,6 +36,12 @@ app.config.from_object("config.default")
|
|||
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)
|
||||
def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, int]:
|
||||
"""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("/")
|
||||
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."""
|
||||
now = datetime.now()
|
||||
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")
|
||||
async def launch_list() -> str:
|
||||
def launch_list() -> str:
|
||||
"""Web page showing List of space launches."""
|
||||
now = datetime.now()
|
||||
data_dir = app.config["DATA_DIR"]
|
||||
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")
|
||||
async def gaps_page() -> str:
|
||||
"""List of available gaps."""
|
||||
now = datetime.now()
|
||||
data = await agenda.data.get_data(now, app.config)
|
||||
return flask.render_template("gaps.html", today=now.date(), gaps=data["gaps"])
|
||||
trip_list = agenda.trip.build_trip_list()
|
||||
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")
|
||||
def travel_list() -> str:
|
||||
"""Page showing a list of upcoming travel."""
|
||||
data_dir = app.config["PERSONAL_DATA"]
|
||||
flights = agenda.travel.parse_yaml("flights", data_dir)
|
||||
trains = agenda.travel.parse_yaml("trains", data_dir)
|
||||
flights = agenda.trip.load_flight_bookings(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:
|
||||
"""Date of event."""
|
||||
return d.date() if isinstance(d, datetime) else d
|
||||
def build_conference_list() -> list[StrDict]:
|
||||
"""Build conference list."""
|
||||
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")
|
||||
def conference_list() -> str:
|
||||
"""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()
|
||||
for conf in item_list:
|
||||
conf["start_date"] = as_date(conf["start"])
|
||||
conf["end_date"] = as_date(conf["end"])
|
||||
|
||||
item_list.sort(key=operator.itemgetter("start_date"))
|
||||
items = build_conference_list()
|
||||
|
||||
current = [
|
||||
conf
|
||||
for conf in item_list
|
||||
for conf in items
|
||||
if conf["start_date"] <= today and conf["end_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]
|
||||
future = [conf for conf in items if conf["start_date"] > today]
|
||||
|
||||
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:
|
||||
"""Page showing a list of past, present and future accommodation."""
|
||||
data_dir = app.config["PERSONAL_DATA"]
|
||||
items = agenda.travel.parse_yaml("accommodation", data_dir)
|
||||
items = travel.parse_yaml("accommodation", data_dir)
|
||||
|
||||
stays_in_2024 = [item for item in items if item["from"].year == 2024]
|
||||
total_nights_2024 = sum(
|
||||
|
@ -140,13 +338,267 @@ def accommodation_list() -> str:
|
|||
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(
|
||||
"accommodation.html",
|
||||
items=items,
|
||||
past=past,
|
||||
current=current,
|
||||
future=future,
|
||||
total_nights_2024=total_nights_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__":
|
||||
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