diff --git a/config-example.yml b/config-example.yml index 0c29540..d62dfa5 100644 --- a/config-example.yml +++ b/config-example.yml @@ -212,6 +212,20 @@ clublog-api-key: "" # Allow submitting spots to the Spothole API? allow-spotting: true +# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API? +# Requires allow-spotting to also be true. Set to false to only accept spots into the local +# Spothole database, without forwarding them to any external service. +allow-upstream-spotting: true + +# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA. +# Leave both empty to disable CAPTCHA (e.g. for a private/trusted server) or if allow-spotting is false, in which case +# they will do nothing. Note that with CAPTCHA enabled, this will prevent third-party clients submitting spots through +# Spothole unless the clients are web-based, use the same site key, have their domains enabled in your reCAPTCHA config, +# and of course their user solves the CAPTCHA. +# You can sign up for reCAPTCHA at https://www.google.com/recaptcha/ +recaptcha-site-key: "" +recaptcha-secret-key: "" + # Options for the web UI. web-ui-options: spot-count: [10, 25, 50, 100] diff --git a/core/config.py b/core/config.py index 70adc18..ac6469a 100644 --- a/core/config.py +++ b/core/config.py @@ -14,20 +14,27 @@ with open("config.yml") as f: config = yaml.safe_load(f) logging.info("Loaded config.") +# TODO load other keys with config.get(key, default) instead of config[key] BASE_URL = config["base-url"] MAX_SPOT_AGE = config["max-spot-age-sec"] MAX_ALERT_AGE = config["max-alert-age-sec"] SERVER_OWNER_CALLSIGN = config["server-owner-callsign"] WEB_SERVER_PORT = config["web-server-port"] ALLOW_SPOTTING = config["allow-spotting"] +ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True) WEB_UI_OPTIONS = config["web-ui-options"] API_ONLY_MODE = config.get("api-only-mode", False) +RECAPTCHA_SECRET_KEY = config.get("recaptcha-secret-key", "") +RECAPTCHA_SITE_KEY = config.get("recaptcha-site-key", "") # For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI # but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI. WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and ( "enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])] # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from -# one of our proviers. We set that to also be enabled by default. +# one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI +# can access it. if ALLOW_SPOTTING: WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API") + WEB_UI_OPTIONS["recaptcha-site-key"] = RECAPTCHA_SITE_KEY +WEB_UI_OPTIONS["allow-upstream-spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING diff --git a/core/constants.py b/core/constants.py index ae8a00a..71ecd5e 100644 --- a/core/constants.py +++ b/core/constants.py @@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C # Special Interest Groups SIGS = [ - SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"), + SIG(name="POTA", description="Parks on the Air", ref_regex=r"([A-Z]{2}\-\d{4,5}|K\-TEST)"), SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), @@ -37,10 +37,12 @@ SIGS = [ # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". CW_MODES = ["CW"] -PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"] +PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"] ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES MODE_TYPES = ["CW", "PHONE", "DATA"] +SSB_SUB_MODES = ["USB", "LSB"] +DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"] # Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode # we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots diff --git a/server/handlers/api/addspot.py b/server/handlers/api/addspot.py index 1f82b92..95509cd 100644 --- a/server/handlers/api/addspot.py +++ b/server/handlers/api/addspot.py @@ -4,9 +4,10 @@ import re from datetime import datetime import pytz +import requests import tornado -from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE +from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, MAX_SPOT_AGE, RECAPTCHA_SECRET_KEY from core.constants import UNKNOWN_BAND from core.lookup_helper import infer_band_from_freq from core.prometheus_metrics_handler import api_requests_counter @@ -15,13 +16,16 @@ from core.utils import serialize_everything from data.sig_ref import SIGRef from data.spot import Spot +RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" + class APISpotHandler(tornado.web.RequestHandler): """API request handler for /api/v1/spot (POST)""" - def initialize(self, spots, web_server_metrics): + def initialize(self, spots, web_server_metrics, spot_providers=None): self._spots = spots self._web_server_metrics = web_server_metrics + self._spot_providers = spot_providers or [] def post(self): try: @@ -58,15 +62,43 @@ class APISpotHandler(tornado.web.RequestHandler): self.set_header("Content-Type", "application/json") return - # Read in the request body as JSON then convert to a Spot object - json_spot = tornado.escape.json_decode(post_data) - spot = Spot(**json_spot) + # Read in the request body as JSON + json_body = tornado.escape.json_decode(post_data) + + # Extract fields relating to how we handle the spot, such as CAPTCHA and upstream submission. Remove these + # from the data so they don't accidentally end up in the spot object itself. + # todo: Better way of separating these out. Possible without API change or not? + submit_upstream = json_body.pop("submit_upstream", False) + upstream_provider_name = json_body.pop("upstream_provider", None) + upstream_credentials = json_body.pop("upstream_credentials", {}) + captcha_token = json_body.pop("captcha_token", None) + + # Verify CAPTCHA if required + if RECAPTCHA_SECRET_KEY: + if not captcha_token: + self.set_status(422) + self.write(json.dumps("Error - CAPTCHA token is required for spot submission.", + default=serialize_everything)) + self.set_header("Cache-Control", "no-store") + self.set_header("Content-Type", "application/json") + return + if not self._verify_recaptcha(captcha_token): + self.set_status(422) + self.write(json.dumps("Error - CAPTCHA verification failed.", + default=serialize_everything)) + self.set_header("Cache-Control", "no-store") + self.set_header("Content-Type", "application/json") + return + + # Convert remaining fields to a Spot object + spot = Spot(**json_body) # 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_refs: + 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 @@ -126,13 +158,47 @@ class APISpotHandler(tornado.web.RequestHandler): self.set_header("Content-Type", "application/json") return - # infer missing data, and add it to our database. - spot.source = "API" - spot.infer_missing() - self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) + # Reject upstream submission if not permitted + if submit_upstream and not ALLOW_UPSTREAM_SPOTTING: + self.set_status(403) + self.write(json.dumps("Error - this server does not allow upstream spot submission.", + default=serialize_everything)) + self.set_header("Cache-Control", "no-store") + self.set_header("Content-Type", "application/json") + return - self.write(json.dumps("OK", default=serialize_everything)) - self.set_status(201) + # Submit upstream if requested + upstream_warning = None + if submit_upstream and upstream_provider_name and spot.sig: + provider = self._find_provider(upstream_provider_name, spot.sig) + if provider: + try: + # Submit spot to the upstream provider + provider.submit_spot(spot, upstream_credentials) + # Trigger an immediate re-poll so the spot appears quickly + provider.force_poll() + except NotImplementedError as e: + upstream_warning = str(e) + except Exception as e: + logging.warning("Failed to submit spot upstream to " + upstream_provider_name + ": " + str(e)) + upstream_warning = "Spot was saved locally but upstream submission to " + upstream_provider_name + " failed: " + str( + e) + else: + upstream_warning = "No enabled provider named '" + upstream_provider_name + "' supports upstream submission for " + spot.sig + " spots." + + # If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a + # duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or + # we were but it failed, we should still add it to our database anyway. + if not submit_upstream or upstream_warning: + spot.infer_missing() + self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) + + if upstream_warning: + self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything)) + self.set_status(201) + else: + self.write(json.dumps("OK", default=serialize_everything)) + self.set_status(201) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") @@ -142,3 +208,23 @@ class APISpotHandler(tornado.web.RequestHandler): self.set_status(500) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") + + def _find_provider(self, provider_name, sig): + """Find an enabled provider by name that can submit spots for the given SIG.""" + + for p in self._spot_providers: + if p.enabled and p.name == provider_name and p.can_submit_spot(sig): + return p + return None + + def _verify_recaptcha(self, token): + """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)) + return response.ok and response.json().get("success", False) + except Exception as e: + logging.warning("reCAPTCHA verification request failed: " + str(e)) + return False diff --git a/server/handlers/api/options.py b/server/handlers/api/options.py index b442a5c..c630c1f 100644 --- a/server/handlers/api/options.py +++ b/server/handlers/api/options.py @@ -13,9 +13,10 @@ from core.utils import serialize_everything class APIOptionsHandler(tornado.web.RequestHandler): """API request handler for /api/v1/options""" - def initialize(self, status_data, web_server_metrics): + def initialize(self, status_data, web_server_metrics, spot_providers=None): self._status_data = status_data self._web_server_metrics = web_server_metrics + self._spot_providers = spot_providers or [] def get(self): # Metrics @@ -24,6 +25,15 @@ class APIOptionsHandler(tornado.web.RequestHandler): 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 + 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): + spot_submit_providers.setdefault(sig.name, []).append(provider.name) + options = {"bands": BANDS, "modes": ALL_MODES, "mode_types": MODE_TYPES, @@ -35,7 +45,8 @@ class APIOptionsHandler(tornado.web.RequestHandler): map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))), "continents": CONTINENTS, "max_spot_age": MAX_SPOT_AGE, - "spot_allowed": ALLOW_SPOTTING} + "spot_allowed": ALLOW_SPOTTING, + "spot_submit_providers": spot_submit_providers} # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from # one of our proviers. if ALLOW_SPOTTING: diff --git a/server/webserver.py b/server/webserver.py index d204fb6..a9c71a5 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -22,7 +22,7 @@ from server.handlers.pagetemplate import PageTemplateHandler class WebServer: """Provides the public-facing web server.""" - def __init__(self, spots, alerts, solar_conditions, status_data): + def __init__(self, spots, alerts, solar_conditions, status_data, spot_providers=None): """Constructor""" self._spots = spots @@ -31,6 +31,7 @@ class WebServer: self._sse_spot_queues = [] self._sse_alert_queues = [] self._status_data = status_data + self._spot_providers = spot_providers or [] self._port = WEB_SERVER_PORT self._api_only_mode = API_ONLY_MODE self._shutdown_event = asyncio.Event() @@ -69,12 +70,12 @@ class WebServer: {"sse_alert_queues": self._sse_alert_queues, **handler_opts}), (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}), (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}), - (r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, **handler_opts}), + (r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}), (r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}), (r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}), (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), (r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}), - (r"/api/v1/spot", APISpotHandler, {"spots": self._spots, **handler_opts}), + (r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}), ] # If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes diff --git a/spothole.py b/spothole.py index f5455be..e586866 100644 --- a/spothole.py +++ b/spothole.py @@ -98,18 +98,21 @@ if __name__ == '__main__': # Set up lookup helper lookup_helper.start() - # Set up web server - web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data) - - # Fetch, set up and start spot providers + # Create spot providers for entry in config["spot-providers"]: spot_providers.append(get_spot_provider_from_config(entry)) + + # Set up web server + web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data, + spot_providers=spot_providers) + + # Set up and start spot providers for p in spot_providers: p.setup(spots=spots, web_server=web_server) if p.enabled: p.start() - # Fetch, set up and start alert providers + # Create, set up and start alert providers for entry in config["alert-providers"]: alert_providers.append(get_alert_provider_from_config(entry)) for p in alert_providers: @@ -117,7 +120,7 @@ if __name__ == '__main__': if p.enabled: p.start() - # Fetch, set up and start solar conditions providers + # Create, set up and start solar conditions providers for entry in config.get("solar-condition-providers", []): solar_condition_providers.append(get_solar_conditions_provider_from_config(entry)) for p in solar_condition_providers: diff --git a/spotproviders/gma.py b/spotproviders/gma.py index 732c8cd..281b4c0 100644 --- a/spotproviders/gma.py +++ b/spotproviders/gma.py @@ -89,3 +89,11 @@ class GMA(HTTPSpotProvider): logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[ "REF"] + ", ignoring this spot for now") return new_spots + + def can_submit_spot(self, sig): + return sig == "GMA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. + # Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!) + raise NotImplementedError("GMA upstream spot submission is not yet implemented") diff --git a/spotproviders/hema.py b/spotproviders/hema.py index cad3167..6fe7f37 100644 --- a/spotproviders/hema.py +++ b/spotproviders/hema.py @@ -64,3 +64,10 @@ class HEMA(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "HEMA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Spotting to HEMA is covered in the original email from the team. + raise NotImplementedError("HEMA upstream spot submission is not yet implemented") diff --git a/spotproviders/http_spot_provider.py b/spotproviders/http_spot_provider.py index 39b052d..335cfde 100644 --- a/spotproviders/http_spot_provider.py +++ b/spotproviders/http_spot_provider.py @@ -19,6 +19,7 @@ class HTTPSpotProvider(SpotProvider): self._poll_interval = poll_interval self._thread = None self._stop_event = Event() + self._wakeup_event = Event() def start(self): # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between @@ -29,11 +30,19 @@ class HTTPSpotProvider(SpotProvider): def stop(self): self._stop_event.set() + self._wakeup_event.set() + + def force_poll(self): + """Trigger an immediate poll without waiting for the normal interval.""" + + self._wakeup_event.set() def _run(self): while True: + self._wakeup_event.clear() self._poll() - if self._stop_event.wait(timeout=self._poll_interval): + self._wakeup_event.wait(timeout=self._poll_interval) + if self._stop_event.is_set(): break def _poll(self): diff --git a/spotproviders/parksnpeaks.py b/spotproviders/parksnpeaks.py index ae63201..26bc707 100644 --- a/spotproviders/parksnpeaks.py +++ b/spotproviders/parksnpeaks.py @@ -3,7 +3,9 @@ import re from datetime import datetime import pytz +import requests +from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider @@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider): POLL_INTERVAL_SEC = 120 SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" + SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/" SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv" + SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"] def __init__(self, provider_config): super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) @@ -62,3 +66,27 @@ class ParksNPeaks(HTTPSpotProvider): # Add new spot to the list new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig in self.SUBMITTABLE_SIGS + + def submit_spot(self, spot, credentials): + # TODO test this works + user_id = credentials.get("user_id", "") + api_key = credentials.get("api_key", "") + if not user_id or not api_key: + raise ValueError("Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.") + sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" + body = { + "actClass": spot.sig or "", + "actCallsign": spot.dx_call, + "actSite": sig_ref, + "mode": spot.mode or "", + "freq": str(spot.freq / 1000000.0), + "comments": spot.comment or "", + "userID": user_id, + "APIKey": api_key, + } + response = requests.post(self.SUBMIT_URL, json=body, headers=HTTP_HEADERS, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("Parks N Peaks API returned " + str(response.status_code) + ": " + response.text) diff --git a/spotproviders/pota.py b/spotproviders/pota.py index e3eafbf..9fc3191 100644 --- a/spotproviders/pota.py +++ b/spotproviders/pota.py @@ -1,7 +1,9 @@ from datetime import datetime import pytz +import requests +from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider @@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider): POLL_INTERVAL_SEC = 120 SPOTS_URL = "https://api.pota.app/spot/activator" + SUBMIT_URL = "https://api.pota.app/spot" def __init__(self, provider_config): super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) @@ -40,3 +43,25 @@ class POTA(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "POTA" + + def submit_spot(self, spot, credentials): + sig_ref = spot.sig_refs[0].id if spot.sig_refs else None + if sig_ref: + body = { + "activator": spot.dx_call, + "spotter": spot.de_call, + "frequency": str(spot.freq / 1000.0), + "mode": spot.mode or "", + "reference": sig_ref, + "comments": spot.comment or "", + "source": "Spothole", + } + headers = {**HTTP_HEADERS, "Content-Type": "application/json"} + response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("POTA API returned " + str(response.status_code) + ": " + response.text) + else: + raise RuntimeError("Park reference is required for submitting POTA spots.") diff --git a/spotproviders/sota.py b/spotproviders/sota.py index ecddf92..f34d69d 100644 --- a/spotproviders/sota.py +++ b/spotproviders/sota.py @@ -2,7 +2,7 @@ from datetime import datetime import requests -from core.constants import HTTP_HEADERS +from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES from data.sig_ref import SIGRef from data.spot import Spot from spotproviders.http_spot_provider import HTTPSpotProvider @@ -20,6 +20,10 @@ class SOTA(HTTPSpotProvider): # SOTA spots don't contain lat/lon, we need a separate lookup for that SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/" + SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots" + + VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"] + def __init__(self, provider_config): super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) self._api_epoch = "" @@ -56,3 +60,44 @@ class SOTA(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "SOTA" + + def submit_spot(self, spot, credentials): + # TODO test this method works + access_token = credentials.get("access_token", "") + id_token = credentials.get("id_token", "") + if not access_token or not id_token: + raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.") + sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" + if sig_ref: + # Split reference into association and summit codes + ref_split = sig_ref.split("/") + + # Figure out a valid mode. Borrowed this from PoLo :) + # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js + if spot.mode and spot.mode not in self.VALID_MODES: + if spot.mode in SSB_SUB_MODES: + spot.mode = "SSB" + elif spot.mode in DV_SUB_MODES: + spot.mode = "DV" + else: + spot.mode = "Data" + + body = { + "activatorCallsign": spot.dx_call, + "associationCode": ref_split[0], + "summitCode": ref_split[1], + "frequency": str(spot.freq / 1000000.0), + "mode": spot.mode or "", + "posterCallsign": spot.de_call, + "comments": spot.comment or "", + "type": "TEST" # todo remove once testing complete + } + headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token, "Content-Type": "application/json"} + response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text) + else: + raise RuntimeError("Summit reference is required for submitting SOTA spots.") diff --git a/spotproviders/spot_provider.py b/spotproviders/spot_provider.py index 7647d6f..7be396a 100644 --- a/spotproviders/spot_provider.py +++ b/spotproviders/spot_provider.py @@ -68,3 +68,20 @@ class SpotProvider: """Stop any threads and prepare for application shutdown""" raise NotImplementedError("Subclasses must implement this method") + + def can_submit_spot(self, sig): + """Return True if this provider supports submitting spots upstream for the given SIG.""" + + return False + + def submit_spot(self, spot, credentials): + """Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys. + Raises an exception with a descriptive message on failure.""" + + raise NotImplementedError("This provider does not support spot submission") + + def force_poll(self): + """Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing + because not all spot providers have a polling mechanism. Providers that do should override this method.""" + + return diff --git a/spotproviders/wota.py b/spotproviders/wota.py index 0af0760..8fb7334 100644 --- a/spotproviders/wota.py +++ b/spotproviders/wota.py @@ -77,3 +77,10 @@ class WOTA(HTTPSpotProvider): except Exception as e: logging.error("Exception parsing WOTA spot", e) return new_spots + + def can_submit_spot(self, sig): + return sig == "WOTA" + + def submit_spot(self, spot, credentials): + # TODO Ask M5TEA if he's happy to share how this is done from his app + raise NotImplementedError("WOTA upstream spot submission is not yet implemented") diff --git a/spotproviders/wwbota.py b/spotproviders/wwbota.py index c2358c7..d112c95 100644 --- a/spotproviders/wwbota.py +++ b/spotproviders/wwbota.py @@ -41,3 +41,10 @@ class WWBOTA(SSESpotProvider): # WWBOTA does support a special "Test" spot type, we need to avoid adding that. return spot if source_spot["type"] != "Test" else None + + def can_submit_spot(self, sig): + return sig == "WWBOTA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post + raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented") diff --git a/spotproviders/wwff.py b/spotproviders/wwff.py index 38bd042..e5d8412 100644 --- a/spotproviders/wwff.py +++ b/spotproviders/wwff.py @@ -38,3 +38,10 @@ class WWFF(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "WWFF" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from PoLo. Either way I think we need an API key for the app (but maybe not for the user?) + raise NotImplementedError("WWFF upstream spot submission is not yet implemented") diff --git a/spotproviders/zlota.py b/spotproviders/zlota.py index cb65c32..1df6c5e 100644 --- a/spotproviders/zlota.py +++ b/spotproviders/zlota.py @@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider): new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "ZLOTA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api + raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented") diff --git a/templates/about.html b/templates/about.html index 5b39f8d..2672326 100644 --- a/templates/about.html +++ b/templates/about.html @@ -69,7 +69,7 @@
This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.
- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index e25725c..838f5b2 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -54,13 +54,12 @@ - - - + + + {% end %} diff --git a/templates/alerts.html b/templates/alerts.html index 8f25709..da63985 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -70,8 +70,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index 85c0778..734b3f6 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,9 +76,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index cabb60e..24a863d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -19,9 +19,9 @@ integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn" crossorigin="anonymous"> - - - + + + {% end %} {% block body %}