First attempt at converting "sig" to "activity" for v3

This commit is contained in:
Ian Renton
2026-09-24 07:09:37 +01:00
parent 1d0129f7bb
commit d91fa70655
90 changed files with 822 additions and 496 deletions
+12 -12
View File
@@ -21,7 +21,7 @@ RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)"""
"""API request handler for /api/v3/spot (POST)"""
def __init__(
self,
@@ -144,17 +144,17 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if activity ref format incorrect for activity
if (
spot.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
spot.activity
and spot.activity_refs
and len(spot.activity_refs) > 0
and spot.activity_refs[0].id
and get_ref_regex_for_activity(spot.activity)
and not re.match(get_ref_regex_for_activity(spot.activity), spot.activity_refs[0].id)
):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."
f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
)
)
self.set_header("Cache-Control", "no-store")
@@ -171,13 +171,13 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.sig:
if not spot.activity:
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.sig_refs and upstream_provider_name != "Tiles":
if not spot.activity_refs and upstream_provider_name != "Tiles":
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")
@@ -201,7 +201,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.sig)
provider = self._find_provider(upstream_provider_name, spot.activity)
if provider:
try:
# Submit spot to the upstream provider
@@ -216,7 +216,7 @@ class APISpotHandler(tornado.web.RequestHandler):
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
)
else:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.sig if spot.sig else ''} spots."
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.activity if spot.activity else ''} spots."
# 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
+9 -9
View File
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts"""
"""API request handler for /api/v3/alerts"""
def __init__(
self,
@@ -69,7 +69,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/alerts/stream"""
"""API request handler for /api/v3/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None
@@ -169,13 +169,13 @@ def alert_allowed_by_query(alert, query):
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check.
if (
alert.sig == ActivityName.DXPEDITION
alert.activity == ActivityName.DXPEDITION
and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
):
continue
if (
alert.sig == ActivityName.CONTEST
alert.activity == ActivityName.CONTEST
and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
):
@@ -186,14 +186,14 @@ def alert_allowed_by_query(alert, query):
sources = query.get(k).split(",")
if not alert.source or alert.source not in sources:
return False
case "sig":
case "activity":
# 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_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not alert.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not alert.activity and not include_no_activity:
return False
if alert.sig and alert.sig not in activities:
if alert.activity and alert.activity not in activities:
return False
case "dx_continent":
dxconts = query.get(k).split(",")
+1 -1
View File
@@ -20,7 +20,7 @@ HF_BANDS = [b.name for b in BANDS if b.is_ham_hf]
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats"""
"""API request handler for /api/v3/dxstats"""
def __init__(
self,
+9 -9
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call"""
"""API request handler for /api/v3/lookup/call"""
def __init__(
self,
@@ -63,7 +63,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupActivityRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/sigref"""
"""API request handler for /api/v3/lookup/activityref"""
def __init__(
self,
@@ -79,16 +79,16 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# "sig" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# "activity" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# that activity, the provided id must match it.
if "sig" in query_params and "id" in query_params:
activity = str(query_params.get("sig")).upper()
if "activity" in query_params and "id" in query_params:
activity = str(query_params.get("activity")).upper()
ref_id = str(query_params.get("id")).upper()
if get_activity_by_name(activity):
if not get_ref_regex_for_activity(activity) or re.match(
get_ref_regex_for_activity(activity), ref_id
):
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, sig=activity))
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, activity=activity))
self.write(safe_json_dumps(data))
else:
@@ -99,10 +99,10 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
)
self.set_status(422)
else:
self.write(safe_json_dumps(f"Error - sig '{activity}' is not known."))
self.write(safe_json_dumps(f"Error - activity '{activity}' is not known."))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - sig and id must be provided"))
self.write(safe_json_dumps("Error - activity and id must be provided"))
self.set_status(422)
except Exception:
@@ -115,7 +115,7 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/grid"""
"""API request handler for /api/v3/lookup/grid"""
def __init__(
self,
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options"""
"""API request handler for /api/v3/options"""
def __init__(
self,
@@ -75,7 +75,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"bands": BANDS,
"modes": [m.value for m in Mode],
"mode_types": [t.value for t in ModeType],
"sigs": list(ACTIVITIES.values()),
"activities": list(ACTIVITIES.values()),
"spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers,
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar"""
"""API request handler for /api/v3/solar"""
def __init__(
self,
+12 -12
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots"""
"""API request handler for /api/v3/spots"""
def __init__(
self,
@@ -68,7 +68,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/spots/stream"""
"""API request handler for /api/v3/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None
@@ -196,25 +196,25 @@ def spot_allowed_by_query(spot, query):
sources = query.get(k).split(",")
if not spot.source or spot.source not in sources:
return False
case "sig":
case "activity":
# 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_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not spot.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not spot.activity and not include_no_activity:
return False
if spot.sig and spot.sig not in activities:
if spot.activity and spot.activity not in activities:
return False
case "needs_sig":
case "needs_activity":
# 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_ACTIVITY" parameter to the "activity" query param.
needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.sig:
if needs_activity and not spot.activity:
return False
case "needs_sig_ref":
case "needs_activity_ref":
# 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"
if needs_activity_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
if needs_activity_ref and (not spot.activity_refs or len(spot.activity_refs) == 0):
return False
case "band":
bands = query.get(k).split(",")
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status"""
"""API request handler for /api/v3/status"""
def __init__(
self,
+13 -10
View File
@@ -11,12 +11,14 @@ from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
from webserver.handlers.api.v2_compatibility import translate_v2_spot
logger = logging.getLogger(__name__)
class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in early Spothole v2 for backwards compatibility."""
"""API request handler for /api/v1/spot (POST). Included in Spothole v2 onwards for backwards
compatibility."""
def __init__(
self,
@@ -57,8 +59,9 @@ class V1APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Read in the request body as JSON then convert to a Spot object
json_spot = tornado.escape.json_decode(post_data)
# Read in the request body as JSON then convert to a Spot object. The v1 spot format uses the same field
# names as v2, so needs the same translation to v3 field names.
json_spot = translate_v2_spot(tornado.escape.json_decode(post_data))
spot = Spot(**json_spot)
# Reject if no timestamp, frequency, dx_call or de_call
@@ -106,17 +109,17 @@ class V1APISpotHandler(tornado.web.RequestHandler):
# Reject if activity ref format incorrect for activity
if (
spot.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
spot.activity
and spot.activity_refs
and len(spot.activity_refs) > 0
and spot.activity_refs[0].id
and get_ref_regex_for_activity(spot.activity)
and not re.match(get_ref_regex_for_activity(spot.activity), spot.activity_refs[0].id)
):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."
f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
)
)
self.set_header("Cache-Control", "no-store")
+7 -5
View File
@@ -1,6 +1,6 @@
import re
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.v2_compatibility import V2APISpotsHandler, V2APISpotsStreamHandler
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
_LEGACY_PARAM_TO_HEADER_MAP = {
@@ -24,8 +24,9 @@ def _handle_legacy_params(handler):
handler.request.headers[header] = value
class V1APISpotsHandler(APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in early Spothole v2 for backwards compatibility."""
class V1APISpotsHandler(V2APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self):
_handle_legacy_params(self)
@@ -37,8 +38,9 @@ class V1APISpotsHandler(APISpotsHandler):
super().write(chunk)
class V1APISpotsStreamHandler(APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in early Spothole v2 for backwards compatibility."""
class V1APISpotsStreamHandler(V2APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self):
_handle_legacy_params(self)
+169
View File
@@ -0,0 +1,169 @@
import json
import tornado
from core.utils import safe_json_dumps
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.lookups import APILookupActivityRefHandler
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
# Query parameters renamed in v3
_V2_TO_V3_QUERY_PARAMS = {
"sig": "activity",
"needs_sig": "needs_activity",
"needs_sig_ref": "needs_activity_ref",
}
# Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"},
"fields": {"sig": "activity", "sig_refs": "activity_refs"},
}
# Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = {
"activity": "sig",
"activity_refs": "sig_refs",
"activity_type": "sig_type",
"activities": "sigs",
"activity_ref_data_providers": "sig_ref_data_providers",
"activity_name": "sig_name",
}
# Values of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_VALUES = {
"dx_location_source": {"ACTIVITY REF LOOKUP": "SIG REF LOOKUP"},
}
def _translate_v2_query_params(handler):
"""Rename any v2 query parameters (and values) in the request to their v3 equivalents, so the v3 handler can
understand them."""
for arguments in (handler.request.arguments, handler.request.query_arguments):
for v2_name, v3_name in _V2_TO_V3_QUERY_PARAMS.items():
if v2_name in arguments:
arguments[v3_name] = arguments.pop(v2_name)
for name, value_map in _V2_TO_V3_QUERY_VALUES.items():
if name in arguments:
arguments[name] = [
",".join(value_map.get(item.strip(), item) for item in v.decode("utf-8").split(",")).encode("utf-8")
for v in arguments[name]
]
def _translate_v3_response_object(obj):
"""Rename keys and values in an object from their v3 names to their v2 names."""
if isinstance(obj, dict):
translated = {}
for k, v in obj.items():
if k in _V3_TO_V2_RESPONSE_VALUES and isinstance(v, str):
v = _V3_TO_V2_RESPONSE_VALUES[k].get(v, v)
translated[_V3_TO_V2_RESPONSE_KEYS.get(k, k)] = _translate_v3_response_object(v)
return translated
if isinstance(obj, list):
return [_translate_v3_response_object(i) for i in obj]
return obj
def translate_v3_response(chunk):
"""Translate a JSON string output by a v3 handler into its v2 equivalent"""
if not isinstance(chunk, str):
return chunk
try:
return safe_json_dumps(_translate_v3_response_object(json.loads(chunk)))
except ValueError:
return chunk
def translate_v2_spot(spot_data):
"""Translate a spot provided by a client in v2 format into v3 format"""
spot_data = dict(spot_data)
if "sig" in spot_data:
spot_data["activity"] = spot_data.pop("sig")
if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list):
refs = []
for ref in spot_data["activity_refs"]:
if isinstance(ref, dict) and "sig" in ref:
ref = dict(ref)
ref["activity"] = ref.pop("sig")
refs.append(ref)
spot_data["activity_refs"] = refs
return spot_data
class _V2ResponseTranslationMixin:
"""Mixin for request handlers that translates v2 query params to v3 on the way in, and v3 JSON responses to v2 on
the way out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write(self, chunk):
super().write(translate_v3_response(chunk))
class _V2StreamTranslationMixin:
"""Mixin for SSE handlers that translates v2 query params to v3 on the way in, and v3 JSON messages to v2 on the way
out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write_message(self, name=None, msg=True, wait=None, evt_id=None):
if not name:
msg = translate_v3_response(msg)
return super().write_message(name=name, msg=msg, wait=wait, evt_id=evt_id)
class V2APISpotsHandler(_V2ResponseTranslationMixin, APISpotsHandler):
"""API request handler for /api/v2/spots (GET). Included in Spothole v3 for backwards compatibility."""
class V2APISpotsStreamHandler(_V2StreamTranslationMixin, APISpotsStreamHandler):
"""API request handler for /api/v2/spots/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsHandler(_V2ResponseTranslationMixin, APIAlertsHandler):
"""API request handler for /api/v2/alerts (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsStreamHandler(_V2StreamTranslationMixin, APIAlertsStreamHandler):
"""API request handler for /api/v2/alerts/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIOptionsHandler(_V2ResponseTranslationMixin, APIOptionsHandler):
"""API request handler for /api/v2/options (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIStatusHandler(_V2ResponseTranslationMixin, APIStatusHandler):
"""API request handler for /api/v2/status (GET). Included in Spothole v3 for backwards compatibility."""
class V2APILookupSigRefHandler(_V2ResponseTranslationMixin, APILookupActivityRefHandler):
"""API request handler for /api/v2/lookup/sigref (GET). Included in Spothole v3 for backwards compatibility. This
is the v2 equivalent of /api/v3/lookup/activityref."""
class V2APISpotHandler(APISpotHandler):
"""API request handler for /api/v2/spot (POST). Included in Spothole v3 for backwards compatibility. Translates the
spot in the request body from v2 to v3 format. The response is a plain status message so needs no translation."""
def post(self):
# Translate the request body if we can. If the body is empty or invalid JSON, leave it alone and let the v3
# handler return the appropriate error.
try:
json_body = tornado.escape.json_decode(self.request.body)
if isinstance(json_body, dict) and isinstance(json_body.get("spot"), dict):
json_body["spot"] = translate_v2_spot(json_body["spot"])
self.request.body = json.dumps(json_body).encode("utf-8")
except ValueError:
pass
super().post()