Refactor of caching & data storage part 3 #118

This commit is contained in:
Ian Renton
2026-07-31 17:45:09 +01:00
parent 818fd2d504
commit 0f59af6f9e
24 changed files with 592 additions and 268 deletions
+34
View File
@@ -0,0 +1,34 @@
import csv
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
class DME(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Diploma Municipios de Espana"""
SIG = "DME"
PATH = "datafiles/MUNICIPIOS.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH)
def _file_to_data(self, path):
new_data = {}
with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"):
ref_id = row["COD_INE"][:5]
latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LATITUD_ETRS89_REGCAN95") else None
longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LONGITUD_ETRS89_REGCAN95") else None
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id,
name=row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"],
latitude=latitude,
longitude=longitude)
if latitude and longitude:
new_data[ref_id].grid = latlong_to_locator(latitude, longitude, 6)
return new_data
@@ -0,0 +1,75 @@
import logging
from datetime import datetime
from threading import Thread, Event
import pytz
import requests
from requests import ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout
from core.constants import HTTP_HEADERS
from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
def __init__(self, sig_name, provider_config, url, poll_interval):
super().__init__(sig_name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logging.info(
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
def _run(self):
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
break
def _poll(self):
try:
# Request data from API
logging.debug("Downloading " + self.sig_name + " SIG ref data...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing
new_data = self._http_response_to_data(http_response)
# Submit the new spots for processing. There might not be any spots for the less popular programs.
if new_data:
self._replace_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received SIG ref data for " + self.sig_name)
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
except ConnectionError:
logging.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP SIG Ref Data Provider (" + self.sig_name + ")")
self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response):
"""Convert an HTTP response returned by the server into SIG Ref data. The whole response is provided here so the
subclass implementations can check for HTTP status codes if necessary, and handle the response as JSON, CSV,
whatever the remote file actually is."""
raise NotImplementedError("Subclasses must implement this method")
+32
View File
@@ -0,0 +1,32 @@
from pyhamtools.locator import locator_to_latlong
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class LLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Lagos y Lagunas on the Air"""
POLL_INTERVAL_SEC = 7 * 24 * 60 * 60 # 7 days
SIG = "LLOTA"
DATA_URL = "https://llota.app/api/public/references"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_data(self, http_response):
new_data = {}
data = http_response.json()
if isinstance(data, list):
for ref in data:
ref_id = ref["reference_code"]
grid = str(ref["grid_locator"])
ll = locator_to_latlong(grid)
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=str(ref["name"]),
url="https://llota.app/list/ref/" + ref_id,
grid=grid,
latitude=ll[0],
longitude=ll[1])
return new_data
@@ -0,0 +1,36 @@
import logging
from datetime import datetime
import pytz
from sigrefdataproviders.sig_ref_data_provider import SIGRefDataProvider
class LocalFileSIGRefDataProvider(SIGRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, sig, provider_config, path):
super().__init__(sig, provider_config)
self._path = path
def start(self):
logging.info("Loading " + self.sig_name + " SIG ref data from file.")
try:
new_data = self._file_to_data(self._path)
if new_data:
self._replace_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
else:
logging.info("No new SIG ref data found for " + self.sig_name)
except Exception as e:
self.status = "Error"
logging.exception("Exception in local file SIG Ref Data Provider (" + self.sig_name + ")")
def stop(self):
pass
def _file_to_data(self, path):
"""Load a file on the given path and turn it into SIG Ref data."""
raise NotImplementedError("Subclasses must implement this method")
+14 -2
View File
@@ -2,19 +2,25 @@ from datetime import datetime
import pytz
from core.data_store import DATA_STORE
class SIGRefDataProvider:
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
def __init__(self, name, provider_config):
def __init__(self, sig_name, provider_config):
"""Constructor"""
self.name = name
self.sig_name = sig_name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
# Create an empty dict to store data if one doesn't already exist
if not sig_name in DATA_STORE.sigrefs:
DATA_STORE.sigrefs[sig_name] = {}
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -26,3 +32,9 @@ class SIGRefDataProvider:
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
def _replace_data(self, new_data):
"""Replace all data for the named sig with the new data. new_data should be a map of reference ID to SIGRef
objects."""
DATA_STORE.sigrefs[self.sig_name] = new_data
+26
View File
@@ -0,0 +1,26 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class SIOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Silos on the Air"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days
SIG = "SIOTA"
DATA_URL = "https://www.silosontheair.com/data/silos.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode().splitlines()):
ref_id = row["SILO_CODE"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["NAME"] if "NAME" in row else None,
grid=row["LOCATOR"] if "LOCATOR" in row else None,
latitude=float(row["LAT"]) if "LAT" in row else None,
longitude=float(row["LNG"]) if "LNG" in row else None)
return new_data
+24
View File
@@ -0,0 +1,24 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
class TOTA(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Toilets on the Air"""
SIG = "TOTA"
PATH = "datafiles/tota.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH)
def _file_to_data(self, path):
new_data = {}
f = open(path)
csv_data = f.read()
dr = csv.DictReader(csv_data.splitlines())
for row in dr:
new_data[row["ref"]] = SIGRef(sig=self.SIG, id=row["ref"], name=row["ref"], latitude=float(row["lat"]),
longitude=float(row["lon"]))
return new_data
+31
View File
@@ -0,0 +1,31 @@
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class WOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Wainwrights on the Air"""
POLL_INTERVAL_SEC = 365 * 24 * 60 * 60 # 365 days
SIG = "WOTA"
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_data(self, http_response):
new_data = {}
for feature in http_response.json().get("features", []):
ref_id = feature["properties"]["wotaId"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
# added to them
url = "https://www.wota.org.uk/MM_" + ref_id
if ref_id.upper().startswith("LDO-"):
number = int(ref_id.upper().replace("LDO-", ""))
url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=feature["properties"]["title"], url=url,
grid=feature["properties"]["qthLocator"],
latitude=feature["geometry"]["coordinates"][1],
longitude=feature["geometry"]["coordinates"][0])
return new_data
+30
View File
@@ -0,0 +1,30 @@
import csv
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class WWFF(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Flora & Fauna"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days
SIG = "WWFF"
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_data(self, http_response):
new_data = {}
for row in csv.DictReader(http_response.content.decode().splitlines()):
ref_id = row["reference"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
url="https://wwff.co/directory/?showRef=" + ref_id,
grid=row["iaruLocator"] if "iaruLocator" in row and row[
"iaruLocator"] != "-" else None,
latitude=float(row["latitude"]) if "latitude" in row and row[
"latitude"] != "" and row["latitude"] != "-" else None,
longitude=float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "" and row["longitude"] != "-" else None)
return new_data
+33
View File
@@ -0,0 +1,33 @@
from pyhamtools.locator import latlong_to_locator
from data.sig_ref import SIGRef
from sigrefdataproviders.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class ZLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for New Zealand on the Air"""
POLL_INTERVAL_SEC = 30 * 24 * 60 * 60 # 30 days
SIG = "ZLOTA"
DATA_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_data(self, http_response):
new_data = {}
data = http_response.json()
if isinstance(data, list):
for ref in data:
ref_id = ref["code"]
latitude = ref["y"]
longitude = ref["x"]
new_data[ref_id] = SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
url="https://ontheair.nz/assets/" + ref_id.replace("/", "_"),
latitude=latitude,
longitude=longitude)
if latitude and longitude:
new_data[ref_id].grid = latlong_to_locator(latitude, longitude, 6)
return new_data