Allow spotting upstream to cluster. #39

This commit is contained in:
Ian Renton
2026-09-27 11:09:20 +01:00
parent 1a48d178ca
commit 27bd8a6b44
28 changed files with 192 additions and 81 deletions
+32 -24
View File
@@ -6,6 +6,7 @@ from typing import Any
import requests
import tornado
from tornado import httputil
from tornado.ioloop import IOLoop
from tornado.web import Application
from core.activity_utils import get_ref_regex_for_activity
@@ -13,7 +14,9 @@ from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRE
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider
from providers.spot.dxcluster import DXCluster
from providers.spot.spot_provider import SpotProvider, SpotSubmissionError
from providers.spot.tiles import Tiles
logger = logging.getLogger(__name__)
@@ -37,7 +40,10 @@ class APISpotHandler(tornado.web.RequestHandler):
self._spots = spots
self._spot_providers = spot_providers or []
def post(self):
async def post(self):
"""Handle the post request with spot data. This is async because it could go on to submit spots to providers
which could be slow, so we need to stop it blocking the whole web server"""
try:
# Reject if not allowed
if not ALLOW_SPOTTING:
@@ -77,10 +83,6 @@ class APISpotHandler(tornado.web.RequestHandler):
upstream_credentials = handling.get("upstream_credentials", {})
captcha_token = handling.get("captcha_token", None)
# Spothole v2.0 release only: deny upstream spotting. Spothole API breaking changes were in v2.0 but
# functionality is not ready yet. TODO
submit_upstream = False
# Verify CAPTCHA if required
if RECAPTCHA_SECRET_KEY:
if not captcha_token:
@@ -89,7 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not self._verify_recaptcha(captcha_token):
if not await IOLoop.current().run_in_executor(None, self._verify_recaptcha, captcha_token):
self.set_status(422)
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
self.set_header("Cache-Control", "no-store")
@@ -166,20 +168,15 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.activities:
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
self.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.activity_refs and upstream_provider_name != "Tiles":
is_cluster = isinstance(self._find_provider(upstream_provider_name, spot.activities), DXCluster)
is_tiles = isinstance(self._find_provider(upstream_provider_name, spot.activities), Tiles)
if not spot.activity_refs and not is_tiles and not is_cluster:
self.set_status(422)
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.dx_grid and upstream_provider_name == "Tiles":
if not spot.dx_grid and is_tiles:
self.set_status(422)
self.write(
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.")
@@ -187,7 +184,7 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.mode and upstream_provider_name == "Tiles":
if not spot.mode and is_tiles:
self.set_status(422)
self.write(safe_json_dumps("Error - a mode is required to submit upstream to Tiles on the Air."))
self.set_header("Cache-Control", "no-store")
@@ -200,19 +197,29 @@ class APISpotHandler(tornado.web.RequestHandler):
provider = self._find_provider(upstream_provider_name, spot.activities)
if provider:
try:
# Submit spot to the upstream provider
provider.submit_spot(spot, upstream_credentials)
# Trigger a re-poll after 1 second so the spot appears quickly
threading.Timer(1.0, provider.force_poll).start()
# Submit spot to the upstream provider. Run in a separate thread otherwise this blocks the whole
# web server for everyone!
await IOLoop.current().run_in_executor(None, provider.submit_spot, spot, upstream_credentials)
# Trigger a re-poll after 3 second so the spot appears quickly. (Submitting to a cluster node is
# slower than this, but we get data as a live stream from cluster anyway, so force_poll does
# nothing in that case. This is really just for the HTTP providers when we submit a spot to them)
threading.Timer(3.0, provider.force_poll).start()
except NotImplementedError as e:
upstream_warning = str(e)
except SpotSubmissionError as e:
logger.warning(f"Upstream submission to {upstream_provider_name} was not accepted: {e}")
upstream_warning = (
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed: {e}"
)
except Exception:
logger.exception(f"Failed to submit spot upstream to {upstream_provider_name}")
upstream_warning = (
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
)
else:
elif spot.activities:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots."
else:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for spots with no activity."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
@@ -239,10 +246,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for at least one of the given activities."""
"""Find an enabled provider by name that can submit spots for at least one of the given activities. If there
are no activities, find one that can submit spots with no activity."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities or [None]):
return p
return None
+11 -9
View File
@@ -21,16 +21,18 @@ def build_options():
"""Build the options data that is returned by the /options API and used to populate the web UI templates. This
doesn't change at runtime, so it is built once on first use and then cached."""
# 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. Spots with no
# activity use the empty string as the key as that's effectively what is in the "blank" entry in the drop-down
# on the Add Spot page
spot_submit_providers = {}
# Spothole v2.0 - disable this for now, API changes are in but this functionality is not ready yet. TODO
# for provider in DATA_PROVIDERS.spot_providers:
# if not provider.enabled:
# continue
# for activity in ACTIVITIES.values():
# if provider.can_submit_spot(activity.name):
# spot_submit_providers.setdefault(activity.name, []).append(provider.name)
for provider in DATA_PROVIDERS.spot_providers:
if not provider.enabled:
continue
for activity_name in [None, *(activity.name for activity in ACTIVITIES.values())]:
if provider.can_submit_spot(activity_name):
names = spot_submit_providers.setdefault(activity_name or "", [])
if provider.name not in names:
names.append(provider.name)
# 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.
+3 -3
View File
@@ -175,7 +175,7 @@ class WebServer:
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
"spot_providers": self._data_providers.spot_providers,
},
),
]
@@ -226,7 +226,7 @@ class WebServer:
V2APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
"spot_providers": self._data_providers.spot_providers,
},
),
]
@@ -277,7 +277,7 @@ class WebServer:
V1APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
"spot_providers": self._data_providers.spot_providers,
},
),
]