mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-05 18:11:41 +00:00
Refactor of caching & data storage part 13 #118
This commit is contained in:
@@ -274,6 +274,10 @@ callsign-data-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
# No server-side credentials for QRZ. Users must provide their own as per QRZ policy.
|
# 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
|
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||||
# for alerts.
|
# for alerts.
|
||||||
|
|||||||
+16
-611
@@ -13,620 +13,25 @@ from requests_cache import CachedSession
|
|||||||
|
|
||||||
from core.config import config
|
from core.config import config
|
||||||
from core.constants import HTTP_HEADERS, HAMQTH_PRG
|
from core.constants import HTTP_HEADERS, HAMQTH_PRG
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.url_data_cache import URLDataCache
|
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):
|
callsign = Callsign(call=callsign)
|
||||||
data = {}
|
for p in DATA_PROVIDERS.callsign_data_providers:
|
||||||
for k, v in raw.items():
|
# Get new lookup data
|
||||||
if v is None:
|
data = p.lookup(callsign, lookup_credentials)
|
||||||
continue
|
if data:
|
||||||
mapped_key = _QRZ_FIELD_MAP.get(k, k)
|
# Merge in turn, replacing any existing content where we have it.
|
||||||
if mapped_key in _QRZ_INT_FIELDS:
|
for key, value in data.__dict__.items():
|
||||||
try:
|
if value is not None:
|
||||||
v = int(v)
|
callsign.__dict__[key] = value
|
||||||
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
|
|
||||||
|
|
||||||
|
return callsign
|
||||||
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()
|
|
||||||
+27
-14
@@ -4,21 +4,20 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
|
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
def populate_sig_ref_info(sig_ref):
|
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_ref object
|
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
||||||
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. This makes
|
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
|
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
|
SIG we are getting data for."""
|
||||||
be populated if it can be determined."""
|
|
||||||
|
|
||||||
if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "":
|
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 id were not set.")
|
logging.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||||
return sig_ref
|
return None
|
||||||
|
|
||||||
sig = sig_ref.sig
|
sig_ref = SIGRef(sig=sig, id=ref_id)
|
||||||
ref_id = sig_ref.id
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
### FUDGES ###
|
### FUDGES ###
|
||||||
@@ -81,10 +80,8 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
key = sig + ":" + ref_id
|
key = sig + ":" + ref_id
|
||||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||||
if lookup_data:
|
if lookup_data:
|
||||||
# Copy new sig ref data into existing object where data was previously missing
|
return lookup_data
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||||
# just silently ignore it.
|
# just silently ignore it.
|
||||||
@@ -93,3 +90,19 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||||
return sig_ref
|
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
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.call_lookup_helper import lookup_helper
|
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
|
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
|
# 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:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
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
|
# 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.
|
# and apply it to the whole spot.
|
||||||
|
|||||||
+2
-2
@@ -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.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.call_lookup_helper import lookup_helper
|
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_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, \
|
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
|
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
|
||||||
from data.sig_ref import SIGRef
|
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
|
# 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:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
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 the spot itself doesn't have location yet, but the SIG ref does, extract it
|
||||||
if sig_ref.grid and not self.dx_grid:
|
if sig_ref.grid and not self.dx_grid:
|
||||||
self.dx_grid = sig_ref.grid
|
self.dx_grid = sig_ref.grid
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -40,7 +40,7 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
session_key = lookup_credentials.qrz_session_key
|
session_key = lookup_credentials.qrz_session_key
|
||||||
elif lookup_credentials.qrz_username and lookup_credentials.qrz_password:
|
elif lookup_credentials.qrz_username and lookup_credentials.qrz_password:
|
||||||
try:
|
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) +
|
self._QRZ_BASE_URL + "?username=" + urllib.parse.quote_plus(lookup_credentials.qrz_username) +
|
||||||
"&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole",
|
"&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole",
|
||||||
headers=HTTP_HEADERS).content
|
headers=HTTP_HEADERS).content
|
||||||
@@ -49,7 +49,8 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
if "Key" in session:
|
if "Key" in session:
|
||||||
session_key = str(session["Key"])
|
session_key = str(session["Key"])
|
||||||
else:
|
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
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when getting QRZ.com session key")
|
logging.error("Exception when getting QRZ.com session key")
|
||||||
@@ -146,9 +147,10 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
name=name,
|
name=name,
|
||||||
qth=data["addr2"] if "addr2" in data else None,
|
qth=data["addr2"] if "addr2" in data else None,
|
||||||
country=data["country"] if "country" in data else None,
|
country=data["country"] if "country" in data else None,
|
||||||
|
continent=data["continent"] if "continent" in data else None,
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
grid=grid,
|
grid=grid,
|
||||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||||
cq_zone=int(data["cqzone"]) if "cqzone" 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)
|
||||||
|
|||||||
@@ -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.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_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 core.utils import safe_json_dumps
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -112,7 +112,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
ref_id = str(query_params.get("id")).upper()
|
ref_id = str(query_params.get("id")).upper()
|
||||||
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
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):
|
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))
|
self.write(safe_json_dumps(data))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ info:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2.0
|
||||||
|
|
||||||
|
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to status and removed `cleanup`
|
||||||
|
|
||||||
### 1.4
|
### 1.4
|
||||||
|
|
||||||
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
* 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
|
* 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
|
* 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`.
|
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
|
### 1.3
|
||||||
|
|
||||||
@@ -46,8 +48,7 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: The Unlicense
|
name: The Unlicense
|
||||||
url: https://unlicense.org/#the-unlicense
|
url: https://unlicense.org/#the-unlicense
|
||||||
version: v1.4
|
version: v2.0
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: https://spothole.app/api/v1
|
- url: https://spothole.app/api/v1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user