From 50ab27e4a27691e9b37fca916bffa3eea64b6628 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Mon, 3 Aug 2026 08:56:34 +0100 Subject: [PATCH] Refactor of caching & data storage part 13 #118 --- config-example.yml | 4 + core/call_lookup_helper.py | 627 +------------------------------ core/sig_lookup_helper.py | 41 +- data/alert.py | 4 +- data/spot.py | 4 +- providers/callsigndata/hamqth.py | 137 +++++++ providers/callsigndata/qrz.py | 8 +- server/handlers/api/lookups.py | 4 +- static/apidocs/openapi.yml | 9 +- 9 files changed, 200 insertions(+), 638 deletions(-) create mode 100644 providers/callsigndata/hamqth.py diff --git a/config-example.yml b/config-example.yml index 33353e6..c1f9871 100644 --- a/config-example.yml +++ b/config-example.yml @@ -274,6 +274,10 @@ callsign-data-providers: enabled: true # No server-side credentials for QRZ. Users must provide their own as per QRZ policy. + - class: "HamQTH" + enabled: true + # No server-side credentials for HamQTH. Users must provide their own. + # Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week # for alerts. diff --git a/core/call_lookup_helper.py b/core/call_lookup_helper.py index 5160233..0706d4a 100644 --- a/core/call_lookup_helper.py +++ b/core/call_lookup_helper.py @@ -13,620 +13,25 @@ from requests_cache import CachedSession from core.config import config from core.constants import HTTP_HEADERS, HAMQTH_PRG +from core.data_providers import DATA_PROVIDERS from core.data_store import DATA_STORE from core.url_data_cache import URLDataCache +from data.callsign import Callsign -# 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"} -_URL_DATA_CACHE = URLDataCache("callsign_lookup") +def get_call_info(callsign, lookup_credentials): + """Utility method to get the best set of data for a callsign as we can, using all enabled providers. + lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them, + to enable lookup using those providers.""" -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 + callsign = Callsign(call=callsign) + for p in DATA_PROVIDERS.callsign_data_providers: + # Get new lookup data + data = p.lookup(callsign, lookup_credentials) + if data: + # Merge in turn, replacing any existing content where we have it. + for key, value in data.__dict__.items(): + if value is not None: + callsign.__dict__[key] = value - -class LookupHelper: - """Singleton class that provides lookup functionality.""" - - def __init__(self): - """Create the lookup helper. Note that nothing actually happens until the start() method is called, and that all - lookup methods will fail if start() has not yet been called. This therefore needs starting before any spot or - alert handlers are created.""" - - self._clublog_callsign_data_cache = None - self._lookup_lib_clublog_xml = None - self._clublog_xml_available = None - self._lookup_lib_clublog_api = None - self._clublog_xml_download_location = None - self._clublog_api_available = None - self._clublog_cty_xml_cache = None - self._clublog_api_key = None - self._qrz_callsign_data_cache = None - self._qrz_base_url = "https://xmldata.qrz.com/xml/current/" - # QRZ session keys expire after an hour; cache the login response for 55 minutes. - self._qrz_session_cache = CachedSession("cache/qrz_session_cache", - expire_after=timedelta(minutes=55)) - self._hamqth_callsign_data_cache = None - 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 - # and cache the HTTP response for 55 minutes, so when the login URL is queried within 55 minutes of the previous - # time, you just get the cached response. - self._hamqth_session_lookup_cache = CachedSession("cache/hamqth_session_cache", - expire_after=timedelta(minutes=55)) - self._call_info_basic = None - self._lookup_lib_basic = None - self._country_files_cty_plist_download_location = None - self._dxcc_json_download_location = None - - def start(self): - # Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads - # the data once on startup, and requires no login/key, but does not have the best coverage. - # If the user provides login details/API keys, we also set up helpers for QRZ.com, HamQTH, Clublog (live API - # request), and Clublog (XML download). The lookup functions iterate through these in a sensible order, looking - # for suitable data. - self._country_files_cty_plist_download_location = "cache/cty.plist" - success = self._download_country_files_cty_plist() - if success: - self._lookup_lib_basic = LookupLib(lookuptype="countryfile", - filename=self._country_files_cty_plist_download_location) - else: - self._lookup_lib_basic = LookupLib(lookuptype="countryfile") - self._call_info_basic = Callinfo(self._lookup_lib_basic) - - self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache') - - self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache') - - self._clublog_api_key = str(config["clublog-api-key"]) - self._clublog_cty_xml_cache = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10)) - self._clublog_api_available = self._clublog_api_key != "" - self._clublog_xml_download_location = "cache/cty.xml" - if self._clublog_api_available: - self._lookup_lib_clublog_api = LookupLib(lookuptype="clublogapi", apikey=self._clublog_api_key) - success = self._download_clublog_ctyxml() - self._clublog_xml_available = success - if success: - self._lookup_lib_clublog_xml = LookupLib(lookuptype="clublogxml", - filename=self._clublog_xml_download_location) - self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache') - - - def _download_country_files_cty_plist(self): - """Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use - this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can - catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the - requests_cache library to prevent re-downloading too quickly if the software keeps restarting.""" - - try: - logging.info("Downloading Country-files.com cty.plist...") - response = _URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist", - headers=HTTP_HEADERS) - - if response.ok: - with open(self._country_files_cty_plist_download_location, "w") as f: - f.write(response.text) - f.flush() - return True - else: - logging.warning(f"HTTP {response.status_code} when downloading Country-files.com cty.plist.") - return False - - except ConnectionError: - logging.warning(f"Connection error when downloading Clublog cty.xml.") - except (ConnectTimeout, ReadTimeout): - logging.warning(f"Timeout when downloading Clublog cty.xml.") - except Exception as e: - logging.error("Exception when downloading Clublog cty.xml", e) - return False - - - def _download_clublog_ctyxml(self): - """Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the - database live if possible.""" - - try: - logging.info("Downloading Clublog cty.xml.gz...") - response = self._clublog_cty_xml_cache.get("https://cdn.clublog.org/cty.php?api=" + self._clublog_api_key, - headers=HTTP_HEADERS) - logging.info("Caching Clublog cty.xml.gz...") - open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content) - with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed: - file_content = uncompressed.read() - assert isinstance(file_content, bytes) - logging.info("Caching Clublog cty.xml...") - with open(self._clublog_xml_download_location, "wb") as f: - f.write(file_content) - f.flush() - return True - - except Exception as e: - logging.error("Exception when downloading Clublog cty.xml", e) - return False - - def infer_country_from_callsign(self, call, credentials=None): - """Infer a country name from a callsign""" - - try: - # Start with the basic country-files.com-based decoder. - country = self._call_info_basic.get_country_name(call) - except (KeyError, ValueError): - country = None - # Couldn't get anything from basic call info database, try QRZ.com - if not country: - qrz_data = self._get_qrz_data_for_callsign(call, credentials) - if qrz_data and "country" in qrz_data: - country = qrz_data["country"] - # Couldn't get anything from QRZ.com database, try HamQTH - if not country: - hamqth_data = self._get_hamqth_data_for_callsign(call, credentials) - if hamqth_data and "country" in hamqth_data: - country = hamqth_data["country"] - # Couldn't get anything from HamQTH database, try Clublog data - if not country: - clublog_data = self._get_clublog_xml_data_for_callsign(call) - if clublog_data and "Name" in clublog_data: - country = clublog_data["Name"] - if not country: - clublog_data = self._get_clublog_api_data_for_callsign(call) - if clublog_data and "Name" in clublog_data: - country = clublog_data["Name"] - # Couldn't get anything from Clublog database, try DXCC data - if not country: - dxcc_data = self._get_dxcc_data_for_callsign(call) - if dxcc_data and "name" in dxcc_data: - country = dxcc_data["name"] - return country - - def infer_dxcc_id_from_callsign(self, call, credentials=None): - """Infer a DXCC ID from a callsign""" - - try: - # Start with the basic country-files.com-based decoder. - dxcc = self._call_info_basic.get_adif_id(call) - except (KeyError, ValueError): - dxcc = None - # Couldn't get anything from basic call info database, try QRZ.com - if not dxcc: - qrz_data = self._get_qrz_data_for_callsign(call, credentials) - if qrz_data and "adif" in qrz_data: - dxcc = qrz_data["adif"] - # Couldn't get anything from QRZ.com database, try HamQTH - if not dxcc: - hamqth_data = self._get_hamqth_data_for_callsign(call, credentials) - if hamqth_data and "adif" in hamqth_data: - dxcc = hamqth_data["adif"] - # Couldn't get anything from HamQTH database, try Clublog data - if not dxcc: - clublog_data = self._get_clublog_xml_data_for_callsign(call) - if clublog_data and "DXCC" in clublog_data: - dxcc = clublog_data["DXCC"] - if not dxcc: - clublog_data = self._get_clublog_api_data_for_callsign(call) - if clublog_data and "DXCC" in clublog_data: - dxcc = clublog_data["DXCC"] - # Couldn't get anything from Clublog database, try DXCC data - if not dxcc: - dxcc_data = self._get_dxcc_data_for_callsign(call) - if dxcc_data and "entityCode" in dxcc_data: - dxcc = dxcc_data["entityCode"] - return dxcc - - def infer_continent_from_callsign(self, call, credentials=None): - """Infer a continent shortcode from a callsign""" - - try: - # Start with the basic country-files.com-based decoder. - continent = self._call_info_basic.get_continent(call) - except (KeyError, ValueError): - continent = None - # Couldn't get anything from basic call info database, try HamQTH - if not continent: - hamqth_data = self._get_hamqth_data_for_callsign(call, credentials) - if hamqth_data and "continent" in hamqth_data: - continent = hamqth_data["continent"] - # Couldn't get anything from HamQTH database, try Clublog data - if not continent: - clublog_data = self._get_clublog_xml_data_for_callsign(call) - if clublog_data and "Continent" in clublog_data: - continent = clublog_data["Continent"] - if not continent: - clublog_data = self._get_clublog_api_data_for_callsign(call) - if clublog_data and "Continent" in clublog_data: - continent = clublog_data["Continent"] - # Couldn't get anything from Clublog database, try DXCC data - if not continent: - dxcc_data = self._get_dxcc_data_for_callsign(call) - # Some DXCCs are in two continents, if so don't use the continent data as we can't be sure - if dxcc_data and "continent" in dxcc_data and len(dxcc_data["continent"]) == 1: - continent = dxcc_data["continent"][0] - return continent - - def infer_cq_zone_from_callsign(self, call, credentials=None): - """Infer a CQ zone from a callsign""" - - try: - # Start with the basic country-files.com-based decoder. - cqz = self._call_info_basic.get_cqz(call) - except (KeyError, ValueError): - cqz = None - # Couldn't get anything from basic call info database, try QRZ.com - if not cqz: - qrz_data = self._get_qrz_data_for_callsign(call, credentials) - if qrz_data and "cqz" in qrz_data: - cqz = qrz_data["cqz"] - # Couldn't get anything from QRZ.com database, try HamQTH - if not cqz: - hamqth_data = self._get_hamqth_data_for_callsign(call, credentials) - if hamqth_data and "cq" in hamqth_data: - cqz = hamqth_data["cq"] - # Couldn't get anything from HamQTH database, try Clublog data - if not cqz: - clublog_data = self._get_clublog_xml_data_for_callsign(call) - if clublog_data and "CQZ" in clublog_data: - cqz = clublog_data["CQZ"] - if not cqz: - clublog_data = self._get_clublog_api_data_for_callsign(call) - if clublog_data and "CQZ" in clublog_data: - cqz = clublog_data["CQZ"] - # Couldn't get anything from Clublog database, try DXCC data - if not cqz: - dxcc_data = self._get_dxcc_data_for_callsign(call) - # Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure - if dxcc_data and "cq" in dxcc_data and len(dxcc_data["cq"]) == 1: - cqz = dxcc_data["cq"][0] - return cqz - - def infer_itu_zone_from_callsign(self, call, credentials=None): - """Infer a ITU zone from a callsign""" - - try: - # Start with the basic country-files.com-based decoder. - ituz = self._call_info_basic.get_ituz(call) - except (KeyError, ValueError): - ituz = None - # Couldn't get anything from basic call info database, try QRZ.com - if not ituz: - qrz_data = self._get_qrz_data_for_callsign(call, credentials) - if qrz_data and "ituz" in qrz_data: - ituz = qrz_data["ituz"] - # Couldn't get anything from QRZ.com database, try HamQTH - if not ituz: - hamqth_data = self._get_hamqth_data_for_callsign(call, credentials) - if hamqth_data and "itu" in hamqth_data: - ituz = hamqth_data["itu"] - # Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data - if not ituz: - dxcc_data = self._get_dxcc_data_for_callsign(call) - # Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure - if dxcc_data and "itu" in dxcc_data and len(dxcc_data["itu"]) == 1: - ituz = dxcc_data["itu"] - return ituz - - def infer_name_from_callsign_online_lookup(self, call, credentials=None): - """Infer an operator name from a callsign (requires QRZ.com/HamQTH)""" - - data = self._get_qrz_data_for_callsign(call, credentials) - if data and "name_fmt" in data: - return data["name_fmt"] - if data and "fname" in data: - name = data["fname"] - if "nick" in data: - name = name + " \"" + data["nick"] + "\"" - if "name" in data: - name = name + " " + data["name"] - return name - data = self._get_hamqth_data_for_callsign(call, credentials) - if data and "nick" in data: - return data["nick"] - else: - return None - - def infer_latlon_from_callsign_online_lookup(self, call, credentials=None): - """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)""" - - data = self._get_qrz_data_for_callsign(call, credentials) - if data and "latitude" in data and "longitude" in data and ( - float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float( - data["latitude"]) < 89.9: - return [float(data["latitude"]), float(data["longitude"])] - data = self._get_hamqth_data_for_callsign(call, credentials) - if data and "latitude" in data and "longitude" in data and ( - float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float( - data["latitude"]) < 89.9: - return [float(data["latitude"]), float(data["longitude"])] - else: - return None - - def infer_grid_from_callsign_online_lookup(self, call, credentials=None): - """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)""" - - 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 \ - data["locator"].upper() != "AA00AA00": - return data["locator"] - 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[ - "grid"].upper() != "AA00AA00": - return data["grid"] - else: - return None - - def infer_qth_from_callsign_online_lookup(self, call, credentials=None): - """Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)""" - - data = self._get_qrz_data_for_callsign(call, credentials) - if data and "addr2" in data: - return data["addr2"] - data = self._get_hamqth_data_for_callsign(call, credentials) - if data and "qth" in data: - return data["qth"] - else: - return None - - def infer_latlon_from_callsign_dxcc(self, call): - """Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)""" - - try: - data = self._call_info_basic.get_lat_long(call) - if data and "latitude" in data and "longitude" in data: - loc = [float(data["latitude"]), float(data["longitude"])] - else: - loc = None - except KeyError: - loc = None - # Couldn't get anything from basic call info database, try Clublog data - if not loc: - data = self._get_clublog_xml_data_for_callsign(call) - if data and "Lat" in data and "Lon" in data: - loc = [float(data["Lat"]), float(data["Lon"])] - if not loc: - data = self._get_clublog_api_data_for_callsign(call) - if data and "Lat" in data and "Lon" in data: - loc = [float(data["Lat"]), float(data["Lon"])] - return loc - - def infer_grid_from_callsign_dxcc(self, call): - """Infer a grid locator from a callsign (using DXCC, probably very inaccurate)""" - - latlon = self.infer_latlon_from_callsign_dxcc(call) or [] - grid = None - if latlon: - try: - grid = latlong_to_locator(latlon[0], latlon[1], 8) - except: - logging.debug("Invalid lat/lon received for DXCC") - return grid - - def _get_qrz_data_for_callsign(self, call, credentials) -> dict | None: - """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.""" - - # Return from cache if available (a cached None means 'not found in QRZ') - if call in self._qrz_callsign_data_cache: - return self._qrz_callsign_data_cache.get(call) - - # 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: - login_response = self._qrz_session_cache.get( - self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) + - "&password=" + urllib.parse.quote_plus(credentials.qrz_password) + "&agent=spothole", - headers=HTTP_HEADERS).content - login_data = xmltodict.parse(login_response) - session = login_data.get("QRZDatabase", {}).get("Session", {}) - if "Key" in session: - session_key = str(session["Key"]) - else: - logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.") - return None - except Exception: - logging.error("Exception when getting QRZ.com session key") - return None - - if not session_key: - return None - - # Try the call as given, then fall back to the base call (strips /P, /M etc.) - calls_to_try = [call] - try: - home_call = callinfo.Callinfo.get_homecall(call) - if home_call != call: - calls_to_try.append(home_call) - except ValueError: - logging.debug("Could not look up home call for callsign %s", call) - - for lookup_call in calls_to_try: - try: - response = _URL_DATA_CACHE.get( - self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call), - headers=HTTP_HEADERS, timeout=10) - if response.ok: - qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {}) - if qrz_response: - if "Callsign" in qrz_response: - data = _normalize_qrz_data(qrz_response.get("Callsign")) - self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds - return data - elif "Session" in qrz_response and "Error" in qrz_response.get("Session"): - # Errors here are normally just "callsign not in database", no need to log that ourselves - # above debug level. - logging.debug("QRZ returned an error looking up callsign %s: %s", lookup_call, - qrz_response.get("Session").get("Error")) - - elif not response.from_cache: - logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call) - elif not response.from_cache: - logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call) - - except (KeyError, ValueError): - continue - except ConnectionError: - logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call) - continue - except (ConnectTimeout, ReadTimeout): - logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call) - continue - except Exception: - logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True) - continue - - # 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) -> dict | None: - """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: - return self._hamqth_callsign_data_cache.get(call) - - # 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: - session_data = self._hamqth_session_lookup_cache.get( - self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) + - "&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content - dict_data = xmltodict.parse(session_data) - if "session_id" in dict_data["HamQTH"]["session"]: - session_id = str(dict_data["HamQTH"]["session"]["session_id"]) - else: - logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.") - 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] - try: - home_call = callinfo.Callinfo.get_homecall(call) - if home_call != call: - calls_to_try.append(home_call) - except ValueError: - logging.debug("Could not look up home call for callsign %s", call) - - for lookup_call in calls_to_try: - try: - response = _URL_DATA_CACHE.get( - self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus( - lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS) - if response.ok: - data = xmltodict.parse(response.content)["HamQTH"]["search"] - self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds - return data - elif not response.from_cache: - logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, lookup_call) - - except (KeyError, ValueError): - continue - except ConnectionError: - logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call) - continue - except (ConnectTimeout, ReadTimeout): - logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call) - continue - except Exception: - logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True) - continue - - # 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 - - def _get_clublog_api_data_for_callsign(self, call) -> dict | None: - """Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it""" - - # Fetch from cache if we can, otherwise fetch from the API and cache it - if call in self._clublog_callsign_data_cache: - return self._clublog_callsign_data_cache.get(call) - elif self._clublog_api_available: - try: - data = self._lookup_lib_clublog_api.lookup_callsign(callsign=call) - self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds - return data - except (KeyError, ValueError): - # Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call. - try: - data = self._lookup_lib_clublog_api.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call)) - self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds - return data - except (KeyError, ValueError): - # Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again - self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds - return None - except APIKeyMissingError: - # User API key was wrong, warn - logging.error("Could not look up via Clublog API, key " + self._clublog_api_key + " was rejected.") - return None - else: - return None - - def _get_clublog_xml_data_for_callsign(self, call) -> dict | None: - """Utility method to get Clublog XML data from file""" - - if self._clublog_xml_available: - try: - data = self._lookup_lib_clublog_xml.lookup_callsign(callsign=call) - return data - except (KeyError, ValueError): - # Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again - self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds - return None - else: - return None - - def _get_dxcc_data_for_callsign(self, call) -> dict | None: - """Utility method to get generic DXCC data from our lookup table, if we can find it""" - - for pattern, entity_code in DATA_STORE.dxcc_lookup_by_call_regex: - if pattern.match(call): - return DATA_STORE.dxcc_data[entity_code] - return None - - def stop(self): - """Shutdown method to close down any caches neatly.""" - - self._qrz_callsign_data_cache.close() - self._hamqth_callsign_data_cache.close() - self._clublog_callsign_data_cache.close() - - -# Singleton object -lookup_helper = LookupHelper() + return callsign \ No newline at end of file diff --git a/core/sig_lookup_helper.py b/core/sig_lookup_helper.py index 339c289..b64bb3e 100644 --- a/core/sig_lookup_helper.py +++ b/core/sig_lookup_helper.py @@ -4,21 +4,20 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator from core.data_store import DATA_STORE from core.geo_utils import wab_wai_square_to_lat_lon +from data.sig_ref import SIGRef -def populate_sig_ref_info(sig_ref): - """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object - which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. This makes +def get_sig_ref_info(sig, ref_id): + """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and + a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which - SIG we are getting data for. Any data currently in the object will be kept, only missing data in the object will - be populated if it can be determined.""" + SIG we are getting data for.""" - if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "": - logging.debug("Failed to look up sig_ref info, sig or id were not set.") - return sig_ref + if sig is None or sig == "" or ref_id is None or ref_id == "": + logging.debug("Failed to look up sig_ref info, sig or ref were not set.") + return None - sig = sig_ref.sig - ref_id = sig_ref.id + sig_ref = SIGRef(sig=sig, id=ref_id) try: ### FUDGES ### @@ -81,10 +80,8 @@ def populate_sig_ref_info(sig_ref): key = sig + ":" + ref_id lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None if lookup_data: - # Copy new sig ref data into existing object where data was previously missing - for key, value in lookup_data.__dict__.items(): - if value is not None and sig_ref.__dict__.get(key) is None: - sig_ref.__dict__[key] = value + return lookup_data + else: # Maybe a super new reference we don't know about yet, but more likely a typo or a test reference, # just silently ignore it. @@ -93,3 +90,19 @@ def populate_sig_ref_info(sig_ref): except Exception: logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True) return sig_ref + + +def populate_missing_sig_ref_info(sig_ref): + """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object + which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data + currently in the object will be kept, only missing data in the object will be populated if it can be determined.""" + + lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id) + + if lookup_data: + # Copy new sig ref data into existing object where data was previously missing + for key, value in lookup_data.__dict__.items(): + if value is not None and sig_ref.__dict__.get(key) is None: + sig_ref.__dict__[key] = value + + return sig_ref diff --git a/data/alert.py b/data/alert.py index d948bcc..839f333 100644 --- a/data/alert.py +++ b/data/alert.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta import pytz from core.call_lookup_helper import lookup_helper -from core.sig_lookup_helper import populate_sig_ref_info +from core.sig_lookup_helper import populate_missing_sig_ref_info from core.utils import get_flag_for_dxcc @@ -104,7 +104,7 @@ class Alert: # from WAB and WAI, which count as a SIG even though there's no real lookup, just maths if self.sig_refs and len(self.sig_refs) > 0: for sig_ref in self.sig_refs: - populate_sig_ref_info(sig_ref) + populate_missing_sig_ref_info(sig_ref) # If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG # and apply it to the whole spot. diff --git a/data/spot.py b/data/spot.py index 6977b16..ddcb081 100644 --- a/data/spot.py +++ b/data/spot.py @@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.call_lookup_helper import lookup_helper from core.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name -from core.sig_lookup_helper import populate_sig_ref_info +from core.sig_lookup_helper import populate_missing_sig_ref_info from core.utils import infer_band_from_freq, infer_mode_from_comment, \ infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc from data.sig_ref import SIGRef @@ -287,7 +287,7 @@ class Spot: # from WAB and WAI, which count as a SIG even though there's no real lookup, just maths if self.sig_refs and len(self.sig_refs) > 0: for sig_ref in self.sig_refs: - sig_ref = populate_sig_ref_info(sig_ref) + sig_ref = populate_missing_sig_ref_info(sig_ref) # If the spot itself doesn't have location yet, but the SIG ref does, extract it if sig_ref.grid and not self.dx_grid: self.dx_grid = sig_ref.grid diff --git a/providers/callsigndata/hamqth.py b/providers/callsigndata/hamqth.py new file mode 100644 index 0000000..ed17fed --- /dev/null +++ b/providers/callsigndata/hamqth.py @@ -0,0 +1,137 @@ +import logging +import urllib.parse +from datetime import timedelta + +import xmltodict +from pyhamtools import callinfo +from requests import ConnectTimeout, ReadTimeout +from requests_cache import CachedSession + +from core.config import SERVER_OWNER_CALLSIGN +from core.constants import HTTP_HEADERS, SOFTWARE_VERSION +from core.data_store import DATA_STORE, CACHE_DIR +from core.url_data_cache import URLDataCache +from data.callsign import Callsign +from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider + + +class HamQTH(APIQueryCallsignDataProvider): + """Callsign data provider for HamQTH.""" + + def __init__(self, provider_config): + super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth) + self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php" + self._PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_CALLSIGN).replace(" ", "_") + self._URL_DATA_CACHE = URLDataCache("hamqth") + # Separate URL cache for session key lookups. Once a session key is returned from logging in with a username + # and password, this is valid for an hour, so our cache stores this specifically for 55 minutes. + self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/hamqth-creds", + expire_after=timedelta(minutes=55)) + self.status = "Ready" + + def _perform_new_lookup(self, callsign, lookup_credentials): + # If we don't have HamQTH credentials, skip this lookup + if not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password) + or lookup_credentials.hamqth_session_key): + return None + + try: + # Obtain session key from credentials, by looking it up from username & password if necessary. + session_key = None + if lookup_credentials.hamqth_session_key: + session_key = lookup_credentials.hamqth_session_key + elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password: + try: + session_data = self._CREDENTIALS_CACHE.get( + self._HAMQTH_BASE_URL + "?u=" + urllib.parse.quote_plus(lookup_credentials.hamqth_username) + + "&p=" + urllib.parse.quote_plus(lookup_credentials.hamqth_password), + headers=HTTP_HEADERS).content + dict_data = xmltodict.parse(session_data) + if "session_id" in dict_data["HamQTH"]["session"]: + session_key = str(dict_data["HamQTH"]["session"]["session_id"]) + else: + # 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.") + return None + except Exception: + logging.error("Exception when getting HamQTH session key") + return None + + if not session_key: + return None + + # Try the call as given, then fall back to the base call (strips /P, /M etc.) + calls_to_try = [callsign] + try: + home_call = callinfo.Callinfo.get_homecall(callsign) + if home_call != callsign: + calls_to_try.append(home_call) + except ValueError: + logging.debug("Could not look up home call for callsign %s", callsign) + + # Try looking up each call using the API + for lookup_call in calls_to_try: + try: + response = self._URL_DATA_CACHE.get( + self._HAMQTH_BASE_URL + "?id=" + session_key + "&callsign=" + urllib.parse.quote_plus( + lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10) + if response.ok: + data = xmltodict.parse(response.content)["HamQTH"]["search"] + # Found data, convert it to our object and return it + return self.hamqth_response_to_callsign(callsign, data) + + elif not response.from_cache: + logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, + lookup_call) + + except (KeyError, ValueError): + continue + except ConnectionError: + logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call) + continue + except (ConnectTimeout, ReadTimeout): + logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call) + continue + except Exception: + logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True) + continue + + # Not found in HamQTH; return a Callsign object with no data so we cache that and don't keep retrying + return Callsign(call=callsign) + + except Exception as e: + self.status = "Error" + logging.error("Exception when looking up data from HamQTH", e, exc_info=True) + # Return None, this won't be cached so we will be asked to query data again for this call next time. + return None + + @staticmethod + def hamqth_response_to_callsign(callsign, data): + """Convert the "Callsign" block in HamQTH's API response to our own Callsign object.""" + + # Check for sensible latitudes + lat = None + lon = None + if "latitude" in data and "longitude" in data and ( + float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float( + data["latitude"]) < 89.9: + lat = float(data["latitude"]) + lon = float(data["longitude"]) + + # Check for sensible grids + grid = None + if "grid" in data and not data["grid"].startswith("AA00"): + grid = data["grid"] + + return Callsign(call=callsign, + home_call=callinfo.Callinfo.get_homecall(callsign), + name=data["nick"] if "nick" in data else None, + qth=data["qth"] if "qth" in data else None, + country=data["country"] if "country" in data else None, + continent=data["continent"] if "continent" in data else None, + latitude=lat, + longitude=lon, + grid=grid, + dxcc_id=int(data["adif"]) if "adif" in data else None, + cq_zone=int(data["cq"]) if "cq" in data else None, + itu_zone=int(data["itu"]) if "itu" in data else None) diff --git a/providers/callsigndata/qrz.py b/providers/callsigndata/qrz.py index 99db4da..bf6fc7d 100644 --- a/providers/callsigndata/qrz.py +++ b/providers/callsigndata/qrz.py @@ -40,7 +40,7 @@ class QRZ(APIQueryCallsignDataProvider): session_key = lookup_credentials.qrz_session_key elif lookup_credentials.qrz_username and lookup_credentials.qrz_password: try: - login_response = self._URL_DATA_CACHE.get( + login_response = self._CREDENTIALS_CACHE.get( self._QRZ_BASE_URL + "?username=" + urllib.parse.quote_plus(lookup_credentials.qrz_username) + "&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole", headers=HTTP_HEADERS).content @@ -49,7 +49,8 @@ class QRZ(APIQueryCallsignDataProvider): if "Key" in session: session_key = str(session["Key"]) else: - logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.") + # Log this failure at debug level only, not our problem if user entered the wrong password. + logging.debug("QRZ.com login details incorrect, failed to look up with QRZ.") return None except Exception: logging.error("Exception when getting QRZ.com session key") @@ -146,9 +147,10 @@ class QRZ(APIQueryCallsignDataProvider): name=name, qth=data["addr2"] if "addr2" in data else None, country=data["country"] if "country" in data else None, + continent=data["continent"] if "continent" in data else None, latitude=lat, longitude=lon, grid=grid, dxcc_id=int(data["adif"]) if "adif" in data else None, cq_zone=int(data["cqzone"]) if "cqzone" in data else None, - itu_zone=int(data["ituzone"]) if "adif" in data else None) + itu_zone=int(data["ituzone"]) if "ituzone" in data else None) diff --git a/server/handlers/api/lookups.py b/server/handlers/api/lookups.py index 6d18a8f..785576b 100644 --- a/server/handlers/api/lookups.py +++ b/server/handlers/api/lookups.py @@ -12,7 +12,7 @@ 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.prometheus_metrics_handler import api_requests_counter from core.sig_utils import get_ref_regex_for_sig -from core.sig_lookup_helper import populate_sig_ref_info +from core.sig_lookup_helper import populate_missing_sig_ref_info from core.utils import safe_json_dumps from data.lookup_credentials import extract_credentials from data.sig_ref import SIGRef @@ -112,7 +112,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler): ref_id = str(query_params.get("id")).upper() if sig in list(map(lambda p: p.name.upper(), SIGS)): if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id): - data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig)) + data = populate_missing_sig_ref_info(SIGRef(id=ref_id, sig=sig)) self.write(safe_json_dumps(data)) else: diff --git a/static/apidocs/openapi.yml b/static/apidocs/openapi.yml index cc3db7e..5b735ab 100644 --- a/static/apidocs/openapi.yml +++ b/static/apidocs/openapi.yml @@ -15,6 +15,10 @@ info: ## Changelog + ### 2.0 + + * Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to status and removed `cleanup` + ### 1.4 * Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have. @@ -22,8 +26,6 @@ info: * Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets * Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where it differs from their `name`. - * Added `propagation_mode` field to spots - * Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to status and removed `cleanup` ### 1.3 @@ -46,8 +48,7 @@ info: license: name: The Unlicense url: https://unlicense.org/#the-unlicense - version: v1.4 - + version: v2.0 servers: - url: https://spothole.app/api/v1