Compare commits

..
15 Commits
Author SHA1 Message Date
Ian Renton c71c0e9d75 Release v2.0.2 2026-08-28 16:10:02 +01:00
Ian Renton f2ef6e93fa Merge remote-tracking branch 'origin/main'
# Conflicts:
#	core/constants.py
#	pyproject.toml
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-08-28 15:46:24 +01:00
Ian Renton d63897f8ad Provide CartoDB API key 2026-08-28 15:43:52 +01:00
Ian Renton 02e2730e47 v2.0.1 release 2026-08-28 07:59:10 +01:00
Ian Renton b2719d2cd6 Fix a bug where the default maximum spot age was not being applied on first access of the web UI 2026-08-28 07:52:15 +01:00
ian b9268dd4ca Update pyproject.toml 2026-08-25 20:42:07 +00:00
ian 0cce38b0ab Update core/constants.py 2026-08-25 20:41:53 +00:00
ian 5a77248dbc Fix a bug in calling the V1 add spot endpoint 2026-08-25 20:41:12 +00:00
Ian Renton 14e4886c1b v2.0 release 2026-08-22 08:11:03 +01:00
Ian Renton f346444ec3 Defensive coding improvements 2026-08-22 08:08:27 +01:00
Ian Renton 6f21ac83b7 Add timestamp to log output 2026-08-22 08:07:34 +01:00
Ian Renton a20c29cdd1 GMA Islands support 2026-08-22 08:04:24 +01:00
Ian Renton 1fd6611ac2 GMA Islands support 2026-08-22 08:02:57 +01:00
ian a27a04b65d Update templates/about.html 2026-08-20 22:36:18 +00:00
Ian Renton c5d6a65786 Bug fixes and minor doc clarifications 2026-08-20 07:30:21 +01:00
32 changed files with 97 additions and 70 deletions
+5 -3
View File
@@ -67,7 +67,7 @@ spot_providers:
- class: "DXCluster" - class: "DXCluster"
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to # Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
# several clusters and name them separately. # several clusters and name them separately. The name will be used as the "source" field for the spots received.
name: "HRD Cluster" name: "HRD Cluster"
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
@@ -88,7 +88,7 @@ spot_providers:
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 port: 7373
login_prompt: "Please enter your call:" login_prompt: "login:"
login_callsign: "N0CALL-99" login_callsign: "N0CALL-99"
allow_rbn_spots: false allow_rbn_spots: false
@@ -345,4 +345,6 @@ web_ui_options:
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This # (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it. # is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
# As the server owner you are responsible for the safe usage of this option! # As the server owner you are responsible for the safe usage of this option!
support_button_html: "" support_button_html: ""
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
cartodb_api_key: ""
+3 -2
View File
@@ -1,4 +1,5 @@
from core.constants import CALL_ONLY_PATTERN import re
from core.data_providers import DATA_PROVIDERS from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign from data.callsign import Callsign
@@ -11,7 +12,7 @@ def get_call_info(callsign, lookup_credentials):
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign)
# First check our input looks like a real callsign # First check our input looks like a real callsign
if callsign and CALL_ONLY_PATTERN.match(callsign): if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign):
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only # Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
# query other providers for data we are missing as we go along. # query other providers for data we are missing as we go along.
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p2: p2.priority): for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p2: p2.priority):
+7 -8
View File
@@ -1,21 +1,14 @@
import re
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.band import Band from data.band import Band
from data.sig import SIG from data.sig import SIG
# General software # General software
SOFTWARE_VERSION = "2.0-pre" SOFTWARE_VERSION = "2.0.2"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_") HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Generally useful regexes
CALL_REGEX = r"[A-Za-z0-9/\-]+"
CALL_PATTERN = re.compile(CALL_REGEX)
CALL_ONLY_PATTERN = re.compile(rf"^{CALL_REGEX}$")
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG( SIG(
@@ -60,6 +53,12 @@ SIGS = [
description="Islands on the Air", description="Islands on the Air",
ref_regex=r"[A-Z]{2}\-\d{3}", ref_regex=r"[A-Z]{2}\-\d{3}",
), ),
SIG(
name="GMA Island",
comment_names=[],
description="Global Mountain Activity - Islands",
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
),
SIG( SIG(
name="MOTA", name="MOTA",
comment_names=["MOTA"], comment_names=["MOTA"],
+14
View File
@@ -73,6 +73,20 @@ def get_sig_ref_info(sig, ref_id):
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}" sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
return sig_ref return sig_ref
elif sig.upper() == "GMA Islands":
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
# the best result.
iota_lookup = get_sig_ref_info("IOTA", ref_id)
gma_lookup = get_sig_ref_info("GMA", ref_id)
for key, value in iota_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
for key, value in gma_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
sig_ref.ref_type = "Island"
return sig_ref
### ACTUAL LOOKUP ### ### ACTUAL LOOKUP ###
# #
# OK, this is something we have to look up. Now check to see if our data store contains reference data and use # OK, this is something we have to look up. Now check to see if our data store contains reference data and use
+3 -3
View File
@@ -11,7 +11,7 @@ from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import CALL_REGEX, MODE_ALIASES, PROPAGATION_MODES from core.constants import MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import ( from core.sig_utils import (
@@ -201,14 +201,14 @@ class Spot:
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it. # If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code. # RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
if self.de_call == "RBNHOLE" and self.comment: if self.de_call == "RBNHOLE" and self.comment:
rbnhole_call_match = re.search(rf"\Wat ({CALL_REGEX})\W", self.comment, re.IGNORECASE) rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
if rbnhole_call_match: if rbnhole_call_match:
self.de_call = rbnhole_call_match.group(1).upper() self.de_call = rbnhole_call_match.group(1).upper()
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it. # If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code. # SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
if self.de_call == "SOTAMAT" and self.comment: if self.de_call == "SOTAMAT" and self.comment:
sotamat_call_match = re.search(rf"\Wfrom ({CALL_REGEX})]", self.comment, re.IGNORECASE) sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
if sotamat_call_match: if sotamat_call_match:
self.de_call = sotamat_call_match.group(1).upper() self.de_call = sotamat_call_match.group(1).upper()
+1 -2
View File
@@ -6,7 +6,6 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.constants import CALL_REGEX
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -16,7 +15,7 @@ class NG3K(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile(rf"as ({CALL_REGEX})", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
+4 -4
View File
@@ -8,7 +8,6 @@ import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -20,11 +19,11 @@ class DXCluster(SpotProvider):
See config-example.yml for examples.""" See config-example.yml for examples."""
_LINE_PATTERN_EXCLUDE_RBN = re.compile( _LINE_PATTERN_EXCLUDE_RBN = re.compile(
rf"^DX de ({CALL_REGEX}):\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)", r"^DX de ([a-z0-9/]+):\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
_LINE_PATTERN_ALLOW_RBN = re.compile( _LINE_PATTERN_ALLOW_RBN = re.compile(
rf"^DX de ({CALL_REGEX})-?#?:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)", r"^DX de ([a-z0-9/]+)-?#?:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -51,7 +50,8 @@ class DXCluster(SpotProvider):
def stop(self): def stop(self):
self._running = False self._running = False
self._telnet.close() if self._telnet:
self._telnet.close()
self._thread.join() self._thread.join()
def _handle(self): def _handle(self):
+3
View File
@@ -117,6 +117,9 @@ class GMA(HTTPSpotProvider):
case "IOTA Island": case "IOTA Island":
spot.sig_refs[0].sig = "IOTA" spot.sig_refs[0].sig = "IOTA"
spot.sig = "IOTA" spot.sig = "IOTA"
case "GMA Island":
spot.sig_refs[0].sig = "GMA Island"
spot.sig = "GMA Island"
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ILLW" spot.sig_refs[0].sig = "ILLW"
spot.sig = "ILLW" spot.sig = "ILLW"
+2 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime
import pytz import pytz
import requests import requests
from core.constants import HTTP_HEADERS, CALL_REGEX from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -60,7 +60,7 @@ class ParksNPeaks(HTTPSpotProvider):
) )
# Extract a de_call if it's in the comment but not in the "actSpoter" field # Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(rf"\(de ({CALL_REGEX})\)", spot.comment or "") m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment or "")
if not spot.de_call and m: if not spot.de_call and m:
spot.de_call = str(m.group(1)) spot.de_call = str(m.group(1))
+3 -3
View File
@@ -8,7 +8,6 @@ import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -20,7 +19,7 @@ class RBN(SpotProvider):
(port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor.""" (port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor."""
_LINE_PATTERN = re.compile( _LINE_PATTERN = re.compile(
rf"^DX de ({CALL_REGEX})-.*:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)", r"^DX de ([a-z0-9/]+)-.*:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -40,7 +39,8 @@ class RBN(SpotProvider):
def stop(self): def stop(self):
self._running = False self._running = False
self._telnet.close() if self._telnet:
self._telnet.close()
self._thread.join() self._thread.join()
def _handle(self): def _handle(self):
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "Spothole" name = "Spothole"
version = "2.0" version = "2.0.2"
authors = [ authors = [
{ name="Ian Renton", email="ian@ianrenton.com" }, { name="Ian Renton", email="ian@ianrenton.com" },
] ]
+3 -3
View File
@@ -11,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps from core.utils import infer_band_from_freq, safe_json_dumps
@@ -121,13 +121,13 @@ class APISpotHandler(tornado.web.RequestHandler):
return return
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not CALL_ONLY_PATTERN.match(spot.dx_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not CALL_ONLY_PATTERN.match(spot.de_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+2 -2
View File
@@ -82,8 +82,8 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
self._credentials = None self._credentials = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, _sse_alert_broadcaster, web_server_metrics): def initialize(self, sse_alert_broadcaster, web_server_metrics):
self._sse_alert_broadcaster = _sse_alert_broadcaster self._sse_alert_broadcaster = sse_alert_broadcaster
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def custom_headers(self): def custom_headers(self):
+2 -2
View File
@@ -9,7 +9,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.constants import CALL_ONLY_PATTERN, SIGS from core.constants import SIGS
from core.geo_utils import ( from core.geo_utils import (
lat_lon_for_grid_sw_corner_plus_size, lat_lon_for_grid_sw_corner_plus_size,
lat_lon_to_cq_zone, lat_lon_to_cq_zone,
@@ -55,7 +55,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
# The "call" query param must exist and look like a callsign # The "call" query param must exist and look like a callsign
if "call" in query_params: if "call" in query_params:
call = str(query_params.get("call")).upper() call = str(query_params.get("call")).upper()
if CALL_ONLY_PATTERN.match(call): if re.match(r"^[A-Z0-9/\-]*$", call):
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(self.request.headers)
callsign_data = get_call_info(call, credentials) callsign_data = get_call_info(call, credentials)
self.write(safe_json_dumps(callsign_data)) self.write(safe_json_dumps(callsign_data))
+3 -3
View File
@@ -9,7 +9,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.config import ALLOW_SPOTTING from core.config import ALLOW_SPOTTING
from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps from core.utils import infer_band_from_freq, safe_json_dumps
@@ -83,13 +83,13 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return return
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not CALL_ONLY_PATTERN.match(spot.dx_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not CALL_ONLY_PATTERN.match(spot.de_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+3 -3
View File
@@ -2,6 +2,7 @@ import tornado
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
from tornado.httputil import HTTPHeaders from tornado.httputil import HTTPHeaders
_LEGACY_PARAM_TO_HEADER_MAP = { _LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username", "qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password", "qrz_password": "X-QRZ-Password",
@@ -11,7 +12,6 @@ _LEGACY_PARAM_TO_HEADER_MAP = {
"hamqth_session_id": "X-HamQTH-Session-ID", "hamqth_session_id": "X-HamQTH-Session-ID",
} }
class V1RedirectHandler(tornado.web.RequestHandler): class V1RedirectHandler(tornado.web.RequestHandler):
"""Transparently proxies requests from the old API to the new one, """Transparently proxies requests from the old API to the new one,
returning whatever the v2 endpoint returns, for endpoints with no breaking changes.""" returning whatever the v2 endpoint returns, for endpoints with no breaking changes."""
@@ -34,7 +34,7 @@ class V1RedirectHandler(tornado.web.RequestHandler):
response = await client.fetch( response = await client.fetch(
new_url, new_url,
method=self.request.method, method=self.request.method,
headers=headers, headers=self.request.headers,
body=None if self.request.method == "GET" else (self.request.body or b""), body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False, raise_error=False,
follow_redirects=False, follow_redirects=False,
@@ -60,4 +60,4 @@ class V1RedirectHandler(tornado.web.RequestHandler):
await self._proxy(path) await self._proxy(path)
async def post(self, path): async def post(self, path):
await self._proxy(path) await self._proxy(path)
-1
View File
@@ -160,7 +160,6 @@ class WebServer:
V1APISpotHandler, V1APISpotHandler,
{ {
"spots": self._data_store.spots, "spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts, **handler_opts,
}, },
), ),
+1 -1
View File
@@ -33,7 +33,7 @@ if __name__ == "__main__":
root.setLevel(LOG_LEVEL) root.setLevel(LOG_LEVEL)
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
handler.setLevel(LOG_LEVEL) handler.setLevel(LOG_LEVEL)
formatter = logging.Formatter("%(levelname)s : %(message)s") formatter = logging.Formatter("%(asctime)s | %(levelname)7s | %(message)s", "%Y-%m-%d %H:%M:%S")
handler.setFormatter(formatter) handler.setFormatter(formatter)
root.handlers.clear() root.handlers.clear()
root.addHandler(handler) root.addHandler(handler)
+2 -1
View File
@@ -527,7 +527,8 @@ components:
in: query in: query
description: > description: >
Limit the spots to only ones from one or more sources. To select more than one source, supply a Limit the spots to only ones from one or more sources. To select more than one source, supply a
comma-separated list. comma-separated list. Note that this enumeration is not exhaustive; in some cases the server owner may have
named some sources like clusters differently, and provided multiple options.
schema: schema:
$ref: "#/components/schemas/Source" $ref: "#/components/schemas/Source"
SpotSig: SpotSig:
+6 -2
View File
@@ -394,9 +394,11 @@ function setBasemap(basemapname) {
} }
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied // OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname; const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
const isCartoDB = basemapname === "CartoDB.Voyager" || basemapname === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(providerName, { backgroundTileLayer = L.tileLayer.provider(providerName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
@@ -529,9 +531,11 @@ function setUpMap() {
// Add basemap // Add basemap
loadedBasemap = $("#basemap").val(); loadedBasemap = $("#basemap").val();
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap; const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
const isCartoDB = loadedBasemap === "CartoDB.Voyager" || loadedBasemap === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(initialProviderName, { backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
+2
View File
@@ -345,6 +345,7 @@ const SIG_ICONS = {
"SOTA": "fa-mountain-sun", "SOTA": "fa-mountain-sun",
"WWFF": "fa-seedling", "WWFF": "fa-seedling",
"GMA": "fa-person-hiking", "GMA": "fa-person-hiking",
"GMA Island": "fa-person-hiking",
"WWBOTA": "fa-radiation", "WWBOTA": "fa-radiation",
"HEMA": "fa-mound", "HEMA": "fa-mound",
"IOTA": "fa-book-atlas", "IOTA": "fa-book-atlas",
@@ -372,6 +373,7 @@ const SIG_NAMES = {
"SOTA": "Summits on the Air", "SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna", "WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity", "GMA": "Global Mountain Activity",
"GMA Island": "Global Mountain Activity - Islands",
"WWBOTA": "Bunkers on the Air", "WWBOTA": "Bunkers on the Air",
"HEMA": "Humps Excluding Marilyns Award", "HEMA": "Humps Excluding Marilyns Award",
"IOTA": "Islands on the Air", "IOTA": "Islands on the Air",
+3 -2
View File
@@ -801,12 +801,13 @@
} }
}, },
CartoDB: { CartoDB: {
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png', url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={key}',
options: { options: {
attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20, maxZoom: 20,
variant: 'light_all' variant: 'light_all',
key: '<insert your API key here>'
}, },
variants: { variants: {
Positron: 'light_all', Positron: 'light_all',
+4 -6
View File
@@ -98,7 +98,7 @@
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air <p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air
(POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide
Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos (MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on
@@ -171,10 +171,6 @@
section, Privacy, for details of how these are handled. If you are looking at the map and see lots of spots in section, Privacy, for details of how these are handled. If you are looking at the map and see lots of spots in
the geographic centre of countries, allowing these lookups will help. For QRZ.com a paid account is required to the geographic centre of countries, allowing these lookups will help. For QRZ.com a paid account is required to
look up operator locations.</p> look up operator locations.</p>
<p>IOTA in particular causes a mapping problem, firstly that there is no official set of geodata beyond the names of
islands. We also have no way of telling whether priority should be given to IOTA reference or e.g. QRZ lookup,
for example for a G callsign in EU-005 Great Britain the QRZ home address is probably more accurate, but in
EU-002 Aaland islands, it's more likely a DXpedition and the IOTA would be more accurate.</p>
<h2 id="privacy" class="mt-4">Privacy</h2> <h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from <p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from
the system after a set interval, which by default is one hour for spots and one week for alerts.</p> the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
@@ -182,11 +178,13 @@
filters. They are also stored in your browser's local storage, so that your preferences are remembered between filters. They are also stored in your browser's local storage, so that your preferences are remembered between
sessions.</p> sessions.</p>
<p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these <p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these
in the "Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these in the "Your Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these
services, which can for example provide more accurate markers on the map tab, and operator names when you mouse services, which can for example provide more accurate markers on the map tab, and operator names when you mouse
over a DX callsign. Spothole will still work fine if you don't provide these. The values you enter are sent to over a DX callsign. Spothole will still work fine if you don't provide these. The values you enter are sent to
Spothole via HTTPS so are protected in transit, though of course you do have to trust Spothole with this Spothole via HTTPS so are protected in transit, though of course you do have to trust Spothole with this
sensitive data in order to use this feature.</p> sensitive data in order to use this feature.</p>
<p>Any data you send as part of a query, such as your QRZ or HamQTH credentials, is used only for the lifetime of that
query and is not saved anywhere apart from your own device.</p>
<p>Spothole uses no trackers, no ads, and no cookies.</p> <p>Spothole uses no trackers, no ads, and no cookies.</p>
{% if len(web_ui_options["support_button_html"]) > 0 %} {% if len(web_ui_options["support_button_html"]) > 0 %}
<p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page. <p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page.
+1 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1787072396"></script> <script src="/static/js/add-spot.js?v=1787929803"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -84,7 +84,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1787072395"></script> <script src="/static/js/alerts.js?v=1787929803"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1787072395"></script> <script src="/static/js/spotsbandsandmap.js?v=1787929803"></script>
<script src="/static/js/bands.js?v=1787072395"></script> <script src="/static/js/bands.js?v=1787929803"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1787072395" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1787929803" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -15,10 +15,10 @@
window.fetchEventSource = fetchEventSource; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1787072395"></script> <script src="/static/js/utils.js?v=1787929803"></script>
<script src="/static/js/ui-ham.js?v=1787072395"></script> <script src="/static/js/ui-ham.js?v=1787929803"></script>
<script src="/static/js/geo.js?v=1787072395"></script> <script src="/static/js/geo.js?v=1787929803"></script>
<script src="/static/js/common.js?v=1787072395"></script> <script src="/static/js/common.js?v=1787929803"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -6,7 +6,7 @@
oninput="filtersUpdated();" style="width: 5em; display: inline-block;"> oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
{% for a in web_ui_options["max_spot_age"] %} {% for a in web_ui_options["max_spot_age"] %}
<option value="{{a*60}}" {% if web_ui_options[ <option value="{{a*60}}" {% if web_ui_options[
"max_spot_age_default"] == a*60 %}selected{% end %}>{{a}}</option> "max_spot_age_default"] == a %}selected{% end %}>{{a}}</option>
{% end %} {% end %}
</select> </select>
minutes minutes
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1787072395"></script> <script src="/static/js/conditions.js?v=1787929803"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+6 -2
View File
@@ -109,8 +109,12 @@
<script src="/static/vendor/js/leaflet-cqzones.js"></script> <script src="/static/vendor/js/leaflet-cqzones.js"></script>
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script> <script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
<script src="/static/js/spotsbandsandmap.js?v=1787072394"></script> <script>
<script src="/static/js/map.js?v=1787072394"></script> const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1787929802"></script>
<script src="/static/js/map.js?v=1787929802"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1787072394"></script> <script src="/static/js/spotsbandsandmap.js?v=1787929802"></script>
<script src="/static/js/spots.js?v=1787072394"></script> <script src="/static/js/spots.js?v=1787929802"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1787072395"></script> <script src="/static/js/status.js?v=1787929803"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");