Merge branch 'main' into 95-send-spots-to-xota

# Conflicts:
#	README.md
#	config-example.yml
#	core/config.py
#	server/handlers/api/addspot.py
#	server/handlers/api/options.py
#	static/js/add-spot.js
#	static/js/bands.js
#	static/js/map.js
This commit is contained in:
Ian Renton
2026-08-02 07:47:40 +01:00
509 changed files with 1072 additions and 609 deletions
+14 -33
View File
@@ -1,4 +1,3 @@
import json
import logging
import re
import threading
@@ -16,8 +15,7 @@ from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything
from data.sig_ref import SIGRef
from core.utils import safe_json_dumps
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider
@@ -49,8 +47,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
default=serialize_everything))
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -58,8 +55,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
self.set_status(415)
self.write(
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -68,7 +64,7 @@ class APISpotHandler(tornado.web.RequestHandler):
post_data = self.request.body
if not post_data:
self.set_status(422)
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
self.write(safe_json_dumps("Error - request body is empty"))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -106,20 +102,10 @@ class APISpotHandler(tornado.web.RequestHandler):
# Convert spot field to a Spot object
spot = Spot(**spot_data)
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
# redo this in a functional style)
if spot.sig and spot.sig_refs:
real_sig_refs = []
for dict_obj in spot.sig_refs:
dict_obj = {**dict_obj, "sig": spot.sig}
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
spot.sig_refs = real_sig_refs
# Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422)
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
default=serialize_everything))
self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -127,15 +113,13 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -143,8 +127,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if frequency not in a known band
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422)
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
default=serialize_everything))
self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -154,8 +137,7 @@ class APISpotHandler(tornado.web.RequestHandler):
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper()):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
default=serialize_everything))
self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -164,9 +146,8 @@ class APISpotHandler(tornado.web.RequestHandler):
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
self.set_status(422)
self.write(json.dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
default=serialize_everything))
self.write(safe_json_dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -241,14 +222,14 @@ class APISpotHandler(tornado.web.RequestHandler):
self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything))
self.set_status(201)
else:
self.write(json.dumps("OK", default=serialize_everything))
self.write(safe_json_dumps("OK"))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Exception when handling client request to add spot API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
+6 -8
View File
@@ -1,5 +1,4 @@
import copy
import json
import logging
from datetime import datetime
from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue
from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100
@@ -57,15 +56,14 @@ class APIAlertsHandler(tornado.web.RequestHandler):
data = get_alert_list_with_filters(self._alerts, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything))
self.write(safe_json_dumps(data))
self.set_status(200)
except ValueError as e:
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.write(safe_json_dumps("Bad request - " + str(e)))
self.set_status(400)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Exception when handling client request to alerts API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
@@ -150,7 +148,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials:
alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials)
self.write_message(msg=json.dumps(alert, default=serialize_everything))
self.write_message(msg=safe_json_dumps(alert))
else:
# Send a keepalive comment if the queue was empty
+30 -22
View File
@@ -1,4 +1,5 @@
import json
import logging
from collections import Counter
from datetime import datetime, timedelta
from typing import Any
@@ -9,6 +10,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
@@ -29,29 +31,35 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
try:
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
for key in self._spots.iterkeys():
spot = self._spots.get(key)
if spot is None:
continue
if not spot.time or spot.time < one_hour_ago:
continue
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
for key in self._spots.iterkeys():
spot = self._spots.get(key)
if spot is None:
continue
if not spot.time or spot.time < one_hour_ago:
continue
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
result = {
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
for de in CONTINENTS
}
result = {
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
for de in CONTINENTS
}
self.write(json.dumps(result))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
self.write(json.dumps(result))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to dx stats API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+17 -21
View File
@@ -1,4 +1,3 @@
import json
import logging
import re
from datetime import datetime
@@ -13,7 +12,7 @@ from core.constants import SIGS
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef
from data.spot import Spot
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
}
self.write(json.dumps(data, default=serialize_everything))
self.write(safe_json_dumps(data))
else:
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
default=serialize_everything))
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
self.set_status(422)
else:
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
self.write(safe_json_dumps("Error - call must be provided"))
self.set_status(422)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Exception when handling client request to call lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
@@ -114,24 +112,22 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
if sig in list(map(lambda p: p.name.upper(), SIGS)):
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
self.write(json.dumps(data, default=serialize_everything))
self.write(safe_json_dumps(data))
else:
self.write(
json.dumps(
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
default=serialize_everything))
self.write(safe_json_dumps(
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
self.set_status(422)
else:
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
self.set_status(422)
else:
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
self.write(safe_json_dumps("Error - sig and id must be provided"))
self.set_status(422)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Exception when handling client request to sig ref lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"latitude": lat + lat_cell_size,
"longitude": lon + lon_cell_size,
}}
self.write(json.dumps(response, default=serialize_everything))
self.write(safe_json_dumps(response))
else:
self.write(json.dumps("Error - grid must be provided", default=serialize_everything))
self.write(safe_json_dumps("Error - grid must be provided"))
self.set_status(422)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Exception when handling client request to grid ref lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
+21 -15
View File
@@ -1,4 +1,4 @@
import json
import logging
from datetime import datetime
from typing import Any
@@ -10,7 +10,7 @@ from tornado.web import Application
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler):
@@ -28,19 +28,20 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._spot_providers = spot_providers or []
def get(self):
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
spot_submit_providers = {}
for provider in self._spot_providers:
if not provider.enabled:
continue
for sig in SIGS:
if provider.can_submit_spot(sig.name):
if provider.can_submit_spot(sig.name):
spot_submit_providers.setdefault(sig.name, []).append(provider.name)
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
@@ -51,8 +52,8 @@ class APIOptionsHandler(tornado.web.RequestHandler):
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our providers.
if ALLOW_SPOTTING:
spot_sources.append("API")
if ALLOW_SPOTTING:
spot_sources.append("API")
options = {"bands": BANDS,
"modes": ALL_MODES,
@@ -66,7 +67,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
self.write(json.dumps(options, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
self.write(safe_json_dumps(options))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to options API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+17 -9
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime
from typing import Any
@@ -7,6 +8,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
class APISolarConditionsHandler(tornado.web.RequestHandler):
@@ -22,13 +24,19 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(self._solar_conditions.to_json())
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
self.write(self._solar_conditions.to_json())
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to solar conditions API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+6 -8
View File
@@ -1,5 +1,4 @@
import copy
import json
import logging
from datetime import datetime, timedelta
from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue
from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
@@ -57,15 +56,14 @@ class APISpotsHandler(tornado.web.RequestHandler):
data = get_spot_list_with_filters(self._spots, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything))
self.write(safe_json_dumps(data))
self.set_status(200)
except ValueError as e:
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.write(safe_json_dumps("Bad request - " + str(e)))
self.set_status(400)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
@@ -152,7 +150,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials:
spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials)
self.write_message(msg=json.dumps(spot, default=serialize_everything))
self.write_message(msg=safe_json_dumps(spot))
else:
# Send a keepalive comment if the queue was empty
+17 -11
View File
@@ -1,4 +1,4 @@
import json
import logging
from datetime import datetime
from typing import Any
@@ -8,7 +8,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler):
@@ -24,13 +24,19 @@ class APIStatusHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(json.dumps(self._status_data, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
self.write(safe_json_dumps(self._status_data))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to status API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
+11
View File
@@ -0,0 +1,11 @@
import tornado.web
from core.config import BASE_URL
class ManifestHandler(tornado.web.RequestHandler):
"""Handler for manifest.webmanifest, which needs BASE_URL inserted and a custom content-type"""
def get(self):
self.set_header("Content-Type", "application/manifest+json; charset=UTF-8")
self.render("manifest.webmanifest", baseurl=BASE_URL)