Use ruff linter to fix issues and provide consistent formatting

This commit is contained in:
Ian Renton
2026-08-15 08:25:54 +01:00
parent 7391c28cd0
commit af3f82c14d
121 changed files with 1989 additions and 996 deletions
+39 -24
View File
@@ -14,8 +14,7 @@ from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRE
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq
from core.utils import safe_json_dumps
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider
@@ -25,7 +24,12 @@ RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
self._spot_providers = None
@@ -53,7 +57,7 @@ class APISpotHandler(tornado.web.RequestHandler):
return
# Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
if not self.request.headers.get("Content-Type", "").startswith("application/json"):
self.set_status(415)
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
self.set_header("Cache-Control", "no-store")
@@ -82,13 +86,10 @@ 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:
@@ -111,7 +112,8 @@ class APISpotHandler(tornado.web.RequestHandler):
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422)
self.write(
safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
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
@@ -133,29 +135,37 @@ 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(
safe_json_dumps(f"Error - Frequency of {spot.freq / 1000.0!s}kHz is not in a known band."))
self.write(safe_json_dumps(f"Error - Frequency of {spot.freq / 1000.0!s}kHz is not in a known band."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if grid formatting incorrect
if spot.dx_grid and not re.match(
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()):
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(
safe_json_dumps(f"Error - '{spot.dx_grid}' does not look like a valid Maidenhead grid."))
self.write(safe_json_dumps(f"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
# Reject if sig_ref format incorrect for sig
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):
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(safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."))
self.write(
safe_json_dumps(
f"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
@@ -185,7 +195,8 @@ class APISpotHandler(tornado.web.RequestHandler):
if not spot.dx_grid and upstream_provider_name == "Tiles":
self.set_status(422)
self.write(
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air."))
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.")
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -210,7 +221,9 @@ class APISpotHandler(tornado.web.RequestHandler):
upstream_warning = str(e)
except Exception:
logging.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."
upstream_warning = (
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."
@@ -250,10 +263,12 @@ class APISpotHandler(tornado.web.RequestHandler):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""
try:
response = requests.post(RECAPTCHA_VERIFY_URL,
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
timeout=(5, 10))
response = requests.post(
RECAPTCHA_VERIFY_URL,
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
timeout=(5, 10),
)
return response.ok and response.json().get("success", False)
except Exception:
logging.exception(f"reCAPTCHA verification request failed")
logging.exception("reCAPTCHA verification request failed")
return False
+19 -10
View File
@@ -17,7 +17,12 @@ from data.lookup_credentials import extract_credentials
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._alerts = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -82,8 +87,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def custom_headers(self):
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
return {"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
def open(self):
try:
@@ -142,10 +146,10 @@ def get_alert_list_with_filters(all_alerts, query):
a = all_alerts.get(k)
if a is not None:
alerts.append(a)
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))
if "limit" in query.keys():
alerts = alerts[:int(query.get("limit"))]
alerts = alerts[: int(query.get("limit"))]
return alerts
@@ -164,8 +168,11 @@ def alert_allowed_by_query(alert, query):
# 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
# the alert is a dxpedition, it also always passes the check.
if alert.is_dxpedition and (query.get(
"dxpeditions_skip_max_duration_check").upper() == "TRUE" if "dxpeditions_skip_max_duration_check" in query.keys() else False):
if alert.is_dxpedition and (
query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
if "dxpeditions_skip_max_duration_check" in query.keys()
else False
):
continue
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
return False
@@ -192,8 +199,10 @@ def alert_allowed_by_query(alert, query):
return False
case "text_includes":
text_includes = query.get(k).strip()
if (not alert.dx_call or text_includes.upper() not in alert.dx_call.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()):
if (
(not alert.dx_call or text_includes.upper() not in alert.dx_call.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())
):
return False
return True
+12 -4
View File
@@ -21,7 +21,12 @@ BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -46,12 +51,15 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
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:
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
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS} for de in CONTINENTS
}
self.write(json.dumps(result))
+32 -11
View File
@@ -10,7 +10,11 @@ from tornado.web import Application
from core.call_lookup_helper import get_call_info
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.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_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import get_ref_regex_for_sig
@@ -22,7 +26,12 @@ from data.sig_ref import SIGRef
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -42,7 +51,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# The "call" query param must exist and look like a callsign
if "call" in query_params.keys():
if "call" in query_params:
call = str(query_params.get("call")).upper()
if re.match(r"^[A-Z0-9/\-]*$", call):
credentials = extract_credentials(self.request.headers)
@@ -68,7 +77,12 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupSIGRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/sigref"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -89,7 +103,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
# "sig" and "id" query params must exist, SIG must be known, and if we have a reference regex for that SIG,
# the provided id must match it.
if "sig" in query_params.keys() and "id" in query_params.keys():
if "sig" in query_params and "id" in query_params:
sig = str(query_params.get("sig")).upper()
ref_id = str(query_params.get("id")).upper()
if sig in list(map(lambda p: p.name.upper(), SIGS)):
@@ -98,8 +112,9 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
self.write(safe_json_dumps(data))
else:
self.write(safe_json_dumps(
f"Error - '{ref_id}' does not look like a valid reference ID for {sig}."))
self.write(
safe_json_dumps(f"Error - '{ref_id}' does not look like a valid reference ID for {sig}.")
)
self.set_status(422)
else:
self.write(safe_json_dumps(f"Error - sig '{sig}' is not known."))
@@ -120,7 +135,12 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/grid"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -140,7 +160,7 @@ class APILookupGridHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# "grid" query param must exist.
if "grid" in query_params.keys():
if "grid" in query_params:
grid = str(query_params.get("grid")).upper()
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
if lat is not None and lon is not None and lat_cell_size is not None and lon_cell_size is not None:
@@ -154,7 +174,7 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"latitude": center_lat,
"longitude": center_lon,
"cq_zone": center_cq_zone,
"itu_zone": center_itu_zone
"itu_zone": center_itu_zone,
},
"southwest": {
"latitude": lat,
@@ -163,7 +183,8 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"northeast": {
"latitude": lat + lat_cell_size,
"longitude": lon + lon_cell_size,
}}
},
}
self.write(safe_json_dumps(response))
else:
+56 -20
View File
@@ -7,8 +7,15 @@ import tornado
from tornado import httputil
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.config import ALLOW_SPOTTING, MAX_SPOT_AGE
from core.constants import (
ALL_MODES,
BANDS,
CONTINENTS,
MODE_TYPES,
PROPAGATION_MODES,
SIGS,
)
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
@@ -16,7 +23,12 @@ from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._status_data = None
self._web_server_metrics = None
self._spot_providers = None
@@ -49,13 +61,35 @@ class APIOptionsHandler(tornado.web.RequestHandler):
# 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.
spot_providers: list = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"])))
map(
lambda p: p["name"],
filter(lambda p: p["enabled"], self._status_data["spot_providers"]),
)
)
alert_providers = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
map(
lambda p: p["name"],
filter(lambda p: p["enabled"], self._status_data["alert_providers"]),
)
)
callsign_data_providers = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"])))
map(
lambda p: p["name"],
filter(
lambda p: p["enabled"],
self._status_data["callsign_data_providers"],
),
)
)
spot_providers_enabled_by_default = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"] and p["enabled_by_default_in_web_ui"], self._status_data["spot_providers"])))
map(
lambda p: p["name"],
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
# one of our providers.
@@ -63,19 +97,21 @@ class APIOptionsHandler(tornado.web.RequestHandler):
spot_providers.append("API")
spot_providers_enabled_by_default.append("API")
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
"spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers,
"callsign_data_providers": callsign_data_providers,
"continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
options = {
"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
"spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers,
"callsign_data_providers": callsign_data_providers,
"continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers,
}
self.write(safe_json_dumps(options))
self.set_status(200)
+6 -1
View File
@@ -14,7 +14,12 @@ from core.utils import safe_json_dumps
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._solar_conditions = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
+12 -7
View File
@@ -17,7 +17,12 @@ from data.lookup_credentials import extract_credentials
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -82,8 +87,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def custom_headers(self):
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
return {"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
def open(self):
"""Called once on the client opening a connection, set things up"""
@@ -145,10 +149,10 @@ def get_spot_list_with_filters(all_spots, query):
s = all_spots.get(k)
if s is not None:
spots.append(s)
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))
if "limit" in query.keys():
spots = spots[:int(query.get("limit"))]
spots = spots[: int(query.get("limit"))]
# 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
@@ -245,8 +249,9 @@ def spot_allowed_by_query(spot, query):
return False
case "text_includes":
text_includes = query.get(k).strip()
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()):
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()
):
return False
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
+6 -1
View File
@@ -14,7 +14,12 @@ from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._status_data = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
+27 -10
View File
@@ -12,14 +12,19 @@ from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps, infer_band_from_freq
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in early Spothole v2 for backwards compatibility."""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -45,7 +50,7 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return
# Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
if not self.request.headers.get("Content-Type", "").startswith("application/json"):
self.set_status(415)
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
self.set_header("Cache-Control", "no-store")
@@ -68,7 +73,9 @@ class V1APISpotHandler(tornado.web.RequestHandler):
# 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(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
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
@@ -97,8 +104,9 @@ class V1APISpotHandler(tornado.web.RequestHandler):
# Reject if grid formatting incorrect
if spot.dx_grid and not re.match(
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()):
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(safe_json_dumps(f"Error - '{spot.dx_grid}' does not look like a valid Maidenhead grid."))
self.set_header("Cache-Control", "no-store")
@@ -106,11 +114,20 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return
# Reject if sig_ref format incorrect for sig
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):
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(safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}."))
self.write(
safe_json_dumps(
f"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
+6 -2
View File
@@ -35,7 +35,11 @@ class V1RedirectHandler(tornado.web.RequestHandler):
if isinstance(response.headers, HTTPHeaders):
for name, value in response.headers.get_all():
# Let Tornado recompute these for the outgoing response
if name.lower() not in ("content-length", "transfer-encoding", "connection"):
if name.lower() not in (
"content-length",
"transfer-encoding",
"connection",
):
self.add_header(name, value)
if response.body:
self.write(response.body)
@@ -54,4 +58,4 @@ class V1RedirectHandler(tornado.web.RequestHandler):
await self._proxy(path)
async def patch(self, path):
await self._proxy(path)
await self._proxy(path)
+2 -1
View File
@@ -13,6 +13,7 @@ class V1APISpotsHandler(APISpotsHandler):
chunk = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', chunk)
super().write(chunk)
class V1APISpotsStreamHandler(APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in early Spothole v2 for backwards compatibility."""
@@ -24,4 +25,4 @@ class V1APISpotsStreamHandler(APISpotsStreamHandler):
for k, v in kwargs.items():
if isinstance(v, str) and '"dx_location_source"' in v:
kwargs[k] = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', v)
super().write_message(*args, **kwargs)
super().write_message(*args, **kwargs)
+1 -1
View File
@@ -10,4 +10,4 @@ class PrometheusMetricsHandler(tornado.web.RequestHandler):
def get(self):
self.write(get_metrics())
self.set_status(200)
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
self.set_header("Content-Type", CONTENT_TYPE_LATEST)
+16 -5
View File
@@ -6,7 +6,7 @@ import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING, WEB_UI_OPTIONS, BASE_URL, SERVER_OWNER_CALLSIGN
from core.config import ALLOW_SPOTTING, BASE_URL, SERVER_OWNER_CALLSIGN, WEB_UI_OPTIONS
from core.constants import SOFTWARE_VERSION
from core.prometheus_metrics_handler import page_requests_counter
@@ -14,7 +14,12 @@ from core.prometheus_metrics_handler import page_requests_counter
class PageTemplateHandler(tornado.web.RequestHandler):
"""Handler for all HTML pages generated from templates"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._template_name = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
@@ -31,6 +36,12 @@ class PageTemplateHandler(tornado.web.RequestHandler):
page_requests_counter.inc()
# Load named template, and provide variables used in templates
self.render(f"{self._template_name}.html", software_version=SOFTWARE_VERSION,
server_owner_callsign=SERVER_OWNER_CALLSIGN, allow_spotting=ALLOW_SPOTTING,
web_ui_options=WEB_UI_OPTIONS, baseurl=BASE_URL, current_path=self.request.path)
self.render(
f"{self._template_name}.html",
software_version=SOFTWARE_VERSION,
server_owner_callsign=SERVER_OWNER_CALLSIGN,
allow_spotting=ALLOW_SPOTTING,
web_ui_options=WEB_UI_OPTIONS,
baseurl=BASE_URL,
current_path=self.request.path,
)
+2 -2
View File
@@ -25,7 +25,7 @@ class SSEBroadcaster:
self._handlers.discard(handler)
def publish(self, value):
self._loop.add_callback(self._broadcast, value)
self._loop.add_callback(self._broadcast, value)
def _broadcast(self, value):
with self._lock:
@@ -36,4 +36,4 @@ class SSEBroadcaster:
except Exception:
# Connection probably dropped, ignore and de-register the handler to stop getting future items.
logging.debug("Failed to push to an SSE client; dropping it")
self.unregister(handler)
self.unregister(handler)
+131 -36
View File
@@ -5,13 +5,23 @@ import os
import tornado
from tornado.web import StaticFileHandler
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
from core.config import (
ALLOW_SPOTTING,
API_ONLY_MODE,
BASE_URL,
LOG_WEB_REQUESTS,
WEB_SERVER_PORT,
)
from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from server.handlers.api.addspot import APISpotHandler
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from server.handlers.api.dxstats import APIDxStatsHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
from server.handlers.api.lookups import (
APILookupCallHandler,
APILookupGridHandler,
APILookupSIGRefHandler,
)
from server.handlers.api.options import APIOptionsHandler
from server.handlers.api.solar_conditions import APISolarConditionsHandler
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
@@ -45,7 +55,7 @@ class WebServer:
"last_api_access_time": None,
"page_access_counter": 0,
"api_access_counter": 0,
"status": "Starting"
"status": "Starting",
}
def setup(self):
@@ -76,30 +86,73 @@ class WebServer:
# API endpoints are always enabled
api_routes = [
(r"/api/v2/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v2/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}),
(r"/api/v2/spots/stream", APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
(r"/api/v2/alerts/stream", APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
(r"/api/v2/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions,
**handler_opts}),
(r"/api/v2/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v2/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
(r"/api/v2/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
(
r"/api/v2/spots",
APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v2/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts, **handler_opts},
),
(
r"/api/v2/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
),
(
r"/api/v2/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts},
),
(
r"/api/v2/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions, **handler_opts},
),
(
r"/api/v2/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v2/options",
APIOptionsHandler,
{"status_data": self._data_store.status_data, **handler_opts},
),
(
r"/api/v2/status",
APIStatusHandler,
{"status_data": self._data_store.status_data, **handler_opts},
),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v2/spot", APISpotHandler,
{"spots": self._data_store.spots, "spot_providers": self._data_providers, **handler_opts}),
(
r"/api/v2/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts,
},
),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
# that have the major breaking changes get a bespoke handler.
v1_compat_routes = [
(r"/api/v1/spots", V1APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v1/spots/stream", V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
),
(r"/api/v1/spot", V1APISpotHandler),
(r"/api/v1/(.*)", V1RedirectHandler),
]
@@ -108,38 +161,82 @@ class WebServer:
if self._api_only_mode:
logging.info("API-only mode is enabled. Web UI will not be served.")
ui_routes = [
(r"/", PageTemplateHandler, {"template_name": "api_only_home", **handler_opts})
(
r"/",
PageTemplateHandler,
{"template_name": "api_only_home", **handler_opts},
)
]
else:
ui_routes = [
(r"/", PageTemplateHandler, {"template_name": "spots", **handler_opts}),
(r"/map", PageTemplateHandler, {"template_name": "map", **handler_opts}),
(r"/bands", PageTemplateHandler, {"template_name": "bands", **handler_opts}),
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", **handler_opts}),
(r"/conditions", PageTemplateHandler, {"template_name": "conditions", **handler_opts}),
(r"/status", PageTemplateHandler, {"template_name": "status", **handler_opts}),
(r"/about", PageTemplateHandler, {"template_name": "about", **handler_opts})
(
r"/map",
PageTemplateHandler,
{"template_name": "map", **handler_opts},
),
(
r"/bands",
PageTemplateHandler,
{"template_name": "bands", **handler_opts},
),
(
r"/alerts",
PageTemplateHandler,
{"template_name": "alerts", **handler_opts},
),
(
r"/conditions",
PageTemplateHandler,
{"template_name": "conditions", **handler_opts},
),
(
r"/status",
PageTemplateHandler,
{"template_name": "status", **handler_opts},
),
(
r"/about",
PageTemplateHandler,
{"template_name": "about", **handler_opts},
),
]
# Only allow the Add Spot page if spotting is allowed
if ALLOW_SPOTTING:
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
ui_routes += [
(
r"/add-spot",
PageTemplateHandler,
{"template_name": "add_spot", **handler_opts},
)
]
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
# mode.
misc_routes = [
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
(
r"/apidocs",
PageTemplateHandler,
{"template_name": "apidocs", **handler_opts},
),
(r"/metrics", PrometheusMetricsHandler),
(r"/manifest.webmanifest", ManifestHandler),
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
# testing.
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
(
r"/static/(.*)",
StaticFileHandler,
{"path": os.path.join(_HERE, "../static")},
),
]
app = tornado.web.Application(api_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False)
app = tornado.web.Application(
api_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False,
)
app.listen(self._port, xheaders=True)
logging.info(f"Web server running on port {WEB_SERVER_PORT!s}")
logging.info(f"You can access your copy of Spothole at {BASE_URL}")
@@ -162,9 +259,7 @@ def request_log(handler):
user_agent = request.headers.get("User-Agent", "-")
log_method(
f'{client_ip} - "{request.method} {request.uri}" '
f'{handler.get_status()} {request.request_time():.2f}ms | '
f'Ref: {referrer} | UA: {user_agent}'
f'{client_ip} - "{request.method} {request.uri}" {handler.get_status()} {request.request_time():.2f}ms | Ref: {referrer} | UA: {user_agent}'
)