Partial fix for mypy issues

This commit is contained in:
Ian Renton
2026-09-20 20:42:16 +01:00
parent 93ea27510f
commit 203758fa2d
25 changed files with 244 additions and 147 deletions
+3 -1
View File
@@ -12,7 +12,7 @@ from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_activity_ref_info(activity_name: str, ref_id: str) -> ActivityRef | None: def get_activity_ref_info(activity_name: str | None, ref_id: str | None) -> ActivityRef | None:
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an """Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity name and a reference ID (both strings) and returns an ActivityRef object populated with as much data activity name and a reference ID (both strings) and returns an ActivityRef object populated with as much data
as we can find. This makes use of activity ref data in the data store, live lookups from the web, or just as we can find. This makes use of activity ref data in the data store, live lookups from the web, or just
@@ -102,9 +102,11 @@ def get_activity_ref_info(activity_name: str, ref_id: str) -> ActivityRef | None
# the best result. # the best result.
iota_lookup = get_activity_ref_info(ActivityName.IOTA, ref_id) iota_lookup = get_activity_ref_info(ActivityName.IOTA, ref_id)
gma_lookup = get_activity_ref_info(ActivityName.GMA, ref_id) gma_lookup = get_activity_ref_info(ActivityName.GMA, ref_id)
if iota_lookup:
for key, value in iota_lookup.__dict__.items(): for key, value in iota_lookup.__dict__.items():
if value is not None and activity_ref.__dict__.get(key) is None: if value is not None and activity_ref.__dict__.get(key) is None:
activity_ref.__dict__[key] = value activity_ref.__dict__[key] = value
if gma_lookup:
for key, value in gma_lookup.__dict__.items(): for key, value in gma_lookup.__dict__.items():
if value is not None and activity_ref.__dict__.get(key) is None: if value is not None and activity_ref.__dict__.get(key) is None:
activity_ref.__dict__[key] = value activity_ref.__dict__[key] = value
+1 -1
View File
@@ -3,7 +3,7 @@ from data.activities import ACTIVITIES
from data.activity import Activity from data.activity import Activity
def get_activity_by_name(name: str) -> Activity | None: def get_activity_by_name(name: str | None) -> Activity | None:
"""Utility function to resolve an arbitrary, case-insensitive activity name string (e.g. from a spot comment, a """Utility function to resolve an arbitrary, case-insensitive activity name string (e.g. from a spot comment, a
provider, or an API request) to the matching known Activity. Returns None if no match is found.""" provider, or an API request) to the matching known Activity. Returns None if no match is found."""
+2 -2
View File
@@ -5,12 +5,12 @@ from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials from data.lookup_credentials import LookupCredentials
def get_call_info(callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign: def get_call_info(callsign: str | None, lookup_credentials: LookupCredentials | None) -> Callsign:
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers. """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, 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.""" to enable lookup using those providers."""
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign or "")
# First check our input looks like a real callsign # First check our input looks like a real callsign
if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign): if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign):
+7 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import threading import threading
import time import time
from collections.abc import Sequence
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from core.config import config, create_provider_from_config from core.config import config, create_provider_from_config
@@ -48,7 +49,7 @@ class DataProviders:
@staticmethod @staticmethod
def start_providers( def start_providers(
providers: list[ providers: Sequence[
SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider
], ],
provider_type: str, provider_type: str,
@@ -108,13 +109,13 @@ class DataProviders:
logger.exception("Exception stopping provider") logger.exception("Exception stopping provider")
threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers] threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers]
for t in threads: for thread in threads:
t.start() thread.start()
deadline = time.monotonic() + 15 deadline = time.monotonic() + 15
for t in threads: for thread in threads:
t.join(timeout=max(0.0, deadline - time.monotonic())) thread.join(timeout=max(0.0, deadline - time.monotonic()))
still_running = [t for t in threads if t.is_alive()] still_running = [thread for thread in threads if thread.is_alive()]
if still_running: if still_running:
logger.warning("Some threads did not stop in time!") logger.warning("Some threads did not stop in time!")
+83 -25
View File
@@ -14,7 +14,8 @@ from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions from data.solar_conditions import SolarConditions
if TYPE_CHECKING: if TYPE_CHECKING:
# Deferred to avoid a circular import: data.alert and data.spot both import core.data_store at module level. # Can't find a way to resolve the circular dependency on types but apparently this is a way of managing that
# while still having type safety in method definitions.
from data.alert import Alert from data.alert import Alert
from data.spot import Spot from data.spot import Spot
@@ -33,52 +34,109 @@ class DataStore:
self._MAX_ALERT_COUNT = 100000 self._MAX_ALERT_COUNT = 100000
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300 self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60 self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
# Caches # Caches. These are all created by setup(), which must be called before use; they are None only in the window
self.alerts: LiveDataCache[Alert] | None = None # between construction of this (global, single-instance) object and that call.
self.spots: LiveDataCache[Spot] | None = None self._alerts: LiveDataCache[Alert] | None = None
self.callsign_data_countryfiles: diskcache.Cache | None = None self._spots: LiveDataCache[Spot] | None = None
self.callsign_data_clublogxml: diskcache.Cache | None = None self._callsign_data_countryfiles: diskcache.Cache | None = None
self.callsign_data_clublogapi: diskcache.Cache | None = None self._callsign_data_clublogxml: diskcache.Cache | None = None
self.callsign_data_qrz: diskcache.Cache | None = None self._callsign_data_clublogapi: diskcache.Cache | None = None
self.callsign_data_hamqth: diskcache.Cache | None = None self._callsign_data_qrz: diskcache.Cache | None = None
self.dxcc_data: diskcache.Cache | None = None self._callsign_data_hamqth: diskcache.Cache | None = None
self._dxcc_data: diskcache.Cache | None = None
self.dxcc_lookup_by_call_regex: list[tuple[re.Pattern[str], Any]] = [] self.dxcc_lookup_by_call_regex: list[tuple[re.Pattern[str], Any]] = []
self.activity_refs: diskcache.Cache | None = None self._activity_refs: diskcache.Cache | None = None
self.status: SingleObjectDataCache[dict[str, Any]] | None = None self._status: SingleObjectDataCache[dict[str, Any]] | None = None
self.solar_conditions: SingleObjectDataCache[SolarConditions] | None = None self._solar_conditions: SingleObjectDataCache[SolarConditions] | None = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be # ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
# caches, they can just be straight objects # caches, they can just be straight objects. Unlike the caches above, these genuinely may never be populated,
# if the corresponding static data provider isn't configured, so callers must handle None.
self.cq_zone_data: geopandas.GeoDataFrame | None = None self.cq_zone_data: geopandas.GeoDataFrame | None = None
self.itu_zone_data: geopandas.GeoDataFrame | None = None self.itu_zone_data: geopandas.GeoDataFrame | None = None
@property
def alerts(self) -> LiveDataCache[Alert]:
assert self._alerts is not None, "DataStore.setup() must be called before use"
return self._alerts
@property
def spots(self) -> LiveDataCache[Spot]:
assert self._spots is not None, "DataStore.setup() must be called before use"
return self._spots
@property
def callsign_data_countryfiles(self) -> diskcache.Cache:
assert self._callsign_data_countryfiles is not None, "DataStore.setup() must be called before use"
return self._callsign_data_countryfiles
@property
def callsign_data_clublogxml(self) -> diskcache.Cache:
assert self._callsign_data_clublogxml is not None, "DataStore.setup() must be called before use"
return self._callsign_data_clublogxml
@property
def callsign_data_clublogapi(self) -> diskcache.Cache:
assert self._callsign_data_clublogapi is not None, "DataStore.setup() must be called before use"
return self._callsign_data_clublogapi
@property
def callsign_data_qrz(self) -> diskcache.Cache:
assert self._callsign_data_qrz is not None, "DataStore.setup() must be called before use"
return self._callsign_data_qrz
@property
def callsign_data_hamqth(self) -> diskcache.Cache:
assert self._callsign_data_hamqth is not None, "DataStore.setup() must be called before use"
return self._callsign_data_hamqth
@property
def dxcc_data(self) -> diskcache.Cache:
assert self._dxcc_data is not None, "DataStore.setup() must be called before use"
return self._dxcc_data
@property
def activity_refs(self) -> diskcache.Cache:
assert self._activity_refs is not None, "DataStore.setup() must be called before use"
return self._activity_refs
@property
def status(self) -> SingleObjectDataCache[dict[str, Any]]:
assert self._status is not None, "DataStore.setup() must be called before use"
return self._status
@property
def solar_conditions(self) -> SingleObjectDataCache[SolarConditions]:
assert self._solar_conditions is not None, "DataStore.setup() must be called before use"
return self._solar_conditions
def setup(self) -> None: def setup(self) -> None:
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# For solar data and status data, we use a wrapper around disk cache where each cache contains only a single # For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
# object exposed to the wider application, and provides a store() method for callers to notify diskcache that # object exposed to the wider application, and provides a store() method for callers to notify diskcache that
# the object has changed and needs to be re-cached. # the object has changed and needs to be re-cached.
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions()) self._solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {}) self._status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
# Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate # Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate
# these on a regular basis but there's no need for a TTL since old data is better than no data. # these on a regular basis but there's no need for a TTL since old data is better than no data.
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data") self._dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
self.regenerate_call_regex_to_dxcc_entity_map() self.regenerate_call_regex_to_dxcc_entity_map()
# For activity reference data specifically, we need to key on both activity *and* reference, and trying to do # For activity reference data specifically, we need to key on both activity *and* reference, and trying to do
# two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an # two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an
# ugly "activity:ref" syntax for keys to keep it a single level. # ugly "activity:ref" syntax for keys to keep it a single level.
self.activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs") self._activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs")
logger.info(f"Loaded data for {len(self.activity_refs)} activity references.") logger.info(f"Loaded data for {len(self.activity_refs)} activity references.")
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup. # Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign # Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
# we've seen, so we rely on them timing out and this triggering another lookup. # we've seen, so we rely on them timing out and this triggering another lookup.
self.callsign_data_countryfiles = diskcache.Cache(f"{CACHE_DIR}callsign_data_countryfiles") self._callsign_data_countryfiles = diskcache.Cache(f"{CACHE_DIR}callsign_data_countryfiles")
self.callsign_data_clublogxml = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogxml") self._callsign_data_clublogxml = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogxml")
self.callsign_data_clublogapi = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogapi") self._callsign_data_clublogapi = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogapi")
self.callsign_data_qrz = diskcache.Cache(f"{CACHE_DIR}callsign_data_qrz") self._callsign_data_qrz = diskcache.Cache(f"{CACHE_DIR}callsign_data_qrz")
self.callsign_data_hamqth = diskcache.Cache(f"{CACHE_DIR}callsign_data_hamqth") self._callsign_data_hamqth = diskcache.Cache(f"{CACHE_DIR}callsign_data_hamqth")
unique_keys = set() unique_keys = set()
for c in [ for c in [
self.callsign_data_countryfiles, self.callsign_data_countryfiles,
@@ -93,7 +151,7 @@ class DataStore:
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We # Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
# specifically load these caches *last* so that any activity ref and callsign data is already loaded from disk # specifically load these caches *last* so that any activity ref and callsign data is already loaded from disk
# cache before the spots and alerts are live in the system. # cache before the spots and alerts are live in the system.
self.spots = LiveDataCache( self._spots = LiveDataCache(
maxsize=self._MAX_SPOT_COUNT, maxsize=self._MAX_SPOT_COUNT,
ttl=MAX_SPOT_AGE, ttl=MAX_SPOT_AGE,
snapshot_dir=f"{CACHE_DIR}spots", snapshot_dir=f"{CACHE_DIR}spots",
@@ -101,7 +159,7 @@ class DataStore:
) )
logger.info(f"Loaded {len(self.spots.keys())} spots from a previous run.") logger.info(f"Loaded {len(self.spots.keys())} spots from a previous run.")
self.alerts = LiveDataCache( self._alerts = LiveDataCache(
maxsize=self._MAX_ALERT_COUNT, maxsize=self._MAX_ALERT_COUNT,
ttl=MAX_ALERT_AGE, ttl=MAX_ALERT_AGE,
snapshot_dir=f"{CACHE_DIR}alerts", snapshot_dir=f"{CACHE_DIR}alerts",
+4 -5
View File
@@ -2,8 +2,7 @@ import threading
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from requests import Response from requests_cache import AnyResponse, CachedSession
from requests_cache import CachedSession
from core.data_store import CACHE_DIR from core.data_store import CACHE_DIR
@@ -22,8 +21,8 @@ class URLDataCache(CachedSession):
expire_after=timedelta(days=1), expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404), allowable_codes=(200, 400, 401, 403, 404),
) )
self._lock = threading.Lock() self._get_lock = threading.Lock()
def get(self, *args: Any, **kwargs: Any) -> Response: def get(self, *args: Any, **kwargs: Any) -> AnyResponse:
with self._lock: with self._get_lock:
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
+13 -11
View File
@@ -299,7 +299,7 @@ class Spot:
# Now look to see if that activity name was followed by something that looks like a reference ID # Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the sig_refs list for this spot. # for that activity. If so, add that to the sig_refs list for this spot.
found_activity_info = get_activity_by_name(found_activity) found_activity_info = get_activity_by_name(found_activity)
if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex: if found_activity and found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
ref_matches = re.finditer( ref_matches = re.finditer(
r"(^|\W)" + found_activity + r"([ -])(" + found_activity_info.ref_regex + r")($|\W)", r"(^|\W)" + found_activity + r"([ -])(" + found_activity_info.ref_regex + r")($|\W)",
self.comment, self.comment,
@@ -314,19 +314,19 @@ class Spot:
# name, but where the activity reference is unique-looking enough that we can't confuse it with any other # name, but where the activity reference is unique-looking enough that we can't confuse it with any other
# activity. # activity.
if self.comment: if self.comment:
for activity in ACTIVITIES.values(): for candidate_activity in ACTIVITIES.values():
if activity.has_refs and activity.refs_globally_unique and activity.ref_regex: if candidate_activity.has_refs and candidate_activity.refs_globally_unique and candidate_activity.ref_regex:
ref_matches = re.finditer( ref_matches = re.finditer(
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE r"(^|\W)(" + candidate_activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
) )
for ref_match in ref_matches: for ref_match in ref_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This # First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know # covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF. # it's WWFF.
if not self.sig: if not self.sig:
self.sig = activity.name self.sig = candidate_activity.name
self._append_activity_ref_if_missing( self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), sig=activity.name) ActivityRef(id=ref_match.group(2).upper(), sig=candidate_activity.name)
) )
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a # Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
@@ -473,12 +473,14 @@ class Spot:
self.dx_latitude = dx_call_info.latitude self.dx_latitude = dx_call_info.latitude
self.dx_longitude = dx_call_info.longitude self.dx_longitude = dx_call_info.longitude
self.dx_grid = dx_call_info.grid self.dx_grid = dx_call_info.grid
self.dx_location_source = dx_call_info.location_source self.dx_location_source = (
LocationSourceForSpot(dx_call_info.location_source) if dx_call_info.location_source else None
)
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable # Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
# string, otherwise see what they have set on an online lookup service. # string, otherwise see what they have set on an online lookup service.
if self.sig_refs: if self.sig_refs:
qth = self.sig_refs[0].id qth = self.sig_refs[0].id or ""
if self.sig_refs[0].name: if self.sig_refs[0].name:
qth += f" {self.sig_refs[0].name}" qth += f" {self.sig_refs[0].name}"
self.dx_qth = qth self.dx_qth = qth
@@ -499,8 +501,8 @@ class Spot:
# DXCC lookup from callsign if nothing else has provided it # DXCC lookup from callsign if nothing else has provided it
if self.dx_call and not self.dx_dxcc_id: if self.dx_call and not self.dx_dxcc_id:
for regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex: for dxcc_regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
if regex.pattern and regex.match(self.dx_call): if dxcc_regex.pattern and dxcc_regex.match(self.dx_call):
self.dx_dxcc_id = entity_code self.dx_dxcc_id = entity_code
break break
if self.dx_dxcc_id and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
@@ -547,7 +549,7 @@ class Spot:
def _append_activity_ref_if_missing(self, new_activity_ref: ActivityRef) -> None: def _append_activity_ref_if_missing(self, new_activity_ref: ActivityRef) -> None:
"""Append an activity ref to the list, so long as it's not already there.""" """Append an activity ref to the list, so long as it's not already there."""
new_activity_ref.id = new_activity_ref.id.strip().upper() new_activity_ref.id = (new_activity_ref.id or "").strip().upper()
new_activity_ref.sig = new_activity_ref.sig.strip().upper() new_activity_ref.sig = new_activity_ref.sig.strip().upper()
if new_activity_ref.id == "": if new_activity_ref.id == "":
return return
@@ -4,6 +4,9 @@ from typing import Any
import requests import requests
from fastkml import kml from fastkml import kml
from fastkml.containers import Document, Folder
from fastkml.features import Placemark
from fastkml.geometry import Point
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityRefType from core.enums import ActivityRefType
@@ -26,13 +29,19 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]: def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = [] new_data: list[ActivityRef] = []
k = kml.KML.from_string(http_response.content) # KML content may carry an XML encoding declaration, which lxml's parser (used internally here) refuses to
# accept as a decoded str, so bytes must be passed even though the type stub only declares str.
k = kml.KML.from_string(http_response.content) # type: ignore[arg-type]
for document in k.features: for document in k.features:
# noinspection unresolved-references if not isinstance(document, Document):
continue
for folder in document.features: for folder in document.features:
# noinspection unresolved-references if not isinstance(folder, Folder):
continue
for placemark in folder.features: for placemark in folder.features:
if not isinstance(placemark, Placemark):
continue
description = placemark.description or "" description = placemark.description or ""
match = self.REF_PATTERN.search(description) match = self.REF_PATTERN.search(description)
if not match: if not match:
@@ -40,6 +49,9 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
continue continue
ref_id = match.group(0) ref_id = match.group(0)
if not isinstance(placemark.geometry, Point):
# Not a point location (e.g. a boundary polygon) - skip it, we can't get a single lat/lon from it
continue
longitude, latitude = placemark.geometry.x, placemark.geometry.y longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = ActivityRef( ref = ActivityRef(
+1
View File
@@ -39,6 +39,7 @@ class AlertProvider:
self._add_alert(alert) self._add_alert(alert)
def _add_alert(self, alert: Alert) -> None: def _add_alert(self, alert: Alert) -> None:
assert alert.id is not None, "infer_missing() always assigns an id"
if not alert.expired(): if not alert.expired():
self._alerts.set(alert.id, alert) self._alerts.set(alert.id, alert)
+2
View File
@@ -135,7 +135,9 @@ class QRZ(APIQueryCallsignDataProvider):
# functions can't deal with multiple calls this way. # functions can't deal with multiple calls this way.
if isinstance(data, list): if isinstance(data, list):
data = data[0] data = data[0]
assert isinstance(data, dict)
callsign = data["call"] callsign = data["call"]
assert isinstance(data, dict)
# Get a name # Get a name
name = None name = None
+2
View File
@@ -128,6 +128,8 @@ class ParksNPeaks(HTTPSpotProvider):
raise ValueError( raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account." "Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account."
) )
if not spot.freq:
raise RuntimeError("The Parks N Peaks API requires a frequency to be set.")
ref_id = spot.sig_refs[0].id if spot.sig_refs else "" ref_id = spot.sig_refs[0].id if spot.sig_refs else ""
body = { body = {
"actClass": spot.sig or "", "actClass": spot.sig or "",
+2
View File
@@ -64,6 +64,8 @@ class POTA(HTTPSpotProvider):
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None: def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
if sig_ref: if sig_ref:
if not spot.freq:
raise RuntimeError("The POTA API requires a frequency to be set.")
body = { body = {
"activator": spot.dx_call, "activator": spot.dx_call,
"spotter": spot.de_call, "spotter": spot.de_call,
+7 -4
View File
@@ -94,21 +94,24 @@ class SOTA(HTTPSpotProvider):
raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.") raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
if sig_ref: if sig_ref:
if not spot.freq:
raise ValueError("SOTA API requires a frequency to be set.")
# Split reference into association and summit codes # Split reference into association and summit codes
ref_split = sig_ref.split("/") ref_split = sig_ref.split("/")
# Figure out a valid mode. Borrowed this from PoLo :) # Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
mode = spot.mode mode_str = spot.mode.value if spot.mode else ""
if mode and mode not in self.VALID_MODES: if spot.mode and spot.mode not in self.VALID_MODES:
mode = "Data" mode_str = "Data"
body = { body = {
"activatorCallsign": spot.dx_call, "activatorCallsign": spot.dx_call,
"associationCode": ref_split[0], "associationCode": ref_split[0],
"summitCode": ref_split[1], "summitCode": ref_split[1],
"frequency": spot.freq / 1000000.0, "frequency": spot.freq / 1000000.0,
"mode": mode or "", "mode": mode_str,
"callsign": spot.de_call, "callsign": spot.de_call,
"comments": spot.comment or "", "comments": spot.comment or "",
"type": "TEST", # todo replatce with NORMAL/QRT once testing complete "type": "TEST", # todo replatce with NORMAL/QRT once testing complete
+4 -3
View File
@@ -37,12 +37,12 @@ class SpotProvider:
# off to SSE listeners. # off to SSE listeners.
spots = sorted(spots, key=lambda s: s.time if s and s.time else 0) spots = sorted(spots, key=lambda s: s.time if s and s.time else 0)
for spot in spots: for spot in spots:
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time: if datetime.fromtimestamp(spot.time or 0, pytz.UTC) > self.last_spot_time:
# Fill in any blanks and add to the list # Fill in any blanks and add to the list
spot.infer_missing() spot.infer_missing()
self._add_spot(spot) self._add_spot(spot)
if spots: if spots:
self.last_spot_time = datetime.fromtimestamp(max(s.time for s in spots), pytz.UTC) self.last_spot_time = datetime.fromtimestamp(max(s.time or 0 for s in spots), pytz.UTC)
def _submit(self, spot: Spot) -> None: def _submit(self, spot: Spot) -> None:
"""Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots """Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
@@ -52,9 +52,10 @@ class SpotProvider:
# Fill in any blanks and add to the list # Fill in any blanks and add to the list
spot.infer_missing() spot.infer_missing()
self._add_spot(spot) self._add_spot(spot)
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC) self.last_spot_time = datetime.fromtimestamp(spot.time or 0, pytz.UTC)
def _add_spot(self, spot: Spot) -> None: def _add_spot(self, spot: Spot) -> None:
assert spot.id is not None, "infer_missing() always assigns an id"
if not spot.expired(): if not spot.expired():
self._spots.set(spot.id, spot) self._spots.set(spot.id, spot)
+11 -8
View File
@@ -90,22 +90,25 @@ class Tiles(HTTPSpotProvider):
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None: def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# Tiles on the air currently only supports *self* spots # Tiles on the air currently only supports *self* spots
if spot.dx_call == spot.de_call: if spot.dx_call == spot.de_call:
if not spot.freq:
raise RuntimeError("The Tiles on the Air API requires a frequency to be set.")
# Figure out a valid mode. Borrowed this from PoLo :) # Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
if spot.mode: if spot.mode:
mode = spot.mode mode_str: str = spot.mode.value
if mode not in self.VALID_MODES: if spot.mode not in self.VALID_MODES:
if mode == "OLIVIA": if spot.mode == "OLIVIA":
mode = "Olivia" mode_str = "Olivia"
elif mode == "JS8": elif spot.mode == "JS8":
mode = "JS8Call" mode_str = "JS8Call"
else: else:
mode = "Other" mode_str = "Other"
body = { body = {
"call_sign": spot.dx_call, "call_sign": spot.dx_call,
"frequency": str(spot.freq / 1000000.0), "frequency": str(spot.freq / 1000000.0),
"mode": mode or "", "mode": mode_str or "",
"grid": spot.dx_grid or "", "grid": spot.dx_grid or "",
"comment": spot.comment or "", "comment": spot.comment or "",
"lat": spot.dx_latitude or None, "lat": spot.dx_latitude or None,
+2 -2
View File
@@ -213,9 +213,9 @@ class TelnetServer:
and everything must be renderable in ASCII.""" and everything must be renderable in ASCII."""
de_call = f"{callinfo.Callinfo.get_homecall(spot.de_call)[:6] + ':' if spot.de_call else '???:'!s:<7}" de_call = f"{callinfo.Callinfo.get_homecall(spot.de_call)[:6] + ':' if spot.de_call else '???:'!s:<7}"
frequency = f"{(spot.freq / 1000.0):10.1f}" frequency = f"{((spot.freq or 0) / 1000.0):10.1f}"
dx_call = f"{spot.dx_call!s:<12}" dx_call = f"{spot.dx_call!s:<12}"
comment = f"{spot.comment.encode('ascii', errors='ignore').decode()[:29]:<30}" comment = f"{(spot.comment or '').encode('ascii', errors='ignore').decode()[:29]:<30}"
if spot.time: if spot.time:
timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z" timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z"
else: else:
+8 -2
View File
@@ -144,13 +144,14 @@ class APISpotHandler(tornado.web.RequestHandler):
return return
# Reject if activity ref format incorrect for activity # Reject if activity ref format incorrect for activity
ref_regex = get_ref_regex_for_activity(spot.sig) if spot.sig else None
if ( if (
spot.sig spot.sig
and spot.sig_refs and spot.sig_refs
and len(spot.sig_refs) > 0 and len(spot.sig_refs) > 0
and spot.sig_refs[0].id and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig) and ref_regex
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) and not re.match(ref_regex, spot.sig_refs[0].id)
): ):
self.set_status(422) self.set_status(422)
self.write( self.write(
@@ -202,6 +203,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested # Submit upstream if requested
upstream_warning = None upstream_warning = None
if submit_upstream and upstream_provider_name: if submit_upstream and upstream_provider_name:
# spot.sig was already validated non-empty above, under the same submit_upstream/upstream_provider_name gate
assert spot.sig is not None
provider = self._find_provider(upstream_provider_name, spot.sig) provider = self._find_provider(upstream_provider_name, spot.sig)
if provider: if provider:
try: try:
@@ -224,6 +227,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# we were but it failed, we should still add it to our database anyway. # we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning: if not submit_upstream or upstream_warning:
spot.infer_missing() spot.infer_missing()
assert self._spots is not None, "initialize() must be called before post()"
assert spot.id is not None, "infer_missing() always assigns an id"
self._spots.set(spot.id, spot) self._spots.set(spot.id, spot)
if upstream_warning: if upstream_warning:
@@ -245,6 +250,7 @@ class APISpotHandler(tornado.web.RequestHandler):
def _find_provider(self, provider_name: str, activity: str) -> SpotProvider | None: def _find_provider(self, provider_name: str, activity: str) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity.""" """Find an enabled provider by name that can submit spots for the given activity."""
assert self._spot_providers is not None, "initialize() must be called before _find_provider()"
for p in self._spot_providers: for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(activity): if p.enabled and p.name == provider_name and p.can_submit_spot(activity):
return p return p
+21 -19
View File
@@ -51,13 +51,13 @@ class APIAlertsHandler(tornado.web.RequestHandler):
# Fetch all alerts matching the query, then optionally enrich with online data # Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(self.request.headers)
data = get_alert_list_with_filters(self._alerts, query_params) assert self._alerts is not None, "initialize() must be called before get()"
alerts = get_alert_list_with_filters(self._alerts, query_params)
fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else [] fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else []
if credentials: if credentials:
data = self._enrich(data, credentials) alerts = self._enrich(alerts, credentials)
# Filter for only the required fields, if necessary # Filter for only the required fields, if necessary
if fields: data: list[Alert] | list[dict[str, Any]] = filter_fields(alerts, fields) if fields else alerts
data = filter_fields(data, fields)
self.write(safe_json_dumps(data)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
@@ -104,6 +104,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# Register to handle new alerts arriving. The callback() method will get called with the new alert as an # Register to handle new alerts arriving. The callback() method will get called with the new alert as an
# argument. # argument.
assert self._sse_alert_broadcaster is not None, "initialize() must be called before open()"
self._sse_alert_broadcaster.register(self) self._sse_alert_broadcaster.register(self)
except Exception: except Exception:
@@ -113,6 +114,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def close(self) -> None: def close(self) -> None:
"""When the user closes the socket, deregister ourselves from the alert broadcaster""" """When the user closes the socket, deregister ourselves from the alert broadcaster"""
assert self._sse_alert_broadcaster is not None, "initialize() must be called before close()"
self._sse_alert_broadcaster.unregister(self) self._sse_alert_broadcaster.unregister(self)
super().close() super().close()
@@ -121,15 +123,15 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
try: try:
# If the new alert matches our param filters, send it to the client. If not, ignore it. # If the new alert matches our param filters, send it to the client. If not, ignore it.
assert self._query_params is not None, "open() must be called before callback()"
if alert_allowed_by_query(alert, self._query_params): if alert_allowed_by_query(alert, self._query_params):
# Add lookup data if we have credentials # Add lookup data if we have credentials
if self._credentials: if self._credentials:
alert = copy.deepcopy(alert) alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials) alert.infer_missing(self._credentials)
# Filter fields returned if necessary # Filter fields returned if necessary
if self._fields: output: Alert | dict[str, Any] = filter_fields([alert], self._fields)[0] if self._fields else alert
alert = filter_fields([alert], self._fields)[0] self.write_message(msg=safe_json_dumps(output))
self.write_message(msg=safe_json_dumps(alert))
except Exception: except Exception:
logger.exception("Exception in SSE callback, connection will be closed") logger.exception("Exception in SSE callback, connection will be closed")
self.close() self.close()
@@ -151,7 +153,7 @@ def get_alert_list_with_filters(all_alerts: LiveDataCache, query: dict[str, str]
alerts = sorted(alerts, key=lambda alert: alert.start_time if alert and alert.start_time else 0) alerts = sorted(alerts, key=lambda alert: alert.start_time if alert and alert.start_time else 0)
alerts = list(filter(lambda alert: alert_allowed_by_query(alert, query), alerts)) alerts = list(filter(lambda alert: alert_allowed_by_query(alert, query), alerts))
if "limit" in query: if "limit" in query:
alerts = alerts[: int(query.get("limit"))] alerts = alerts[: int(query["limit"])]
return alerts return alerts
@@ -162,11 +164,11 @@ def alert_allowed_by_query(alert: Alert, query: dict[str, str]) -> bool:
for k in query: for k in query:
match k: match k:
case "received_since": case "received_since":
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC) since = datetime.fromtimestamp(float(query[k]), pytz.UTC).timestamp()
if not alert.received_time or alert.received_time <= since: if not alert.received_time or alert.received_time <= since:
return False return False
case "max_duration": case "max_duration":
max_duration = int(query.get(k)) max_duration = int(query[k])
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is # Check the duration if end_time is provided. If end_time is not provided, assume the activation is
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and # "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also # the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
@@ -174,42 +176,42 @@ def alert_allowed_by_query(alert: Alert, query: dict[str, str]) -> bool:
if ( if (
alert.sig == ActivityName.DXPEDITION alert.sig == ActivityName.DXPEDITION
and "dxpeditions_skip_max_duration_check" in query and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE" and query["dxpeditions_skip_max_duration_check"].upper() == "TRUE"
): ):
continue continue
if ( if (
alert.sig == ActivityName.CONTEST alert.sig == ActivityName.CONTEST
and "contests_skip_max_duration_check" in query and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE" and query["contests_skip_max_duration_check"].upper() == "TRUE"
): ):
continue continue
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration: if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
return False return False
case "source": case "source":
sources = query.get(k).split(",") sources = query[k].split(",")
if not alert.source or alert.source not in sources: if not alert.source or alert.source not in sources:
return False return False
case "sig": case "sig":
# If a list of activities is provided, the alert must have an activity and it must match one of them. # If a list of activities is provided, the alert must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches alerts with no activity. # The special activity "NO_SIG", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",") activities = query[k].split(",")
include_no_activity = "NO_SIG" in activities include_no_activity = "NO_SIG" in activities
if not alert.sig and not include_no_activity: if not alert.sig and not include_no_activity:
return False return False
if alert.sig and alert.sig not in activities: if alert.sig and alert.sig not in activities:
return False return False
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") dxconts = query[k].split(",")
if not alert.dx_continent or alert.dx_continent not in dxconts: if not alert.dx_continent or alert.dx_continent not in dxconts:
return False return False
case "dx_call_includes": case "dx_call_includes":
dx_call_includes = query.get(k).strip() dx_call_includes = query[k].strip()
if not alert.dx_call or dx_call_includes.upper() not in alert.dx_call.upper(): if not alert.dx_calls or not any(dx_call_includes.upper() in c.upper() for c in alert.dx_calls if c):
return False return False
case "text_includes": case "text_includes":
text_includes = query.get(k).strip() text_includes = query[k].strip()
if ( if (
(not alert.dx_call or text_includes.upper() not in alert.dx_call.upper()) (not alert.dx_calls or not any(text_includes.upper() in c.upper() for c in alert.dx_calls if c))
and (not alert.comment or text_includes.upper() not in alert.comment.upper()) and (not alert.comment or text_includes.upper() not in alert.comment.upper())
and (not alert.freqs_modes or text_includes.upper() not in alert.freqs_modes.upper()) and (not alert.freqs_modes or text_includes.upper() not in alert.freqs_modes.upper())
): ):
+2 -1
View File
@@ -37,8 +37,9 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
def get(self) -> None: def get(self) -> None:
try: try:
assert self._spots is not None, "initialize() must be called before get()"
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp() one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter() counts: Counter[tuple[str, str, str]] = Counter()
for key in self._spots.keys(): # noqa: SIM118 for key in self._spots.keys(): # noqa: SIM118
spot = self._spots.get(key) spot = self._spots.get(key)
+2 -3
View File
@@ -85,9 +85,8 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
activity = str(query_params.get("sig")).upper() activity = str(query_params.get("sig")).upper()
ref_id = str(query_params.get("id")).upper() ref_id = str(query_params.get("id")).upper()
if get_activity_by_name(activity): if get_activity_by_name(activity):
if not get_ref_regex_for_activity(activity) or re.match( ref_regex = get_ref_regex_for_activity(activity)
get_ref_regex_for_activity(activity), ref_id if not ref_regex or re.match(ref_regex, ref_id):
):
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, sig=activity)) data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, sig=activity))
self.write(safe_json_dumps(data)) self.write(safe_json_dumps(data))
+11 -17
View File
@@ -34,8 +34,10 @@ class APIOptionsHandler(tornado.web.RequestHandler):
def get(self) -> None: def get(self) -> None:
try: try:
assert self._status_data is not None, "initialize() must be called before get()"
# Build a map of activity name -> list of provider names that can submit spots for that activity # Build a map of activity name -> list of provider names that can submit spots for that activity
spot_submit_providers = {} spot_submit_providers: dict[str, list[str]] = {}
# Spothole v2.0 - disable this for now, API changes are in but this functionality is not ready yet. TODO # Spothole v2.0 - disable this for now, API changes are in but this functionality is not ready yet. TODO
# for provider in self._spot_providers: # for provider in self._spot_providers:
@@ -47,23 +49,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle # Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
# things that aren't even available. # things that aren't even available.
spot_providers: list = [ spot_provider_configs: list[dict[str, Any]] = self._status_data["spot_providers"]
p["name"] for p in filter(lambda p: p["enabled"], self._status_data["spot_providers"]) alert_provider_configs: list[dict[str, Any]] = self._status_data["alert_providers"]
] callsign_data_provider_configs: list[dict[str, Any]] = self._status_data["callsign_data_providers"]
alert_providers = [p["name"] for p in filter(lambda p: p["enabled"], self._status_data["alert_providers"])]
callsign_data_providers = [ spot_providers: list = [p["name"] for p in spot_provider_configs if p["enabled"]]
p["name"] alert_providers = [p["name"] for p in alert_provider_configs if p["enabled"]]
for p in filter( callsign_data_providers = [p["name"] for p in callsign_data_provider_configs if p["enabled"]]
lambda p: p["enabled"],
self._status_data["callsign_data_providers"],
)
]
spot_providers_enabled_by_default = [ spot_providers_enabled_by_default = [
p["name"] p["name"] for p in spot_provider_configs if p["enabled"] and p["enabled_by_default_in_web_ui"]
for p in filter(
lambda p: p["enabled"] and p["enabled_by_default_in_web_ui"],
self._status_data["spot_providers"],
)
] ]
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
@@ -28,6 +28,7 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
def get(self) -> None: def get(self) -> None:
try: try:
assert self._solar_conditions is not None, "initialize() must be called before get()"
self.write(self._solar_conditions.to_json()) self.write(self._solar_conditions.to_json())
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+28 -26
View File
@@ -51,12 +51,12 @@ class APISpotsHandler(tornado.web.RequestHandler):
# Fetch all spots matching the query, then optionally enrich with online data # Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(self.request.headers)
fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else [] fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else []
data = get_spot_list_with_filters(self._spots, query_params) assert self._spots is not None, "initialize() must be called before get()"
spots = get_spot_list_with_filters(self._spots, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) spots = self._enrich(spots, credentials)
# Filter for only the required fields, if necessary # Filter for only the required fields, if necessary
if fields: data: list[Spot] | list[dict[str, Any]] = filter_fields(spots, fields) if fields else spots
data = filter_fields(data, fields)
self.write(safe_json_dumps(data)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
@@ -105,6 +105,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# Register to handle new spots arriving. The callback() method will get called with the new spot as an # Register to handle new spots arriving. The callback() method will get called with the new spot as an
# argument. # argument.
assert self._sse_spot_broadcaster is not None, "initialize() must be called before open()"
self._sse_spot_broadcaster.register(self) self._sse_spot_broadcaster.register(self)
except Exception: except Exception:
@@ -114,6 +115,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def close(self) -> None: def close(self) -> None:
"""When the user closes the socket, deregister ourselves from the spot broadcaster""" """When the user closes the socket, deregister ourselves from the spot broadcaster"""
assert self._sse_spot_broadcaster is not None, "initialize() must be called before close()"
self._sse_spot_broadcaster.unregister(self) self._sse_spot_broadcaster.unregister(self)
super().close() super().close()
@@ -122,15 +124,15 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
try: try:
# If the new spot matches our param filters, send it to the client. If not, ignore it. # If the new spot matches our param filters, send it to the client. If not, ignore it.
assert self._query_params is not None, "open() must be called before callback()"
if spot_allowed_by_query(spot, self._query_params): if spot_allowed_by_query(spot, self._query_params):
# Add lookup data if we have credentials # Add lookup data if we have credentials
if self._credentials: if self._credentials:
spot = copy.deepcopy(spot) spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials) spot.infer_missing(self._credentials)
# Filter fields returned if necessary # Filter fields returned if necessary
if self._fields: output: Spot | dict[str, Any] = filter_fields([spot], self._fields)[0] if self._fields else spot
spot = filter_fields([spot], self._fields)[0] self.write_message(msg=safe_json_dumps(output))
self.write_message(msg=safe_json_dumps(spot))
except Exception: except Exception:
logger.exception("Exception in SSE callback, connection will be closed") logger.exception("Exception in SSE callback, connection will be closed")
self.close() self.close()
@@ -152,7 +154,7 @@ def get_spot_list_with_filters(all_spots: LiveDataCache, query: dict[str, str])
spots = sorted(spots, key=lambda spot: spot.time if spot and spot.time else 0, reverse=True) spots = sorted(spots, key=lambda spot: spot.time if spot and spot.time else 0, reverse=True)
spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots)) spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots))
if "limit" in query: if "limit" in query:
spots = spots[: int(query.get("limit"))] spots = spots[: int(query["limit"])]
# Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the # Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
# list being in reverse time order, so if any future change allows re-ordering the list, that should # list being in reverse time order, so if any future change allows re-ordering the list, that should
@@ -162,7 +164,7 @@ def get_spot_list_with_filters(all_spots: LiveDataCache, query: dict[str, str])
# duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't # duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't
# work well for the other views. # work well for the other views.
if "dedupe" in query: if "dedupe" in query:
dedupe = query.get("dedupe").upper() == "TRUE" dedupe = query["dedupe"].upper() == "TRUE"
if dedupe: if dedupe:
spots_temp = [] spots_temp = []
already_seen = [] already_seen = []
@@ -183,26 +185,26 @@ def spot_allowed_by_query(spot: Spot, query: dict[str, str]) -> bool:
for k in query: for k in query:
match k: match k:
case "since": case "since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp() since = datetime.fromtimestamp(int(query[k]), pytz.UTC).timestamp()
if not spot.time or spot.time <= since: if not spot.time or spot.time <= since:
return False return False
case "max_age": case "max_age":
max_age = int(query.get(k)) max_age = int(query[k])
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp() since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
if not spot.time or spot.time <= since: if not spot.time or spot.time <= since:
return False return False
case "received_since": case "received_since":
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC).timestamp() since = datetime.fromtimestamp(float(query[k]), pytz.UTC).timestamp()
if not spot.received_time or spot.received_time <= since: if not spot.received_time or spot.received_time <= since:
return False return False
case "source": case "source":
sources = query.get(k).split(",") sources = query[k].split(",")
if not spot.source or spot.source not in sources: if not spot.source or spot.source not in sources:
return False return False
case "sig": case "sig":
# If a list of activities is provided, the spot must have an activity and it must match one of them. # If a list of activities is provided, the spot must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches spots with no activity. # The special activity "NO_SIG", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",") activities = query[k].split(",")
include_no_activity = "NO_SIG" in activities include_no_activity = "NO_SIG" in activities
if not spot.sig and not include_no_activity: if not spot.sig and not include_no_activity:
return False return False
@@ -211,56 +213,56 @@ def spot_allowed_by_query(spot: Spot, query: dict[str, str]) -> bool:
case "needs_sig": case "needs_sig":
# If true, an activity is required, regardless of what it is, it just can't be missing. Mutually # If true, an activity is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param. # exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
needs_activity = query.get(k).upper() == "TRUE" needs_activity = query[k].upper() == "TRUE"
if needs_activity and not spot.sig: if needs_activity and not spot.sig:
return False return False
case "needs_sig_ref": case "needs_sig_ref":
# If true, at least one activity ref is required, regardless of what it is, it just can't be missing. # If true, at least one activity ref is required, regardless of what it is, it just can't be missing.
needs_activity_ref = query.get(k).upper() == "TRUE" needs_activity_ref = query[k].upper() == "TRUE"
if needs_activity_ref and (not spot.sig_refs or len(spot.sig_refs) == 0): if needs_activity_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
return False return False
case "band": case "band":
bands = query.get(k).split(",") bands = query[k].split(",")
if not spot.band or spot.band not in bands: if not spot.band or spot.band not in bands:
return False return False
case "mode": case "mode":
modes = query.get(k).split(",") modes = query[k].split(",")
if not spot.mode or spot.mode not in modes: if not spot.mode or spot.mode not in modes:
return False return False
case "mode_type": case "mode_type":
mode_types = query.get(k).split(",") mode_types = query[k].split(",")
if not spot.mode_type or spot.mode_type not in mode_types: if not spot.mode_type or spot.mode_type not in mode_types:
return False return False
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") dxconts = query[k].split(",")
if not spot.dx_continent or spot.dx_continent not in dxconts: if not spot.dx_continent or spot.dx_continent not in dxconts:
return False return False
case "de_continent": case "de_continent":
deconts = query.get(k).split(",") deconts = query[k].split(",")
if not spot.de_continent or spot.de_continent not in deconts: if not spot.de_continent or spot.de_continent not in deconts:
return False return False
case "comment_includes": case "comment_includes":
comment_includes = query.get(k).strip() comment_includes = query[k].strip()
if not spot.comment or comment_includes.upper() not in spot.comment.upper(): if not spot.comment or comment_includes.upper() not in spot.comment.upper():
return False return False
case "dx_call_includes": case "dx_call_includes":
dx_call_includes = query.get(k).strip() dx_call_includes = query[k].strip()
if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper(): if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
return False return False
case "text_includes": case "text_includes":
text_includes = query.get(k).strip() text_includes = query[k].strip()
if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) and ( if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) and (
not spot.comment or text_includes.upper() not in spot.comment.upper() not spot.comment or text_includes.upper() not in spot.comment.upper()
): ):
return False return False
case "allow_qrt": case "allow_qrt":
# If false, spots that are flagged as QRT are not returned. # If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE" prevent_qrt = query[k].upper() == "FALSE"
if prevent_qrt and spot.qrt: if prevent_qrt and spot.qrt:
return False return False
case "needs_good_location": case "needs_good_location":
# If true, spots require a "good" location to be returned # If true, spots require a "good" location to be returned
needs_good_location = query.get(k).upper() == "TRUE" needs_good_location = query[k].upper() == "TRUE"
if needs_good_location and not spot.dx_location_good: if needs_good_location and not spot.dx_location_good:
return False return False
return True return True
+5 -2
View File
@@ -106,13 +106,14 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return return
# Reject if activity ref format incorrect for activity # Reject if activity ref format incorrect for activity
ref_regex = get_ref_regex_for_activity(spot.sig) if spot.sig else None
if ( if (
spot.sig spot.sig
and spot.sig_refs and spot.sig_refs
and len(spot.sig_refs) > 0 and len(spot.sig_refs) > 0
and spot.sig_refs[0].id and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig) and ref_regex
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) and not re.match(ref_regex, spot.sig_refs[0].id)
): ):
self.set_status(422) self.set_status(422)
self.write( self.write(
@@ -127,6 +128,8 @@ class V1APISpotHandler(tornado.web.RequestHandler):
# infer missing data, and add it to our database. # infer missing data, and add it to our database.
spot.source = "API" spot.source = "API"
spot.infer_missing() spot.infer_missing()
assert self._spots is not None, "initialize() must be called before post()"
assert spot.id is not None, "infer_missing() always assigns an id"
self._spots.set(spot.id, spot) self._spots.set(spot.id, spot)
self.write(safe_json_dumps("OK")) self.write(safe_json_dumps("OK"))
+1
View File
@@ -34,6 +34,7 @@ class SSEBroadcaster:
return len(self._handlers) return len(self._handlers)
def publish(self, value: Any) -> None: def publish(self, value: Any) -> None:
assert self._loop is not None, "bind_to_web_server_loop() must be called before publish()"
self._loop.add_callback(self._broadcast, value) self._loop.add_callback(self._broadcast, value)
def _broadcast(self, value: Any) -> None: def _broadcast(self, value: Any) -> None: