Modify the backend so that instead of using the server owner's QRZ & HamQTH credentials, it instead requires them to be provided by the client (if none are provided, the lookups do not occur.)

This commit is contained in:
Ian Renton
2026-05-09 15:43:22 +01:00
parent 0988a567b8
commit f81ef4347f
18 changed files with 385 additions and 174 deletions

View File

@@ -192,15 +192,6 @@ web-server-port: 8080
max-spot-age-sec: 3600 max-spot-age-sec: 3600
max-alert-age-sec: 604800 max-alert-age-sec: 604800
# Login for QRZ.com to look up information. Optional. You will need an "XML Subscriber" (paid) package to retrieve all
# the data for a callsign via their system.
qrz-username: ""
qrz-password: ""
# Login for HamQTH to look up information. Optional.
hamqth-username: ""
hamqth-password: ""
# API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you # API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you
# want to use callsign lookups from Clublog. # want to use callsign lookups from Clublog.
clublog-api-key: "" clublog-api-key: ""

View File

@@ -5,6 +5,7 @@ import re
import urllib.parse import urllib.parse
from datetime import timedelta from datetime import timedelta
import requests
import xmltodict import xmltodict
from diskcache import Cache from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo from pyhamtools import LookupLib, Callinfo, callinfo
@@ -17,6 +18,38 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
from data.lookup_credentials import LookupCredentials
# QRZ XML field names differ from pyhamtools' normalised names; map them here.
_QRZ_FIELD_MAP = {
"lat": "latitude",
"lon": "longitude",
"grid": "locator",
"ituzone": "ituz",
"cqzone": "cqz",
}
_QRZ_INT_FIELDS = {"adif", "cqz", "ituz"}
_QRZ_FLOAT_FIELDS = {"latitude", "longitude"}
def _normalize_qrz_data(raw):
data = {}
for k, v in raw.items():
if v is None:
continue
mapped_key = _QRZ_FIELD_MAP.get(k, k)
if mapped_key in _QRZ_INT_FIELDS:
try:
v = int(v)
except (ValueError, TypeError):
pass
elif mapped_key in _QRZ_FLOAT_FIELDS:
try:
v = float(v)
except (ValueError, TypeError):
pass
data[mapped_key] = v
return data
class LookupHelper: class LookupHelper:
@@ -36,9 +69,10 @@ class LookupHelper:
self._clublog_cty_xml_cache = None self._clublog_cty_xml_cache = None
self._clublog_api_key = None self._clublog_api_key = None
self._qrz_callsign_data_cache = None self._qrz_callsign_data_cache = None
self._lookup_lib_qrz = None self._qrz_base_url = "https://xmldata.qrz.com/xml/current/"
self._qrz_available = None # QRZ session keys expire after an hour; cache the login response for 55 minutes.
self._hamqth_available = None self._qrz_session_cache = CachedSession("cache/qrz_session_cache",
expire_after=timedelta(minutes=55))
self._hamqth_callsign_data_cache = None self._hamqth_callsign_data_cache = None
self._hamqth_base_url = "https://www.hamqth.com/xml.php" self._hamqth_base_url = "https://www.hamqth.com/xml.php"
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat # HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
@@ -67,13 +101,8 @@ class LookupHelper:
self._lookup_lib_basic = LookupLib(lookuptype="countryfile") self._lookup_lib_basic = LookupLib(lookuptype="countryfile")
self._call_info_basic = Callinfo(self._lookup_lib_basic) self._call_info_basic = Callinfo(self._lookup_lib_basic)
self._qrz_available = config["qrz-username"] != "" and config["qrz-password"] != ""
if self._qrz_available:
self._lookup_lib_qrz = LookupLib(lookuptype="qrz", username=config["qrz-username"],
pwd=config["qrz-password"])
self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache') self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache')
self._hamqth_available = config["hamqth-username"] != "" and config["hamqth-password"] != ""
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache') self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
self._clublog_api_key = config["clublog-api-key"] self._clublog_api_key = config["clublog-api-key"]
@@ -166,7 +195,7 @@ class LookupHelper:
logging.error("Exception when downloading Clublog cty.xml", e) logging.error("Exception when downloading Clublog cty.xml", e)
return False return False
def infer_country_from_callsign(self, call): def infer_country_from_callsign(self, call, credentials=None):
"""Infer a country name from a callsign""" """Infer a country name from a callsign"""
try: try:
@@ -176,12 +205,12 @@ class LookupHelper:
country = None country = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not country: if not country:
qrz_data = self._get_qrz_data_for_callsign(call) qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "country" in qrz_data: if qrz_data and "country" in qrz_data:
country = qrz_data["country"] country = qrz_data["country"]
# Couldn't get anything from QRZ.com database, try HamQTH # Couldn't get anything from QRZ.com database, try HamQTH
if not country: if not country:
hamqth_data = self._get_hamqth_data_for_callsign(call) hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "country" in hamqth_data: if hamqth_data and "country" in hamqth_data:
country = hamqth_data["country"] country = hamqth_data["country"]
# Couldn't get anything from HamQTH database, try Clublog data # Couldn't get anything from HamQTH database, try Clublog data
@@ -200,7 +229,7 @@ class LookupHelper:
country = dxcc_data["name"] country = dxcc_data["name"]
return country return country
def infer_dxcc_id_from_callsign(self, call): def infer_dxcc_id_from_callsign(self, call, credentials=None):
"""Infer a DXCC ID from a callsign""" """Infer a DXCC ID from a callsign"""
try: try:
@@ -210,12 +239,12 @@ class LookupHelper:
dxcc = None dxcc = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not dxcc: if not dxcc:
qrz_data = self._get_qrz_data_for_callsign(call) qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "adif" in qrz_data: if qrz_data and "adif" in qrz_data:
dxcc = qrz_data["adif"] dxcc = qrz_data["adif"]
# Couldn't get anything from QRZ.com database, try HamQTH # Couldn't get anything from QRZ.com database, try HamQTH
if not dxcc: if not dxcc:
hamqth_data = self._get_hamqth_data_for_callsign(call) hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "adif" in hamqth_data: if hamqth_data and "adif" in hamqth_data:
dxcc = hamqth_data["adif"] dxcc = hamqth_data["adif"]
# Couldn't get anything from HamQTH database, try Clublog data # Couldn't get anything from HamQTH database, try Clublog data
@@ -234,7 +263,7 @@ class LookupHelper:
dxcc = dxcc_data["entityCode"] dxcc = dxcc_data["entityCode"]
return dxcc return dxcc
def infer_continent_from_callsign(self, call): def infer_continent_from_callsign(self, call, credentials=None):
"""Infer a continent shortcode from a callsign""" """Infer a continent shortcode from a callsign"""
try: try:
@@ -244,7 +273,7 @@ class LookupHelper:
continent = None continent = None
# Couldn't get anything from basic call info database, try HamQTH # Couldn't get anything from basic call info database, try HamQTH
if not continent: if not continent:
hamqth_data = self._get_hamqth_data_for_callsign(call) hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "continent" in hamqth_data: if hamqth_data and "continent" in hamqth_data:
continent = hamqth_data["continent"] continent = hamqth_data["continent"]
# Couldn't get anything from HamQTH database, try Clublog data # Couldn't get anything from HamQTH database, try Clublog data
@@ -264,7 +293,7 @@ class LookupHelper:
continent = dxcc_data["continent"][0] continent = dxcc_data["continent"][0]
return continent return continent
def infer_cq_zone_from_callsign(self, call): def infer_cq_zone_from_callsign(self, call, credentials=None):
"""Infer a CQ zone from a callsign""" """Infer a CQ zone from a callsign"""
try: try:
@@ -274,12 +303,12 @@ class LookupHelper:
cqz = None cqz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not cqz: if not cqz:
qrz_data = self._get_qrz_data_for_callsign(call) qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "cqz" in qrz_data: if qrz_data and "cqz" in qrz_data:
cqz = qrz_data["cqz"] cqz = qrz_data["cqz"]
# Couldn't get anything from QRZ.com database, try HamQTH # Couldn't get anything from QRZ.com database, try HamQTH
if not cqz: if not cqz:
hamqth_data = self._get_hamqth_data_for_callsign(call) hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "cq" in hamqth_data: if hamqth_data and "cq" in hamqth_data:
cqz = hamqth_data["cq"] cqz = hamqth_data["cq"]
# Couldn't get anything from HamQTH database, try Clublog data # Couldn't get anything from HamQTH database, try Clublog data
@@ -299,7 +328,7 @@ class LookupHelper:
cqz = dxcc_data["cq"][0] cqz = dxcc_data["cq"][0]
return cqz return cqz
def infer_itu_zone_from_callsign(self, call): def infer_itu_zone_from_callsign(self, call, credentials=None):
"""Infer a ITU zone from a callsign""" """Infer a ITU zone from a callsign"""
try: try:
@@ -309,12 +338,12 @@ class LookupHelper:
ituz = None ituz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not ituz: if not ituz:
qrz_data = self._get_qrz_data_for_callsign(call) qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "ituz" in qrz_data: if qrz_data and "ituz" in qrz_data:
ituz = qrz_data["ituz"] ituz = qrz_data["ituz"]
# Couldn't get anything from QRZ.com database, try HamQTH # Couldn't get anything from QRZ.com database, try HamQTH
if not ituz: if not ituz:
hamqth_data = self._get_hamqth_data_for_callsign(call) hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "itu" in hamqth_data: if hamqth_data and "itu" in hamqth_data:
ituz = hamqth_data["itu"] ituz = hamqth_data["itu"]
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data # Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
@@ -330,31 +359,31 @@ class LookupHelper:
return self._dxcc_data[dxcc]["flag"] if dxcc in self._dxcc_data else None return self._dxcc_data[dxcc]["flag"] if dxcc in self._dxcc_data else None
def infer_name_from_callsign_online_lookup(self, call): def infer_name_from_callsign_online_lookup(self, call, credentials=None):
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)""" """Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call) data = self._get_qrz_data_for_callsign(call, credentials)
if data and "fname" in data: if data and "fname" in data:
name = data["fname"] name = data["fname"]
if "name" in data: if "name" in data:
name = name + " " + data["name"] name = name + " " + data["name"]
return name return name
data = self._get_hamqth_data_for_callsign(call) data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "nick" in data: if data and "nick" in data:
return data["nick"] return data["nick"]
else: else:
return None return None
def infer_latlon_from_callsign_online_lookup(self, call): def infer_latlon_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH) """Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)""" Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)"""
data = self._get_qrz_data_for_callsign(call) data = self._get_qrz_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and ( if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float( float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9: data["latitude"]) < 89.9:
return [float(data["latitude"]), float(data["longitude"])] return [float(data["latitude"]), float(data["longitude"])]
data = self._get_hamqth_data_for_callsign(call) data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and ( if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float( float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9: data["latitude"]) < 89.9:
@@ -362,28 +391,28 @@ class LookupHelper:
else: else:
return None return None
def infer_grid_from_callsign_online_lookup(self, call): def infer_grid_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a grid locator from a callsign (requires QRZ.com/HamQTH). """Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)""" Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)"""
data = self._get_qrz_data_for_callsign(call) data = self._get_qrz_data_for_callsign(call, credentials)
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \ if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
data["locator"].upper() != "AA00AA00": data["locator"].upper() != "AA00AA00":
return data["locator"] return data["locator"]
data = self._get_hamqth_data_for_callsign(call) data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[ if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
"grid"].upper() != "AA00AA00": "grid"].upper() != "AA00AA00":
return data["grid"] return data["grid"]
else: else:
return None return None
def infer_qth_from_callsign_online_lookup(self, call): def infer_qth_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)""" """Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call) data = self._get_qrz_data_for_callsign(call, credentials)
if data and "addr2" in data: if data and "addr2" in data:
return data["addr2"] return data["addr2"]
data = self._get_hamqth_data_for_callsign(call) data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "qth" in data: if data and "qth" in data:
return data["qth"] return data["qth"]
else: else:
@@ -422,79 +451,116 @@ class LookupHelper:
logging.debug("Invalid lat/lon received for DXCC") logging.debug("Invalid lat/lon received for DXCC")
return grid return grid
def _get_qrz_data_for_callsign(self, call): def _get_qrz_data_for_callsign(self, call, credentials):
"""Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it""" """Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Fetch from cache if we can, otherwise fetch from the API and cache it # Return from cache if available (a cached None means 'not found in QRZ')
if call in self._qrz_callsign_data_cache: if call in self._qrz_callsign_data_cache:
return self._qrz_callsign_data_cache.get(call) return self._qrz_callsign_data_cache.get(call)
elif self._qrz_available:
# Obtain session key from credentials
session_key = None
if credentials and credentials.qrz_session_key:
session_key = credentials.qrz_session_key
elif credentials and credentials.qrz_username and credentials.qrz_password:
try: try:
data = self._lookup_lib_qrz.lookup_callsign(callsign=call) login_response = self._qrz_session_cache.get(
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) +
return data "&password=" + urllib.parse.quote_plus(credentials.qrz_password) + "&agent=spothole",
except (KeyError, ValueError): headers=HTTP_HEADERS).content
# QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call. login_data = xmltodict.parse(login_response)
try: session = login_data.get("QRZDatabase", {}).get("Session", {})
data = self._lookup_lib_qrz.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call)) if "Key" in session:
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds session_key = session["Key"]
return data else:
except (KeyError, ValueError): logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None return None
except Exception: except Exception:
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache logging.error("Exception when getting QRZ.com session key")
# that, so we can try again next time.
logging.error("Exception when looking up QRZ data")
return None return None
else:
if not session_key:
return None return None
def _get_hamqth_data_for_callsign(self, call): # Try the call as given, then fall back to the base call (strips /P, /M etc.)
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it""" calls_to_try = [call]
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
# Fetch from cache if we can, otherwise fetch from the API and cache it for lookup_call in calls_to_try:
try:
lookup_response = requests.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10).content
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign")
if raw:
data = _normalize_qrz_data(raw)
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except Exception:
logging.error("Exception when looking up QRZ data")
return None
# Not found in QRZ; cache None so we don't keep retrying
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
def _get_hamqth_data_for_callsign(self, call, credentials):
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Return from cache if available
if call in self._hamqth_callsign_data_cache: if call in self._hamqth_callsign_data_cache:
return self._hamqth_callsign_data_cache.get(call) return self._hamqth_callsign_data_cache.get(call)
elif self._hamqth_available:
# Obtain session ID from credentials
session_id = None
if credentials and credentials.hamqth_session_id:
session_id = credentials.hamqth_session_id
elif credentials and credentials.hamqth_username and credentials.hamqth_password:
try: try:
# First we need to log in and get a session token.
session_data = self._hamqth_session_lookup_cache.get( session_data = self._hamqth_session_lookup_cache.get(
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(config["hamqth-username"]) + self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) +
"&p=" + urllib.parse.quote_plus(config["hamqth-password"]), headers=HTTP_HEADERS).content "&p=" + urllib.parse.quote_plus(credentials.hamqth_password), 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_id = dict_data["HamQTH"]["session"]["session_id"] session_id = dict_data["HamQTH"]["session"]["session_id"]
# Now look up the actual data.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
callinfo.Callinfo.get_homecall(call)) + "&prg=" + HAMQTH_PRG,
headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, that's OK. Cache a None so we don't try to look this up again
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
else: else:
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.") logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
except: return None
except Exception:
logging.error("Exception when getting HamQTH session ID")
return None
if not session_id:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
for lookup_call in calls_to_try:
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except Exception:
logging.error("Exception when looking up HamQTH data") logging.error("Exception when looking up HamQTH data")
return None return None
# Not found in HamQTH; cache None so we don't keep retrying
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None return None
def _get_clublog_api_data_for_callsign(self, call): def _get_clublog_api_data_for_callsign(self, call):
@@ -551,6 +617,7 @@ class LookupHelper:
"""Shutdown method to close down any caches neatly.""" """Shutdown method to close down any caches neatly."""
self._qrz_callsign_data_cache.close() self._qrz_callsign_data_cache.close()
self._hamqth_callsign_data_cache.close()
self._clublog_callsign_data_cache.close() self._clublog_callsign_data_cache.close()

