#!/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 import flask import requests import agenda.birthday 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.event import Event from agenda.types import StrDict from web_view import app async def update_bank_holidays(config: flask.config.Config) -> None: """Update cached copy of UK Bank holidays.""" t0 = time() events = await agenda.uk_holiday.get_holiday_list(config["DATA_DIR"]) time_taken = time() - t0 if not sys.stdin.isatty(): return print(len(events), "bank holidays in list") print(f"took {time_taken:.1f} seconds") async def update_bristol_bins(config: flask.config.Config) -> None: """Update waste schedule from Bristol City Council.""" t0 = time() events = await agenda.bristol_waste.get( date.today(), config["DATA_DIR"], config["BRISTOL_UPRN"], cache="refresh", ) time_taken = time() - t0 if not sys.stdin.isatty(): return for event in events: print(event) print(f"took {time_taken:.1f} seconds") def update_gwr_advance_ticket_date(config: flask.config.Config) -> None: """Update GWR advance ticket date cache.""" filename = os.path.join(config["DATA_DIR"], "advance-tickets.html") existing_html = open(filename).read() existing_dates = agenda.gwr.extract_dates(existing_html) assert existing_dates assert list(existing_dates.keys()) == ["Weekdays", "Saturdays", "Sundays"] new_html = requests.get(agenda.gwr.url).text new_dates = agenda.gwr.extract_dates(new_html) if not new_dates: subject = "Error parsing GWR advance ticket booking dates" body = new_html agenda.mail.send_mail(config, subject, body) return assert new_dates assert list(new_dates.keys()) == ["Weekdays", "Saturdays", "Sundays"] if existing_dates == new_dates: if sys.stdin.isatty(): print(filename) print(agenda.gwr.url) print("dates haven't changed:", existing_dates) return open(filename, "w").write(new_html) subject = ( "New GWR advance ticket booking date: " + f'{new_dates["Weekdays"].strftime("%d %b %Y")} (Weekdays)' ) body = f""" {"\n".join(f'{key}: {when.strftime("%d %b %Y")}' for key, when in new_dates.items())} {agenda.gwr.url} Agenda: https://edwardbetts.com/agenda/ """ if sys.stdin.isatty(): print(filename) print(agenda.gwr.url) print() print("dates have changed") print("old:", existing_dates) print("new:", new_dates) print() print(subject) print(body) agenda.mail.send_mail(config, subject, body) def report_space_launch_change( config: flask.config.Config, prev_launch: StrDict | None, cur_launch: StrDict | None ) -> None: """Send mail to announce change to space launch data.""" # Handle case where launch disappeared from upcoming list if prev_launch and not cur_launch: # Launch is no longer in upcoming list - could be completed, cancelled, or failed # Check if we can determine status from previous data prev_status_id = ( prev_launch.get("status", {}).get("id", 0) if isinstance(prev_launch.get("status"), dict) else 0 ) name = prev_launch["name"] launch_date = prev_launch.get("net", "Unknown") location = ( prev_launch.get("pad", {}).get("location", {}).get("name", "Unknown") if isinstance(prev_launch.get("pad"), dict) else "Unknown" ) # Since launch is no longer in upcoming list, it likely completed # We can't know the exact outcome, so provide helpful message subject = f"πŸš€ Space Launch Completed: {name}" # Format launch date nicely formatted_date = "Unknown" if launch_date and launch_date != "Unknown": try: dt = datetime.fromisoformat(launch_date.replace("Z", "+00:00")) formatted_date = dt.strftime("%d %b %Y at %H:%M UTC") except: formatted_date = launch_date body = f"""πŸš€ Space Launch Completed Mission: {name} Launch Date: {formatted_date} Location: {location} This launch is no longer appearing in the upcoming launches list, which typically means it has taken place. To check if the launch was successful or failed, visit: https://edwardbetts.com/agenda/launches View all launches: https://edwardbetts.com/agenda/launches """ agenda.mail.send_mail(config, subject, body) return # Handle regular status updates if cur_launch: name = cur_launch["name"] status = ( cur_launch.get("status", {}).get("name", "Unknown") if isinstance(cur_launch.get("status"), dict) else "Unknown" ) status_id = ( cur_launch.get("status", {}).get("id", 0) if isinstance(cur_launch.get("status"), dict) else 0 ) launch_date = cur_launch.get("net", "Unknown") location = ( cur_launch.get("pad", {}).get("location", {}).get("name", "Unknown") if isinstance(cur_launch.get("pad"), dict) else "Unknown" ) # Check for specific status changes that deserve special attention prev_status_id = 0 if prev_launch and isinstance(prev_launch.get("status"), dict): prev_status_id = prev_launch.get("status", {}).get("id", 0) # Customize subject based on status changes if status_id == 3: # Launch Successful subject = f"πŸŽ‰ Launch Successful: {name}" elif status_id == 4: # Launch Failure subject = f"πŸ’₯ Launch Failed: {name}" elif status_id == 7: # Partial Failure subject = f"⚠️ Launch Partial Failure: {name}" elif status_id == 6: # In Flight subject = f"πŸš€ Launch In Flight: {name}" elif status_id == 5: # On Hold subject = f"⏸️ Launch On Hold: {name}" else: subject = f"Space Launch Update: {name}" else: # This shouldn't happen with the new logic above, but keep as fallback assert prev_launch name = prev_launch["name"] status = "Unknown" launch_date = prev_launch.get("net", "Unknown") location = ( prev_launch.get("pad", {}).get("location", {}).get("name", "Unknown") if isinstance(prev_launch.get("pad"), dict) else "Unknown" ) subject = f"Space Launch Update: {name}" differences = deepdiff.DeepDiff(prev_launch, cur_launch) changes_text = agenda.thespacedevs.format_launch_changes(differences) # Format launch date nicely formatted_date = "Unknown" if launch_date and launch_date != "Unknown": try: dt = datetime.fromisoformat(launch_date.replace("Z", "+00:00")) formatted_date = dt.strftime("%d %b %Y at %H:%M UTC") except: formatted_date = launch_date body = f"""πŸš€ Space Launch Update Mission: {name} Status: {status} Launch Date: {formatted_date} Location: {location} Changes: {changes_text} View all launches: https://edwardbetts.com/agenda/launches """ agenda.mail.send_mail(config, subject, body) def is_test_flight(launch: StrDict) -> bool: """Return True if the launch is a test flight.""" mission = typing.cast(dict[str, typing.Any] | None, launch.get("mission")) return bool(mission and mission.get("type") == "Test Flight") 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 and send emails on relevant changes. In addition to the configured FOLLOW_LAUNCHES, also send emails for any launch whose mission.type == "Test Flight" even if its slug is not in FOLLOW_LAUNCHES. """ rocket_dir = os.path.join(config["DATA_DIR"], "thespacedevs") existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir) assert existing_data # Always follow configured slugs follow_slugs: set[str] = set(config["FOLLOW_LAUNCHES"]) # Identify test-flight slugs present in the previous cache prev_test_slugs: set[str] = { typing.cast(str, item["slug"]) for item in existing_data.get("results", []) if is_test_flight(typing.cast(StrDict, item)) } t0 = time() data = agenda.thespacedevs.next_launch_api_data(rocket_dir) if not data: return # thespacedevs API call failed # Identify test-flight slugs present in the current data cur_test_slugs: set[str] = { typing.cast(str, item["slug"]) for item in data.get("results", []) if is_test_flight(typing.cast(StrDict, item)) } # Add any test-flight slugs (whether old or new), excluding those we already # explicitly follow. extra_test_slugs = (prev_test_slugs | cur_test_slugs) - follow_slugs # Final set of slugs to evaluate for changes slugs_to_check = follow_slugs | extra_test_slugs # Build prev/cur lookup dicts for all slugs we're checking prev_launches = { slug: get_launch_by_slug(existing_data, slug) for slug in slugs_to_check } cur_launches = {slug: get_launch_by_slug(data, slug) for slug in slugs_to_check} # Emit reports when a launch appears/disappears or changes for slug in slugs_to_check: prev, cur = prev_launches.get(slug), cur_launches.get(slug) if prev is None and cur is None: continue if prev and cur and prev.get("last_updated") == cur.get("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 check_birthday_reminders(config: flask.config.Config) -> None: """Send at most one grouped birthday reminder email per day. Collects birthdays in the next 7 days, groups them into sections (Today/Tomorrow/In N days), and sends a single email. """ today = date.today() data_dir = config["PERSONAL_DATA"] entities_file = os.path.join(data_dir, "entities.yaml") if not os.path.exists(entities_file): return birthdays = agenda.birthday.get_birthdays(today, entities_file) # Collect next 7 days into a dict keyed by days-until by_days: dict[int, list[Event]] = {} for ev in birthdays: days_until = (ev.as_date - today).days if 0 <= days_until <= 7: by_days.setdefault(days_until, []).append(ev) if not by_days: return # Build subject headings: list[str] = [] if 0 in by_days: headings.append("today") if 1 in by_days: headings.append("tomorrow") others = sum(1 for k in by_days.keys() if k not in (0, 1)) if others: plural = "s" if others != 1 else "" headings.append(f"{others} other{plural}") subject = ( f"πŸŽ‚ Birthday reminders ({', '.join(headings)})" if headings else "πŸŽ‚ Birthday reminders" ) # Build body (UK style dates) lines: list[str] = ["Upcoming birthdays (next 7 days):", ""] for delta in sorted(by_days.keys()): if delta == 0: lines.append("Today") elif delta == 1: lines.append("Tomorrow") else: lines.append(f"In {delta} days") entries = sorted( by_days[delta], key=lambda e: (e.as_date, (e.title or e.name or "")), ) for ev in entries: d = ev.as_date # Portable UK-style date: weekday, D Month YYYY date_str = f"{d:%A}, {d.day} {d:%B %Y}" label = ev.title or ev.name lines.append(f" β€’ {label} β€” {date_str}") lines.append("") lines.append("View all birthdays: https://edwardbetts.com/agenda/birthdays") body = "\n".join(lines) if sys.stdin.isatty(): print(f"Birthday reminder: {subject}\n{body}") agenda.mail.send_mail(config, subject, body) 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) # TODO: debug why update gandi fails # update_gandi(app.config) agenda.geomob.update(app.config) agenda.fx.get_rates(app.config) update_thespacedevs(app.config) # Check for birthday reminders daily at 9 AM if hour == 9: check_birthday_reminders(app.config) if __name__ == "__main__": main()