Refactor of caching & data storage part 16 #118

This commit is contained in:
Ian Renton
2026-08-03 22:15:29 +01:00
parent 27d73c27d6
commit 459d999f57
14 changed files with 93 additions and 55 deletions
+10 -9
View File
@@ -29,15 +29,7 @@ class DataProviders:
def start(self): def start(self):
for p in self.spot_providers: # Start data providers before spot/alert providers so the lookup data is there already for incoming spots
if p.enabled:
p.start()
for p in self.alert_providers:
if p.enabled:
p.start()
for p in self.solar_condition_providers:
if p.enabled:
p.start()
for p in self.static_data_providers: for p in self.static_data_providers:
if p.enabled: if p.enabled:
p.start() p.start()
@@ -47,6 +39,15 @@ class DataProviders:
for p in self.callsign_data_providers: for p in self.callsign_data_providers:
if p.enabled: if p.enabled:
p.start() p.start()
for p in self.spot_providers:
if p.enabled:
p.start()
for p in self.alert_providers:
if p.enabled:
p.start()
for p in self.solar_condition_providers:
if p.enabled:
p.start()
def stop(self): def stop(self):
for sp in self.spot_providers: for sp in self.spot_providers:
+2 -1
View File
@@ -84,7 +84,8 @@ class StatusReporter:
DATA_STORE.status_data["callsign_data_providers"] = list( DATA_STORE.status_data["callsign_data_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
"lookup_count": p.lookup_count},
DATA_PROVIDERS.callsign_data_providers)) DATA_PROVIDERS.callsign_data_providers))
DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"], DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"],
"last_api_access": WEB_SERVER.web_server_metrics[ "last_api_access": WEB_SERVER.web_server_metrics[
+6 -1
View File
@@ -2,6 +2,7 @@ import logging
import simplejson import simplejson
from pyhamtools.frequency import freq_to_band from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
@@ -95,6 +96,9 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
itu_zone = data["ituz"] if "ituz" in data else None itu_zone = data["ituz"] if "ituz" in data else None
lat = float(data["latitude"]) if "latitude" in data else None lat = float(data["latitude"]) if "latitude" in data else None
lon = float(data["longitude"]) if "longitude" in data else None lon = float(data["longitude"]) if "longitude" in data else None
grid = None
if lat and lon:
grid = latlong_to_locator(lat, lon)
return Callsign(call=callsign, return Callsign(call=callsign,
home_call=home_call, home_call=home_call,
@@ -105,8 +109,9 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
itu_zone=itu_zone, itu_zone=itu_zone,
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid,
location_source="DXCC") location_source="DXCC")
except ValueError: except (KeyError, ValueError):
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached # Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
return Callsign(call=callsign) return Callsign(call=callsign)
@@ -19,6 +19,7 @@ class CallsignDataProvider:
self.priority = int(provider_config["priority"]) self.priority = int(provider_config["priority"])
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.lookup_count = 0
self._storage = storage self._storage = storage
def start(self): def start(self):
+1
View File
@@ -37,6 +37,7 @@ class ClublogAPI(APIQueryCallsignDataProvider):
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo) callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
self.lookup_count += 1
else: else:
return None return None
+18 -4
View File
@@ -1,10 +1,13 @@
import gzip import gzip
import logging import logging
from datetime import datetime
import pytz
from pyhamtools import LookupLib, Callinfo from pyhamtools import LookupLib, Callinfo
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
@@ -49,7 +52,18 @@ class ClublogXML(FileDownloadCallsignDataProvider):
return False return False
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign, lookup_credentials):
if self._callinfo: callsign_data = Callsign(call=callsign)
return get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
else: try:
return None if self._callinfo:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.lookup_count += 1
else:
return None
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from Clublog XML data", e, exc_info=True)
return callsign_data
+16 -4
View File
@@ -4,6 +4,7 @@ from pyhamtools import LookupLib, Callinfo
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
@@ -30,7 +31,18 @@ class CountryFiles(FileDownloadCallsignDataProvider):
return False return False
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign, lookup_credentials):
if self._callinfo: callsign_data = Callsign(call=callsign)
return get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
else: try:
return None if self._callinfo:
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
self.status = "OK"
self.lookup_count += 1
else:
return None
except Exception as e:
self.status = "Error"
logging.error("Exception when looking up data from Country file", e, exc_info=True)
return callsign_data
+11 -9
View File
@@ -1,7 +1,8 @@
import logging import logging
import urllib.parse import urllib.parse
from datetime import timedelta from datetime import timedelta, datetime
import pytz
import xmltodict import xmltodict
from pyhamtools import callinfo from pyhamtools import callinfo
from requests import ConnectTimeout, ReadTimeout from requests import ConnectTimeout, ReadTimeout
@@ -32,14 +33,14 @@ class HamQTH(APIQueryCallsignDataProvider):
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because # If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
# # someone might provide credentials next time around. # # someone might provide credentials next time around.
if not lookup_credentials or not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password) if not lookup_credentials or not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password)
or lookup_credentials.hamqth_session_key): or lookup_credentials.hamqth_session_id):
return None return None
try: try:
# Obtain session key from credentials, by looking it up from username & password if necessary. # Obtain session key from credentials, by looking it up from username & password if necessary.
session_key = None session_id = None
if lookup_credentials.hamqth_session_key: if lookup_credentials.hamqth_session_id:
session_key = lookup_credentials.hamqth_session_key session_id = lookup_credentials.hamqth_session_id
elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password: elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password:
try: try:
session_data = self._CREDENTIALS_CACHE.get( session_data = self._CREDENTIALS_CACHE.get(
@@ -48,7 +49,7 @@ class HamQTH(APIQueryCallsignDataProvider):
headers=HTTP_HEADERS).content headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data) dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]: if "session_id" in dict_data["HamQTH"]["session"]:
session_key = str(dict_data["HamQTH"]["session"]["session_id"]) session_id = str(dict_data["HamQTH"]["session"]["session_id"])
else: else:
# Log this failure at debug level only, not our problem if user entered the wrong password. # Log this failure at debug level only, not our problem if user entered the wrong password.
logging.debug("HamQTH login details incorrect, failed to look up with HamQTH.") logging.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
@@ -57,7 +58,7 @@ class HamQTH(APIQueryCallsignDataProvider):
logging.error("Exception when getting HamQTH session key") logging.error("Exception when getting HamQTH session key")
return None return None
if not session_key: if not session_id:
return None return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.) # Try the call as given, then fall back to the base call (strips /P, /M etc.)
@@ -73,13 +74,14 @@ class HamQTH(APIQueryCallsignDataProvider):
for lookup_call in calls_to_try: for lookup_call in calls_to_try:
try: try:
response = self._URL_DATA_CACHE.get( response = self._URL_DATA_CACHE.get(
self._HAMQTH_BASE_URL + "?id=" + session_key + "&callsign=" + urllib.parse.quote_plus( self._HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10) lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10)
if response.ok: if response.ok:
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
data = xmltodict.parse(response.content)["HamQTH"]["search"] self.lookup_count += 1
# Found data, convert it to our object and return it # Found data, convert it to our object and return it
data = xmltodict.parse(response.content)["HamQTH"]["search"]
return self.hamqth_response_to_callsign(callsign, data) return self.hamqth_response_to_callsign(callsign, data)
elif not response.from_cache: elif not response.from_cache:
+3 -1
View File
@@ -1,7 +1,8 @@
import logging import logging
import urllib.parse import urllib.parse
from datetime import timedelta from datetime import timedelta, datetime
import pytz
import xmltodict import xmltodict
from pyhamtools import callinfo from pyhamtools import callinfo
from requests import ConnectTimeout, ReadTimeout from requests import ConnectTimeout, ReadTimeout
@@ -80,6 +81,7 @@ class QRZ(APIQueryCallsignDataProvider):
if "Callsign" in qrz_response: if "Callsign" in qrz_response:
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
self.lookup_count += 1
# Found data, convert it to our object and return it # Found data, convert it to our object and return it
return self.qrz_response_to_callsign(callsign, qrz_response.get("Callsign")) return self.qrz_response_to_callsign(callsign, qrz_response.get("Callsign"))
+4 -22
View File
@@ -8,15 +8,15 @@ import tornado
from tornado import httputil from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.call_lookup_helper import get_call_info
from core.constants import SIGS from core.constants import SIGS
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.sig_lookup_helper import populate_missing_sig_ref_info from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot
class APILookupCallHandler(tornado.web.RequestHandler): class APILookupCallHandler(tornado.web.RequestHandler):
@@ -45,27 +45,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if "call" in query_params.keys(): if "call" in query_params.keys():
call = str(query_params.get("call")).upper() call = str(query_params.get("call")).upper()
if re.match(r"^[A-Z0-9/\-]*$", call): if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response.
credentials = extract_credentials(query_params) credentials = extract_credentials(query_params)
fake_spot = Spot(dx_call=call) callsign_data = get_call_info(call, credentials)
fake_spot.infer_missing(credentials) self.write(safe_json_dumps(callsign_data))
data = {
"call": call,
"name": fake_spot.dx_name,
"qth": fake_spot.dx_qth,
"country": fake_spot.dx_country,
"flag": fake_spot.dx_flag,
"continent": fake_spot.dx_continent,
"dxcc_id": fake_spot.dx_dxcc_id,
"cq_zone": fake_spot.dx_cq_zone,
"itu_zone": fake_spot.dx_itu_zone,
"grid": fake_spot.dx_grid,
"latitude": fake_spot.dx_latitude,
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
}
self.write(safe_json_dumps(data))
else: else:
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign.")) self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
+5 -2
View File
@@ -11,6 +11,9 @@ class SSEBroadcaster:
def __init__(self): def __init__(self):
self._handlers = set() self._handlers = set()
self._lock = threading.Lock() self._lock = threading.Lock()
self._loop = None
def bind_to_web_server_loop(self):
self._loop = IOLoop.current() self._loop = IOLoop.current()
def register(self, handler): def register(self, handler):
@@ -22,9 +25,9 @@ class SSEBroadcaster:
self._handlers.discard(handler) self._handlers.discard(handler)
def publish(self, value): def publish(self, value):
self._loop.add_callback(self._fan_out, value) self._loop.add_callback(self._broadcast, value)
def _fan_out(self, value): def _broadcast(self, value):
with self._lock: with self._lock:
handlers = list(self._handlers) handlers = list(self._handlers)
for handler in handlers: for handler in handlers:
+5
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import os import os
import threading
import tornado import tornado
from tornado.web import StaticFileHandler from tornado.web import StaticFileHandler
@@ -61,6 +62,10 @@ class WebServer:
async def _start_inner(self): async def _start_inner(self):
"""Start method (async). Sets up the Tornado application.""" """Start method (async). Sets up the Tornado application."""
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing # Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
# to avoid copy-pasting the same thing to every route declaration below. # to avoid copy-pasting the same thing to every route declaration below.
handler_opts = {"web_server_metrics": self.web_server_metrics} handler_opts = {"web_server_metrics": self.web_server_metrics}
+10 -2
View File
@@ -325,7 +325,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/CallLookup' $ref: '#/components/schemas/CallsignData'
'422': '422':
description: Validation error e.g. callsign missing or format incorrect description: Validation error e.g. callsign missing or format incorrect
content: content:
@@ -1791,6 +1791,10 @@ components:
The last time at which this provider received data, UTC seconds since UNIX epoch. If this The last time at which this provider received data, UTC seconds since UNIX epoch. If this
is zero, the provider has never updated. is zero, the provider has never updated.
example: 1759579508 example: 1759579508
lookup_count:
type: number
description: The number of callsign lookups performed using this provider since the server was started.
example: 1234
SpotList: SpotList:
type: array type: array
@@ -1962,12 +1966,16 @@ components:
on this server. on this server.
example: true example: true
CallLookup: CallsignData:
type: object type: object
properties: properties:
call: call:
type: string type: string
description: Callsign, as provided to the API description: Callsign, as provided to the API
example: DL/M0TRT/P
home_call:
type: string
description: The "home" call, without prefixes or suffixes
example: M0TRT example: M0TRT
name: name:
type: string type: string
+1
View File
@@ -65,6 +65,7 @@ function loadStatus() {
<div class="col"><strong>${p["name"]}</strong></div> <div class="col"><strong>${p["name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div> <div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div> <div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
<div class="col">Lookups: ${(p["enabled"] && p["lookup_count"] > 0) ? p["lookup_count"] : "N/A"}</div>
</div>`); </div>`);
}); });
}); });