View File

@@ -61,7 +61,7 @@ class Alert:
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str = None source_id: str = None
def infer_missing(self): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
# If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but # If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but
@@ -84,15 +84,15 @@ class Alert:
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't # DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
# have a real location for alerts. # have a real location for alerts.
if self.dx_calls and self.dx_calls[0] and not self.dx_country: if self.dx_calls and self.dx_calls[0] and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0]) self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_continent: if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0]) self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone: if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0]) self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone: if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0]) self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id: if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0]) self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0], credentials)
if self.dx_dxcc_id and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id) self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
@@ -108,21 +108,25 @@ class Alert:
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig: if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig self.sig = self.sig_refs[0].sig
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
# the actual alertting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
# the one from the park reference they're at.
if self.dx_calls and not self.dx_names:
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c), self.dx_calls))
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
# apart from that they were retrieved from the API at different times. Note that the simple Python hash() # apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we # function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element. # use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
self_copy = copy.deepcopy(self) # The ID is computed before the online lookups below so that it is stable regardless of whether credentials
self_copy.received_time = 0 # are provided, allowing the enriched API response to be matched to the stored alert by ID.
self_copy.received_time_iso = "" if not self.id:
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest() self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
# from the actual alerting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
# instead of the one from the park reference they're at.
if self.dx_calls and not self.dx_names:
self.dx_names = list(
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""

View File

@@ -0,0 +1,27 @@
from dataclasses import dataclass
@dataclass
class LookupCredentials:
"""Per-request credentials for QRZ.com and HamQTH online callsign lookups."""
qrz_username: str = ""
qrz_password: str = ""
qrz_session_key: str = "" # alternative to username/password
hamqth_username: str = ""
hamqth_password: str = ""
hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(query_params):
"""Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present."""
creds = LookupCredentials(
qrz_username=query_params.get("qrz_username", ""),
qrz_password=query_params.get("qrz_password", ""),
qrz_session_key=query_params.get("qrz_session_key", ""),
hamqth_username=query_params.get("hamqth_username", ""),
hamqth_password=query_params.get("hamqth_password", ""),
hamqth_session_id=query_params.get("hamqth_session_id", ""),
)
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)
return creds if (has_qrz or has_hamqth) else None

