mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-21 06:47:42 +00:00
Partial fix for mypy issues
This commit is contained in:
@@ -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,12 +102,14 @@ 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)
|
||||||
for key, value in iota_lookup.__dict__.items():
|
if iota_lookup:
|
||||||
if value is not None and activity_ref.__dict__.get(key) is None:
|
for key, value in iota_lookup.__dict__.items():
|
||||||
activity_ref.__dict__[key] = value
|
if value is not None and activity_ref.__dict__.get(key) is None:
|
||||||
for key, value in gma_lookup.__dict__.items():
|
activity_ref.__dict__[key] = value
|
||||||
if value is not None and activity_ref.__dict__.get(key) is None:
|
if gma_lookup:
|
||||||
activity_ref.__dict__[key] = value
|
for key, value in gma_lookup.__dict__.items():
|
||||||
|
if value is not None and activity_ref.__dict__.get(key) is None:
|
||||||
|
activity_ref.__dict__[key] = value
|
||||||
activity_ref.ref_type = ActivityRefType.ISLAND
|
activity_ref.ref_type = ActivityRefType.ISLAND
|
||||||
return activity_ref
|
return activity_ref
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user