View File

@@ -12,8 +12,9 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import MODE_ALIASES from core.constants import MODE_ALIASES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, infer_mode_from_frequency, \ from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_type_from_mode infer_mode_from_frequency, infer_mode_type_from_mode
from data.lookup_credentials import LookupCredentials
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -131,7 +132,7 @@ class Spot:
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str = None source_id: str = None
def infer_missing(self): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but # If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
@@ -158,11 +159,11 @@ class Spot:
# DX country, continent etc. from callsign # DX country, continent etc. from callsign
if self.dx_call and not self.dx_country: if self.dx_call and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call) self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call, credentials)
if self.dx_call and not self.dx_continent: if self.dx_call and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call) self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call, credentials)
if self.dx_call and not self.dx_dxcc_id: if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call) self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call, credentials)
if self.dx_dxcc_id and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id) self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
@@ -192,11 +193,11 @@ class Spot:
if self.de_call and any(char.isdigit() for char in self.de_call) and not ( if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"): self.de_call.startswith("T2") and self.source == "APRS-IS"):
if not self.de_country: if not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call) self.de_country = lookup_helper.infer_country_from_callsign(self.de_call, credentials)
if not self.de_continent: if not self.de_continent:
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call) self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call, credentials)
if not self.de_dxcc_id: if not self.de_dxcc_id:
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call) self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call, credentials)
if self.de_dxcc_id and not self.de_flag: if self.de_dxcc_id and not self.de_flag:
self.de_flag = lookup_helper.get_flag_for_dxcc(self.de_dxcc_id) self.de_flag = lookup_helper.get_flag_for_dxcc(self.de_dxcc_id)
@@ -306,27 +307,40 @@ class Spot:
if self.comment and not self.qrt: if self.comment and not self.qrt:
self.qrt = "QRT" in self.comment.upper() self.qrt = "QRT" in self.comment.upper()
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
# the one from the park reference they're at. # apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
# The ID is computed before the online lookups below so that it is stable regardless of whether credentials
# are provided, allowing the enriched API response to be matched to the stored spot by ID.
if not self.id:
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
# from the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
# instead of the one from the park reference they're at.
if self.dx_call and not self.dx_name: if self.dx_call and not self.dx_name:
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call) self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call, credentials)
if self.dx_call and not self.dx_latitude: if self.dx_call and not self.dx_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call) latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call, credentials)
if latlon: if latlon:
self.dx_latitude = latlon[0] self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1] self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call) self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call, credentials)
self.dx_location_source = "HOME QTH" self.dx_location_source = "HOME QTH"
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable stirng, # Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service. # otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0: if self.sig_refs and len(self.sig_refs) > 0:
self.dx_qth = self.sig_refs[0].id self.dx_qth = self.sig_refs[0].id
if self.sig_refs[0].name: if self.sig_refs[0].name:
self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name
else: else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call) self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
# Last resort for getting a DX position, use the DXCC entity. # Last resort for getting a DX position, use the DXCC entity.
if self.dx_call and not self.dx_latitude: if self.dx_call and not self.dx_latitude:
@@ -352,12 +366,12 @@ class Spot:
if self.dx_latitude: if self.dx_latitude:
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude) self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call: elif self.dx_call:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call) self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call, credentials)
if not self.dx_itu_zone: if not self.dx_itu_zone:
if self.dx_latitude: if self.dx_latitude:
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude) self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call: elif self.dx_call:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call) self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call, credentials)
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home. # is likely at home.
@@ -369,13 +383,13 @@ class Spot:
# DE with no digits and APRS servers starting "T2" are not things we can look up location for # DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call and any(char.isdigit() for char in self.de_call) and not ( if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"): self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com. # DE operator position lookup, using QRZ.com/HamQTH.
if not self.de_latitude: if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call, credentials)
if latlon: if latlon:
self.de_latitude = latlon[0] self.de_latitude = latlon[0]
self.de_longitude = latlon[1] self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call) self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call, credentials)
# Last resort for getting a DE position, use the DXCC entity. # Last resort for getting a DE position, use the DXCC entity.
if not self.de_latitude: if not self.de_latitude:
@@ -385,16 +399,6 @@ class Spot:
self.de_longitude = latlon[1] self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call) self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""

View File

@@ -1,3 +1,4 @@
import copy
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
@@ -9,6 +10,8 @@ import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100 SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -21,6 +24,15 @@ class APIAlertsHandler(tornado.web.RequestHandler):
self._alerts = alerts self._alerts = alerts
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(alerts, credentials):
enriched = []
for alert in alerts:
alert_copy = copy.deepcopy(alert)
alert_copy.infer_missing(credentials)
enriched.append(alert_copy)
return enriched
def get(self): def get(self):
try: try:
# Metrics # Metrics
@@ -33,8 +45,11 @@ class APIAlertsHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query # Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
data = get_alert_list_with_filters(self._alerts, query_params) data = get_alert_list_with_filters(self._alerts, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(json.dumps(data, default=serialize_everything))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
@@ -73,6 +88,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive # Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
@@ -110,6 +126,9 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
alert = self._alert_queue.get() alert = self._alert_queue.get()
# If the new alert matches our param filters, send it to the client. If not, ignore it. # If the new alert matches our param filters, send it to the client. If not, ignore it.
if alert_allowed_by_query(alert, self._query_params): if alert_allowed_by_query(alert, self._query_params):
if self._credentials:
alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials)
self.write_message(msg=json.dumps(alert, default=serialize_everything)) self.write_message(msg=json.dumps(alert, default=serialize_everything))
if self._alert_queue not in self._sse_alert_queues: if self._alert_queue not in self._sse_alert_queues:

View File

@@ -11,6 +11,7 @@ from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_z
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, populate_sig_ref_info from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything from core.utils import serialize_everything
from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -39,8 +40,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
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 # 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. # resulting data in the correct way for the API response.
credentials = extract_credentials(query_params)
fake_spot = Spot(dx_call=call) fake_spot = Spot(dx_call=call)
fake_spot.infer_missing() fake_spot.infer_missing(credentials)
data = { data = {
"call": call, "call": call,
"name": fake_spot.dx_name, "name": fake_spot.dx_name,

View File

@@ -1,3 +1,4 @@
import copy
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -9,6 +10,8 @@ import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000 SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -21,6 +24,15 @@ class APISpotsHandler(tornado.web.RequestHandler):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(spots, credentials):
enriched = []
for spot in spots:
spot_copy = copy.deepcopy(spot)
spot_copy.infer_missing(credentials)
enriched.append(spot_copy)
return enriched
def get(self): def get(self):
try: try:
# Metrics # Metrics
@@ -33,8 +45,11 @@ class APISpotsHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query # Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
data = get_spot_list_with_filters(self._spots, query_params) data = get_spot_list_with_filters(self._spots, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(json.dumps(data, default=serialize_everything))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
@@ -75,6 +90,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive # Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
@@ -112,6 +128,9 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
spot = self._spot_queue.get() spot = self._spot_queue.get()
# If the new spot matches our param filters, send it to the client. If not, ignore it. # If the new spot matches our param filters, send it to the client. If not, ignore it.
if spot_allowed_by_query(spot, self._query_params): if spot_allowed_by_query(spot, self._query_params):
if self._credentials:
spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials)
self.write_message(msg=json.dumps(spot, default=serialize_everything)) self.write_message(msg=json.dumps(spot, default=serialize_everything))
if self._spot_queue not in self._sse_spot_queues: if self._spot_queue not in self._sse_spot_queues:

View File

@@ -67,7 +67,7 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p> <p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div> </div>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -69,8 +69,8 @@
</div> </div>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script src="/js/add-spot.js?v=1777825937"></script> <script src="/js/add-spot.js?v=1778337803"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -56,8 +56,8 @@
</div> </div>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script src="/js/alerts.js?v=1777825937"></script> <script src="/js/alerts.js?v=1778337803"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -62,9 +62,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script src="/js/spotsbandsandmap.js?v=1777825937"></script> <script src="/js/spotsbandsandmap.js?v=1778337803"></script>
<script src="/js/bands.js?v=1777825937"></script> <script src="/js/bands.js?v=1778337803"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -24,7 +24,7 @@
<title>Spothole</title> <title>Spothole</title>
<link rel="stylesheet" href="/css/style.css?v=1777825937" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1778337803" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" /> <link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
@@ -46,9 +46,9 @@
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1777825937"></script> <script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778337803"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1777825937"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778337803"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1777825937"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778337803"></script>
</head> </head>
<body> <body>

View File

@@ -230,8 +230,8 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script src="/js/conditions.js?v=1777825937"></script> <script src="/js/conditions.js?v=1778337803"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>

View File

@@ -79,9 +79,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1777825936"></script> <script src="/js/common.js?v=1778337802"></script>
<script src="/js/spotsbandsandmap.js?v=1777825936"></script> <script src="/js/spotsbandsandmap.js?v=1778337802"></script>
<script src="/js/map.js?v=1777825936"></script> <script src="/js/map.js?v=1778337802"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -90,9 +90,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1777825936"></script> <script src="/js/common.js?v=1778337802"></script>
<script src="/js/spotsbandsandmap.js?v=1777825936"></script> <script src="/js/spotsbandsandmap.js?v=1778337802"></script>
<script src="/js/spots.js?v=1777825936"></script> <script src="/js/spots.js?v=1778337802"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -59,8 +59,8 @@
</div> </div>
</div> </div>
<script src="/js/common.js?v=1777825937"></script> <script src="/js/common.js?v=1778337803"></script>
<script src="/js/status.js?v=1777825937"></script> <script src="/js/status.js?v=1778337803"></script>
<script> <script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --> $(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script> </script>

View File

@@ -13,6 +13,10 @@ info:
## Changelog ## Changelog
### 1.3
* `/spots`, `/spots/stream`, `/alerts`, `/alerts/stream`, and `/lookup/call` now accept optional QRZ.com and HamQTH credentials as query parameters. When supplied, returned data is enriched with operator name, home location etc. from those services.
### 1.2 ### 1.2
* Added `/dxstats` endpoint for inter-continent DX spot statistics. * Added `/dxstats` endpoint for inter-continent DX spot statistics.
@@ -29,7 +33,7 @@ info:
license: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: v1.2 version: v1.3
servers: servers:
- url: https://spothole.app/api/v1 - url: https://spothole.app/api/v1
paths: paths:
@@ -38,7 +42,7 @@ paths:
tags: tags:
- Spots - Spots
summary: Get spots summary: Get spots
description: The main API call that retrieves spots from the system. Supply this with no query parameters to retrieve all spots known to the system. Supply query parameters to filter what is retrieved. description: The main API call that retrieves spots from the system. Supply this with no query parameters to retrieve all spots known to the system. Supply query parameters to filter what is retrieved. If QRZ.com or HamQTH credentials are supplied, returned spots will be enriched with operator name, home location etc. from those services.
operationId: spots operationId: spots
parameters: parameters:
- name: limit - name: limit
@@ -160,6 +164,12 @@ paths:
schema: schema:
type: boolean type: boolean
default: true default: true
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses: responses:
'200': '200':
description: Success description: Success
@@ -175,7 +185,7 @@ paths:
tags: tags:
- Spots - Spots
summary: Get spot stream summary: Get spot stream
description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned. description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned. If QRZ.com or HamQTH credentials are supplied, streamed spots will be enriched with operator name, home location etc. from those services.
operationId: spots-stream operationId: spots-stream
parameters: parameters:
- name: source - name: source
@@ -266,6 +276,12 @@ paths:
schema: schema:
type: boolean type: boolean
default: true default: true
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses: responses:
'200': '200':
description: Success description: Success
@@ -280,7 +296,7 @@ paths:
tags: tags:
- Alerts - Alerts
summary: Get alerts summary: Get alerts
description: Retrieves alerts (indications of upcoming activations) from the system. Supply this with no query parameters to retrieve all alerts known to the system. Supply query parameters to filter what is retrieved. description: Retrieves alerts (indications of upcoming activations) from the system. Supply this with no query parameters to retrieve all alerts known to the system. Supply query parameters to filter what is retrieved. If QRZ.com or HamQTH credentials are supplied, returned alerts will be enriched with operator names from those services.
operationId: alerts operationId: alerts
parameters: parameters:
- name: limit - name: limit
@@ -337,6 +353,12 @@ paths:
required: false required: false
schema: schema:
type: string type: string
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses: responses:
'200': '200':
description: Success description: Success
@@ -353,7 +375,7 @@ paths:
tags: tags:
- Alerts - Alerts
summary: Get alert stream summary: Get alert stream
description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned. description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned. If QRZ.com or HamQTH credentials are supplied, streamed alerts will be enriched with operator names from those services.
operationId: alerts-stream operationId: alerts-stream
parameters: parameters:
- name: max_duration - name: max_duration
@@ -398,6 +420,12 @@ paths:
required: false required: false
schema: schema:
type: string type: string
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses: responses:
'200': '200':
description: Success description: Success
@@ -607,7 +635,7 @@ paths:
tags: tags:
- Utilities - Utilities
summary: Look up callsign details summary: Look up callsign details
description: Perform a lookup of data about a certain callsign, using any of the lookup services available to the Spothole server. description: Perform a lookup of data about a certain callsign, using any of the lookup services available to the Spothole server. If QRZ.com or HamQTH credentials are supplied, the response will be able to use these services to perform a lookup.
operationId: call operationId: call
parameters: parameters:
- name: call - name: call
@@ -616,6 +644,12 @@ paths:
required: true required: true
type: string type: string
example: M0TRT example: M0TRT
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses: responses:
'200': '200':
description: Success description: Success
@@ -838,6 +872,50 @@ paths:
example: "Failed" example: "Failed"
components: components:
parameters:
QrzUsername:
name: qrz_username
in: query
description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `qrz_password`, or supply `qrz_session_key` instead."
required: false
schema:
type: string
QrzPassword:
name: qrz_password
in: query
description: "QRZ.com password. Supply together with `qrz_username`."
required: false
schema:
type: string
QrzSessionKey:
name: qrz_session_key
in: query
description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user."
required: false
schema:
type: string
HamqthUsername:
name: hamqth_username
in: query
description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead."
required: false
schema:
type: string
HamqthPassword:
name: hamqth_password
in: query
description: "HamQTH password. Supply together with `hamqth_username`."
required: false
schema:
type: string
HamqthSessionId:
name: hamqth_session_id
in: query
description: "A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user."
required: false
schema:
type: string
schemas: schemas:
Source: Source:
type: string type: string