sig->activity and multiple activity changes for API v3. #143

This commit is contained in:
ian
2026-09-25 07:01:13 +01:00
committed by Ian Renton
parent 02d08c17cd
commit ecdcbe17e9
92 changed files with 1113 additions and 796 deletions
+7 -7
View File
@@ -130,25 +130,25 @@ spot_providers:
url: "wss://39c3.totawatch.de/api/spot/live"
# For the "XOTA" provider, an activity must be set manually here because xOTA is a generic backend for xOTA
# programmes and so different URLs potentially provide different programmes.
sig: "Toilets"
activity: "Toilets"
# For Toilets on the Air, we prefix the activity references (T-01 etc) with some characters that define the
# conference: C3, EH or HOPE - so we can look up the correct locations in our database, because each conference
# starts from T-01 but refers to a toilet in a different building (or continent!)
sig_ref_prefix: "C3"
activity_ref_prefix: "C3"
- class: "XOTA"
name: "EH23 TOTA"
enabled: false
url: "wss://eh23.totawatch.de/api/spot/live"
sig: "Toilets"
sig_ref_prefix: "EH"
activity: "Toilets"
activity_ref_prefix: "EH"
- class: "XOTA"
name: "HOPE26 TOTA"
enabled: false
url: "wss://hope-26.totawatch.de/api/spot/live"
sig: "Toilets"
sig_ref_prefix: "HOPE"
activity: "Toilets"
activity_ref_prefix: "HOPE"
# Alert providers to use. Same setup as the spot providers list above.
@@ -218,7 +218,7 @@ static_data_providers:
# Activity reference data providers to use. These allow Spothole to download, for example, the WWFF directory that
# maps WWFF park IDs to their name and location.
sig_ref_data_providers:
activity_ref_data_providers:
- class: "POTA"
enabled: true
+3 -3
View File
@@ -27,7 +27,7 @@ def get_activity_ref_info(activity_name, ref_id):
ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned
activity_ref = ActivityRef(sig=activity_name, id=ref_id)
activity_ref = ActivityRef(activity=activity_name, id=ref_id)
# We can always get the reference type and the icon from the activity itself
activity = get_activity_by_name(activity_name)
@@ -141,11 +141,11 @@ def get_activity_ref_info(activity_name, ref_id):
def populate_missing_activity_ref_info(activity_ref):
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity_ref object which must at minimum have a "sig" and an "id". The rest of the object will be populated
activity_ref object which must at minimum have an "activity" and an "id". The rest of the object will be populated
and returned. Any data currently in the object will be kept, only missing data in the object will be populated
if it can be determined."""
lookup_data = get_activity_ref_info(activity_ref.sig, activity_ref.id)
lookup_data = get_activity_ref_info(activity_ref.activity, activity_ref.id)
if lookup_data:
# Copy new activity ref data into existing object where data was previously missing
+1 -1
View File
@@ -2,7 +2,7 @@ from core.config import SERVER_OWNER_CALLSIGN
from data.band import Band
# General software
SOFTWARE_VERSION = "2.2"
SOFTWARE_VERSION = "3.0-pre"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
+5 -5
View File
@@ -15,7 +15,7 @@ class DataProviders:
self.alert_providers = []
self.solar_condition_providers = []
self.static_data_providers = []
self.sig_ref_data_providers = []
self.activity_ref_data_providers = []
self.callsign_data_providers = []
self._startup_timers = []
@@ -28,8 +28,8 @@ class DataProviders:
self.solar_condition_providers.append(create_provider_from_config("providers.solarconditions", entry))
for entry in config.get("static_data_providers", []):
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
for entry in config.get("sig_ref_data_providers", []):
self.sig_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
for entry in config.get("activity_ref_data_providers", []):
self.activity_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
for entry in config.get("callsign_data_providers", []):
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@@ -54,7 +54,7 @@ class DataProviders:
25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
),
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "activity ref data")),
threading.Timer(30.0, lambda: self.start_providers(self.activity_ref_data_providers, "activity ref data")),
]
for t in self._startup_timers:
t.daemon = True
@@ -72,7 +72,7 @@ class DataProviders:
self.spot_providers
+ self.alert_providers
+ self.solar_condition_providers
+ self.sig_ref_data_providers
+ self.activity_ref_data_providers
+ self.static_data_providers
+ self.callsign_data_providers
)
+1 -1
View File
@@ -77,7 +77,7 @@ class LocationSourceForSpot(str, Enum):
"""Where the location data came from in a spot."""
SPOT = "SPOT"
SIG_REF_LOOKUP = "SIG REF LOOKUP"
ACTIVITY_REF_LOOKUP = "ACTIVITY REF LOOKUP"
GRID = "GRID"
HOME_QTH = "HOME QTH"
DXCC = "DXCC"
+3 -3
View File
@@ -111,9 +111,9 @@ class StatusReporter:
}
for p in DATA_PROVIDERS.static_data_providers
]
DATA_STORE.status.get()["sig_ref_data_providers"] = [
DATA_STORE.status.get()["activity_ref_data_providers"] = [
{
"sig_name": p.sig_name,
"activity_name": p.activity_name,
"enabled": p.enabled,
"status": p.status,
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
@@ -121,7 +121,7 @@ class StatusReporter:
else 0,
"reference_count": p.reference_count,
}
for p in DATA_PROVIDERS.sig_ref_data_providers
for p in DATA_PROVIDERS.activity_ref_data_providers
]
DATA_STORE.status.get()["callsign_data_providers"] = [
{
+43 -43
View File
@@ -5,7 +5,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.CONTEST: Activity(
name=ActivityName.CONTEST,
description="Contest",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# No sensible way to determine *which* contest, but if we set comment_names=["CONTEST"] then at least
@@ -17,7 +17,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DXPEDITION: Activity(
name=ActivityName.DXPEDITION,
description="Radio expedition to a remote location",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=["DXPEDITION"],
@@ -27,7 +27,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SATELLITE: Activity(
name=ActivityName.SATELLITE,
description="Amateur Radio Satellite",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
# Satellite "references" are the names of the satellites themselves. This is not an exhaustive list, it just
# matches some of the most commonly used amateur radio satellites so they can be picked out of spot comments.
has_refs=True,
@@ -41,7 +41,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.EME: Activity(
name=ActivityName.EME,
description="Earth-Moon-Earth (Moonbounce)",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=[],
@@ -50,7 +50,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.AERONAUTICAL_MOBILE: Activity(
name=ActivityName.AERONAUTICAL_MOBILE,
description="Aeronautical Mobile",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# Don't pick /AM out of comments, spot.py will handle picking it out of the callsign
@@ -60,7 +60,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.MARITIME_MOBILE: Activity(
name=ActivityName.MARITIME_MOBILE,
description="Maritime Mobile",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# Don't pick /MM out of comments, spot.py will handle picking it out of the callsign
@@ -70,7 +70,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.QRP: Activity(
name=ActivityName.QRP,
description="Low power",
sig_type=ActivityType.TRADITIONAL,
activity_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=["QRP"],
@@ -80,7 +80,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.POTA: Activity(
name=ActivityName.POTA,
description="Parks on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
@@ -92,7 +92,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SOTA: Activity(
name=ActivityName.SOTA,
description="Summits on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.SUMMIT,
@@ -104,7 +104,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WWFF: Activity(
name=ActivityName.WWFF,
description="World Wide Flora & Fauna",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.PARK,
@@ -116,7 +116,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.GMA: Activity(
name=ActivityName.GMA,
description="Global Mountain Activity",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
@@ -127,7 +127,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WWBOTA: Activity(
name=ActivityName.WWBOTA,
description="Worldwide Bunkers on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUNKER,
@@ -138,7 +138,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.HEMA: Activity(
name=ActivityName.HEMA,
description="HuMPs Excluding Marilyns Award",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
@@ -150,7 +150,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.IOTA: Activity(
name=ActivityName.IOTA,
description="Islands on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.ISLAND,
@@ -161,7 +161,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.GMA_ISLANDS: Activity(
name=ActivityName.GMA_ISLANDS,
description="Global Mountain Activity - Islands",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.ISLAND,
@@ -172,7 +172,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ARLHS: Activity(
name=ActivityName.ARLHS,
description="Amateur Radio Lighthouse Society",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE,
@@ -183,7 +183,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ILLW: Activity(
name=ActivityName.ILLW,
description="International Lighthouse & Lightship Weekend",
sig_type=ActivityType.EVENT,
activity_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE,
@@ -194,7 +194,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.MOTA: Activity(
name=ActivityName.MOTA,
description="Mills on the Air",
sig_type=ActivityType.EVENT,
activity_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.MILL,
@@ -205,7 +205,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SIOTA: Activity(
name=ActivityName.SIOTA,
description="Silos on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SILO,
@@ -217,7 +217,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WCA: Activity(
name=ActivityName.WCA,
description="World Castles Award",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
@@ -228,7 +228,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ZLOTA: Activity(
name=ActivityName.ZLOTA,
description="New Zealand on the Air",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=None,
@@ -241,7 +241,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WOTA: Activity(
name=ActivityName.WOTA,
description="Wainwrights on the Air",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
@@ -254,7 +254,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.BOTA: Activity(
name=ActivityName.BOTA,
description="Beaches on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.BEACH,
@@ -265,7 +265,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.KRMNPA: Activity(
name=ActivityName.KRMNPA,
description="Keith Roget Memorial National Parks Award",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
@@ -278,7 +278,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SANPCPA: Activity(
name=ActivityName.SANPCPA,
description="South Australian National Parks and Conservation Parks Award",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
@@ -291,7 +291,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.LLOTA: Activity(
name=ActivityName.LLOTA,
description="Lagos y Lagunas on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.LAKE,
@@ -303,7 +303,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TOWERS: Activity(
name=ActivityName.TOWERS,
description="Towers on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.TOWER,
@@ -314,7 +314,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TILES: Activity(
name=ActivityName.TILES,
description="Tiles on the Air",
sig_type=ActivityType.ADVENTURE,
activity_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
@@ -325,7 +325,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.RADAR_RALLY: Activity(
name=ActivityName.RADAR_RALLY,
description="RaDAR Rally",
sig_type=ActivityType.EVENT,
activity_type=ActivityType.EVENT,
has_refs=False,
refs_globally_unique=False,
comment_names=["RaDAR"],
@@ -334,7 +334,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WAB: Activity(
name=ActivityName.WAB,
description="Worked All Britain",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
@@ -346,7 +346,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WAI: Activity(
name=ActivityName.WAI,
description="Worked All Ireland",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
@@ -358,7 +358,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMF: Activity(
name=ActivityName.DMF,
description="Diplôme des Moulins de France",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.MILL,
@@ -369,7 +369,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DME: Activity(
name=ActivityName.DME,
description="Diploma Municipios de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.TOWN,
@@ -381,7 +381,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.FEA: Activity(
name=ActivityName.FEA,
description="Diploma Faros de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.LIGHTHOUSE,
@@ -396,7 +396,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMUE: Activity(
name=ActivityName.DMUE,
description="Diploma Museos de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
@@ -408,7 +408,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMVE: Activity(
name=ActivityName.DMVE,
description="Diploma Monumentos y Vestigios de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
@@ -420,7 +420,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DCE: Activity(
name=ActivityName.DCE,
description="Diploma Castillos de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
@@ -432,7 +432,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DEFE: Activity(
name=ActivityName.DEFE,
description="Diploma Estaciones de Ferrocarril de España",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
@@ -444,7 +444,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DTMBA: Activity(
name=ActivityName.DTMBA,
description="Diploma Teatri Musei e Belle Arti",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
@@ -456,7 +456,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.BIWOTA: Activity(
name=ActivityName.BIWOTA,
description="British Inland Waterways on the Air",
sig_type=ActivityType.EVENT,
activity_type=ActivityType.EVENT,
has_refs=False,
refs_globally_unique=False,
ref_type=ActivityRefType.WATERWAY,
@@ -467,7 +467,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.COTA: Activity(
name=ActivityName.COTA,
description="Castles on the Air",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
@@ -479,7 +479,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.PGA: Activity(
name=ActivityName.PGA,
description="Polish Gmina Award",
sig_type=ActivityType.REGIONAL,
activity_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.REGION,
@@ -491,7 +491,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TOILETS: Activity(
name=ActivityName.TOILETS,
description="Toilets on the Air",
sig_type=ActivityType.EVENT,
activity_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.TOILET,
+8 -10
View File
@@ -5,22 +5,20 @@ from core.enums import ActivityName, ActivityRefType, ActivityType
@dataclass
class Activity:
"""Data class that defines an Activity (formerly referred to as a "Special Interest Group" or "SIG", a term
which is still used for the `sig` field name in the API for backwards compatibility). Each contains a name and
a longer form description. They also contain comment_names which attempts to separate out the way people might
refer to it in cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster
spot comments almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA"
as it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
"""Data class that defines an Activity. Each contains a name and a longer form description. They also contain
comment_names which attempts to separate out the way people might refer to it in cluster comments from how it is
referred to in the UI & API. (For example, "TOTA" in cluster spot comments almost always means Towers on the Air,
but no single programme is referred to in the UI as "TOTA" as it's ambiguous between Towers, Toilets and Tiles.
And while Beaches got the name "BOTA" first, "BOTA" spots are much more likely to be bunkers.) Finally, there is a
ref_regex which provides a regular expression to match what references (such as parks and summits) look like for
that programme."""
# Activity name as used in the UI and API, e.g. "Towers"
name: ActivityName
# Description, e.g. "Towers on the Air"
description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
# Note: this field is still named "sig_type" in the API for backwards compatibility.
sig_type: ActivityType
activity_type: ActivityType
# Whether this activity has a fixed set of references (e.g. parks) with some sort of ID to "activate"
has_refs: bool
# Identifies that the activity's reference ID structure defined by its regex is unique across all programmes and
+2 -2
View File
@@ -8,8 +8,8 @@ class ActivityRef:
"""Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
name and a lookup URL."""
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API.
sig: str
# Activity that this reference is in, e.g. "POTA".
activity: str
# Reference ID, e.g. "GB-0001".
id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known.
+31 -15
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
import pytz
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.activity_lookup_helper import populate_missing_activity_ref_info
from core.activity_utils import get_icon_for_activity
@@ -66,11 +66,13 @@ class Alert:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list)
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
# Timing info
@@ -93,6 +95,11 @@ class Alert:
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def __post_init__(self):
"""Normalise the activities list, removing any duplicates while keeping the order."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -131,8 +138,8 @@ class Alert:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# Fetch activity data, and set a real position if we can get one.
if self.sig_refs:
for activity_ref in self.sig_refs:
if self.activity_refs:
for activity_ref in self.activity_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref)
# If the alert itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid:
@@ -146,10 +153,10 @@ class Alert:
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig
# Add the activities of any activity refs we have to the alert's list of activities.
for activity_ref in self.activity_refs:
if activity_ref and activity_ref.activity:
self.add_activity(activity_ref.activity)
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -180,14 +187,23 @@ class Alert:
if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
# Icon for the alert should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""
+67 -59
View File
@@ -76,8 +76,8 @@ class Spot:
# DX Location source. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot | None = None
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
# true if the location source is "SPOT", "ACTIVITY REF LOOKUP" or "GRID", or if the location source is "HOME QTH"
# and the DX callsign doesn't have a suffix like /P.
dx_location_good: bool = False
# DE (Spotter) info
@@ -125,11 +125,13 @@ class Spot:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list)
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
# Timing info
@@ -159,12 +161,13 @@ class Spot:
def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the sig_refs list.."""
objects such as the activity_refs list, and de-duplicating the activities list."""
if self.sig_refs:
self.sig_refs = [
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
if self.activity_refs:
self.activity_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
for activity_ref in self.sig_refs
for activity_ref in self.activity_refs
]
def infer_missing(self, credentials=None):
@@ -269,19 +272,22 @@ class Spot:
if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level activity if it is missing but we have at least one activity ref.
if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper()
# Add the activities of any activity refs we have to the top-level activities list.
for activity_ref in self.activity_refs:
if activity_ref.activity:
self.add_activity(activity_ref.activity.upper())
# See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and self.sig_refs[0].sig:
activity = self.sig_refs[0].sig.upper()
if self.comment and self.activity_refs and self.activity_refs[0].activity:
activity = self.activity_refs[0].activity.upper()
regex = get_ref_regex_for_activity(activity)
if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches:
self._append_activity_ref_if_missing(ActivityRef(id=ref_match.group(2).upper(), sig=activity))
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), activity=activity)
)
# See if the comment looks like it contains any activities (and optionally activity references) that we
# can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
@@ -289,14 +295,13 @@ class Spot:
if self.comment:
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# First of all, add the activity to this spot's list of activities. This covers
# things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.sig:
self.sig = found_activity
self.add_activity(found_activity)
# Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the sig_refs list for this spot.
# for that activity. If so, add that to the activity_refs list for this spot.
found_activity_info = get_activity_by_name(found_activity)
if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
ref_matches = re.finditer(
@@ -306,7 +311,7 @@ class Spot:
)
for ref_match in ref_matches:
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(3).upper(), sig=found_activity)
ActivityRef(id=ref_match.group(3).upper(), activity=found_activity)
)
# See if the comment looks like it contains any activity references *without* the corresponding activity
@@ -319,20 +324,18 @@ class Spot:
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
)
for ref_match in ref_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF.
if not self.sig:
self.sig = activity.name
# First of all, add the activity to this spot's list of activities. This covers things
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
self.add_activity(activity.name)
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), sig=activity.name)
ActivityRef(id=ref_match.group(2).upper(), activity=activity.name)
)
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# reference in its initial call, we use this code to populate the rest of the data. This includes working
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
if self.sig_refs:
for activity_ref in self.sig_refs:
if self.activity_refs:
for activity_ref in self.activity_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid:
@@ -345,15 +348,10 @@ class Spot:
):
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
if self.sig in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
if activity_ref.activity in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
self.dx_location_source = LocationSourceForSpot.GRID
else:
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig
self.dx_location_source = LocationSourceForSpot.ACTIVITY_REF_LOOKUP
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
@@ -406,32 +404,33 @@ class Spot:
self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.sig:
self.sig = ActivityName.SATELLITE
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig:
self.sig = ActivityName.EME
if self.propagation_mode == "Satellite":
self.add_activity(ActivityName.SATELLITE)
if self.propagation_mode == "Earth-Moon-Earth":
self.add_activity(ActivityName.EME)
# Set activities based on the DX callsign suffix
if self.dx_call and not self.sig:
if self.dx_call:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
self.sig = ActivityName.AERONAUTICAL_MOBILE
self.add_activity(ActivityName.AERONAUTICAL_MOBILE)
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
self.sig = ActivityName.MARITIME_MOBILE
self.add_activity(ActivityName.MARITIME_MOBILE)
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
# running DXpedition which we know from the alerts list.
if self.dx_call and not self.sig:
if self.dx_call and not self.activities:
now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values():
if (
alert.sig == ActivityName.DXPEDITION
alert.activities
and ActivityName.DXPEDITION in alert.activities
and alert.dx_calls
and alert.start_time
and alert.end_time
and alert.start_time < now < alert.end_time
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
):
self.sig = ActivityName.DXPEDITION
self.add_activity(ActivityName.DXPEDITION)
break
# DX Grid to lat/lon and vice versa in case one is missing
@@ -476,10 +475,10 @@ class Spot:
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
# string, otherwise see what they have set on an online lookup service.
if self.sig_refs:
qth = self.sig_refs[0].id
if self.sig_refs[0].name:
qth += f" {self.sig_refs[0].name}"
if self.activity_refs:
qth = self.activity_refs[0].id
if self.activity_refs[0].name:
qth += f" {self.activity_refs[0].name}"
self.dx_qth = qth
else:
self.dx_qth = dx_call_info.qth
@@ -512,7 +511,7 @@ class Spot:
and self.dx_longitude
and (
self.dx_location_source == LocationSourceForSpot.SPOT
or self.dx_location_source == LocationSourceForSpot.SIG_REF_LOOKUP
or self.dx_location_source == LocationSourceForSpot.ACTIVITY_REF_LOOKUP
or self.dx_location_source == LocationSourceForSpot.GRID
or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or ""))
)
@@ -530,14 +529,23 @@ class Spot:
self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
# Icon for the spot should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""
@@ -547,13 +555,13 @@ class Spot:
"""Append an activity ref to the list, so long as it's not already there."""
new_activity_ref.id = new_activity_ref.id.strip().upper()
new_activity_ref.sig = new_activity_ref.sig.strip().upper()
new_activity_ref.activity = new_activity_ref.activity.strip().upper()
if new_activity_ref.id == "":
return
for activity_ref in self.sig_refs:
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
for activity_ref in self.activity_refs:
if activity_ref.id == new_activity_ref.id and activity_ref.activity == new_activity_ref.activity:
return
self.sig_refs.append(new_activity_ref)
self.activity_refs.append(new_activity_ref)
def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
@@ -13,11 +13,10 @@ class ActivityRefDataProvider:
"""Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
data."""
def __init__(self, sig_name, provider_config):
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's
"sig" field name."""
def __init__(self, activity_name, provider_config):
"""Constructor"""
self.sig_name = sig_name
self.activity_name = activity_name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
@@ -43,7 +42,7 @@ class ActivityRefDataProvider:
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
with DATA_STORE.activity_refs.transact(retry=True):
for d in new_data:
DATA_STORE.activity_refs.set(f"{self.sig_name}:{d.id}", d)
DATA_STORE.activity_refs.set(f"{self.activity_name}:{d.id}", d)
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
@@ -52,4 +51,4 @@ class ActivityRefDataProvider:
break
self.reference_count = len(new_data)
logger.info(f"Loaded {self.reference_count} references for {self.sig_name} into the data store.")
logger.info(f"Loaded {self.reference_count} references for {self.activity_name} into the data store.")
+1 -1
View File
@@ -25,7 +25,7 @@ class ARLHS(FileDownloadActivityRefDataProvider):
ref_id = row["ARLHS"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=ActivityRefType.LIGHTHOUSE,
+1 -1
View File
@@ -30,7 +30,7 @@ class COTA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=name,
ref_type=ActivityRefType.CASTLE,
+6 -1
View File
@@ -27,7 +27,12 @@ class DCE(FileDownloadActivityRefDataProvider):
for index, row in df.iterrows():
if row.iloc[0] and row.iloc[2]:
new_data.append(
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=ActivityRefType.CASTLE)
ActivityRef(
activity=self.ACTIVITY,
id=row.iloc[0].strip(),
name=row.iloc[2].strip(),
ref_type=ActivityRefType.CASTLE,
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
+6 -1
View File
@@ -31,7 +31,12 @@ class DEFE(FileDownloadActivityRefDataProvider):
if row.iloc[0] and row.iloc[1]:
new_data.append(
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=ActivityRefType.BUILDING)
ActivityRef(
activity=self.ACTIVITY,
id=row.iloc[0].strip(),
name=row.iloc[1].strip(),
ref_type=ActivityRefType.BUILDING,
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
+1 -1
View File
@@ -40,7 +40,7 @@ class DME(LocalFileActivityRefDataProvider):
)
ref = ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
ref_type=ActivityRefType.TOWN,
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
+6 -1
View File
@@ -22,7 +22,12 @@ class DMUE(FileDownloadActivityRefDataProvider):
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]:
new_data.append(
ActivityRef(sig=self.ACTIVITY, id=row[0].strip(), name=row[1].strip(), ref_type=ActivityRefType.BUILDING)
ActivityRef(
activity=self.ACTIVITY,
id=row[0].strip(),
name=row[1].strip(),
ref_type=ActivityRefType.BUILDING,
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
+5 -1
View File
@@ -36,7 +36,11 @@ class DMVE(FileDownloadActivityRefDataProvider):
continue
if ref and name:
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref.strip(), name=name.strip(), ref_type=ActivityRefType.BUILDING))
new_data.append(
ActivityRef(
activity=self.ACTIVITY, id=ref.strip(), name=name.strip(), ref_type=ActivityRefType.BUILDING
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
+3 -1
View File
@@ -21,7 +21,9 @@ class DTMBA(FileDownloadActivityRefDataProvider):
split = row.split(";")
ref_id = split[0]
ref_name = split[1]
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id, name=ref_name, ref_type=ActivityRefType.BUILDING))
new_data.append(
ActivityRef(activity=self.ACTIVITY, id=ref_id, name=ref_name, ref_type=ActivityRefType.BUILDING)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
+10 -2
View File
@@ -41,8 +41,16 @@ class FEA(FileDownloadActivityRefDataProvider):
# prefix and just use FEA-1234 or FEA 1234, so we add both copies to the database.
ref_id_1 = row[0].strip()
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
new_data.append(
ActivityRef(
activity=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE
)
)
new_data.append(
ActivityRef(
activity=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
@@ -16,19 +16,21 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from the web by downloading a
file."""
def __init__(self, sig_name, provider_config, url, poll_interval):
def __init__(self, activity_name, provider_config, url, poll_interval):
"""Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config)
super().__init__(activity_name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
self._url_data_cache = URLDataCache(f"activity_ref_data_{activity_name}")
def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
logger.info(f"Set up query of {self.activity_name} activity ref data every {self._poll_interval!s} days.")
self._thread = Thread(
target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.activity_name}", daemon=True
)
self._thread.start()
def stop(self):
@@ -36,7 +38,9 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.")
logger.warning(
f"{self.activity_name} activity ref data worker thread did not exit on time and will be killed."
)
def _run(self):
while True:
@@ -48,7 +52,7 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
try:
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
# caching, but just so continual restarts of the software during testing don't hammer the servers.
logger.debug(f"Downloading {self.sig_name} activity ref data...")
logger.debug(f"Downloading {self.activity_name} activity ref data...")
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
# Check response code was good
if http_response.ok:
@@ -60,20 +64,22 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug(f"Received activity ref data for {self.sig_name}")
logger.debug(f"Received activity ref data for {self.activity_name}")
else:
self.status = "Error"
logger.warning(f"HTTP {http_response.status_code} when downloading activity ref data for {self.sig_name}.")
logger.warning(
f"HTTP {http_response.status_code} when downloading activity ref data for {self.activity_name}."
)
except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when downloading activity ref data for {self.sig_name}.")
logger.warning(f"Connection error when downloading activity ref data for {self.activity_name}.")
except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when downloading activity ref data for {self.sig_name}.")
logger.warning(f"Timeout when downloading activity ref data for {self.activity_name}.")
except Exception:
self.status = "Error"
logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.sig_name})")
logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.activity_name})")
self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response):
+1 -1
View File
@@ -24,7 +24,7 @@ class GMA(FileDownloadActivityRefDataProvider):
ref_id = row["Reference"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=ActivityRefType.SUMMIT,
+1 -1
View File
@@ -25,7 +25,7 @@ class ILLW(FileDownloadActivityRefDataProvider):
ref_id = row["ILLW"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=ActivityRefType.LIGHTHOUSE,
+1 -1
View File
@@ -42,7 +42,7 @@ class IOTA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=ref["name"],
ref_type=ActivityRefType.ISLAND,
+1 -1
View File
@@ -30,7 +30,7 @@ class LLOTA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=str(ref["name"]),
ref_type=ActivityRefType.LAKE,
@@ -11,12 +11,12 @@ logger = logging.getLogger(__name__)
class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, sig_name, provider_config, path):
super().__init__(sig_name, provider_config)
def __init__(self, activity_name, provider_config, path):
super().__init__(activity_name, provider_config)
self._path = path
def start(self):
logger.debug(f"Loading {self.sig_name} activity ref data from file.")
logger.debug(f"Loading {self.activity_name} activity ref data from file.")
try:
new_data = self._file_to_data(self._path)
if new_data:
@@ -25,10 +25,10 @@ class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
self.last_update_time = datetime.now(pytz.UTC)
else:
self.status = "Error"
logger.info(f"Failed to load activity ref data for {self.sig_name}")
logger.info(f"Failed to load activity ref data for {self.activity_name}")
except Exception:
self.status = "Error"
logger.exception(f"Exception in local file Activity Ref Data Provider ({self.sig_name})")
logger.exception(f"Exception in local file Activity Ref Data Provider ({self.activity_name})")
def _file_to_data(self, path):
"""Load a file on the given path and turn it into activity ref data."""
+1 -1
View File
@@ -24,7 +24,7 @@ class MOTA(FileDownloadActivityRefDataProvider):
ref_id = row["Reference"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=ActivityRefType.MILL,
+1 -1
View File
@@ -39,7 +39,7 @@ class PGA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=name,
ref_type=ActivityRefType.REGION,
@@ -17,9 +17,9 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
REF_PATTERN = re.compile(r"VKFF-\d+")
def __init__(self, sig_name, provider_config, url, poll_interval):
def __init__(self, activity_name, provider_config, url, poll_interval):
"""Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config, url, poll_interval)
super().__init__(activity_name, provider_config, url, poll_interval)
def _http_response_to_data(self, http_response):
new_data = []
@@ -41,7 +41,7 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = ActivityRef(
sig=self.sig_name,
activity=self.activity_name,
id=ref_id,
name=placemark.name,
ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -24,7 +24,7 @@ class POTA(FileDownloadActivityRefDataProvider):
ref_id = row["reference"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("name", None),
ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -24,7 +24,7 @@ class SIOTA(FileDownloadActivityRefDataProvider):
ref_id = row["SILO_CODE"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("NAME", None),
ref_type=ActivityRefType.SILO,
+1 -1
View File
@@ -28,7 +28,7 @@ class SOTA(FileDownloadActivityRefDataProvider):
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
ref = ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("SummitName", None),
ref_type=ActivityRefType.SUMMIT,
+1 -1
View File
@@ -24,7 +24,7 @@ class Toilets(LocalFileActivityRefDataProvider):
for row in dr:
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=row["ref"],
name=row["ref"],
ref_type=ActivityRefType.TOILET,
+1 -1
View File
@@ -24,7 +24,7 @@ class Towers(FileDownloadActivityRefDataProvider):
ref_id = row["Ref"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Nazev", None),
ref_type=ActivityRefType.TOWER,
+1 -1
View File
@@ -43,7 +43,7 @@ class WCA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("CLEAN NAME", None),
ref_type=ActivityRefType.CASTLE,
+1 -1
View File
@@ -30,7 +30,7 @@ class WOTA(FileDownloadActivityRefDataProvider):
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=feature["properties"]["title"],
url=url,
+1 -1
View File
@@ -24,7 +24,7 @@ class WWBOTA(FileDownloadActivityRefDataProvider):
ref_id = row["Reference"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=ActivityRefType.BUNKER,
+1 -1
View File
@@ -24,7 +24,7 @@ class WWFF(FileDownloadActivityRefDataProvider):
ref_id = row["reference"]
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=row.get("name", None),
ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -33,7 +33,7 @@ class ZLOTA(FileDownloadActivityRefDataProvider):
ref_type = None
new_ref = ActivityRef(
sig=self.ACTIVITY,
activity=self.ACTIVITY,
id=ref_id,
name=ref["name"],
ref_type=ref_type,
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -56,8 +56,8 @@ class BOTA(HTTPAlertProvider):
alert = Alert(
source=self.name,
dx_calls=[dx_call],
sig=ActivityName.BOTA,
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)],
activities=[ActivityName.BOTA],
activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA, ref_type=ActivityRefType.BEACH)],
start_time=date_time.timestamp(),
)
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -38,12 +38,13 @@ class Hamsat(HTTPAlertProvider):
dx_grid=source_alert["grids"][0],
freqs_modes=freqs_modes,
comment=source_alert["comment"],
sig=ActivityName.SATELLITE,
activities=[ActivityName.SATELLITE],
# Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
sig_refs=[
activity_refs=[
ActivityRef(
sig=ActivityName.SATELLITE,
activity=ActivityName.SATELLITE,
id=source_alert["satellite"]["name"],
ref_type=ActivityRefType.SATELLITE
)
],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
+1 -1
View File
@@ -89,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp,
end_time=end_timestamp,
sig=ActivityName.DXPEDITION,
activities=[ActivityName.DXPEDITION],
)
# Add to our list.
+3 -3
View File
@@ -37,7 +37,7 @@ class ParksNPeaks(HTTPAlertProvider):
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
)
activity_refs = [ActivityRef(id=ref_id, sig=activity, name=ref_name)]
activity_refs = [ActivityRef(id=ref_id, activity=activity, name=ref_name)]
# Convert to our alert format
alert = Alert(
@@ -46,8 +46,8 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"],
sig=activity,
sig_refs=activity_refs,
activities=[activity] if activity else [],
activity_refs=activity_refs,
start_time=start_time,
)
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,11 +28,12 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"],
sig=ActivityName.POTA,
sig_refs=[
activities=[ActivityName.POTA],
activity_refs=[
ActivityRef(
id=source_alert["reference"],
sig=ActivityName.POTA,
activity=ActivityName.POTA,
ref_type=ActivityRefType.PARK,
name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}",
)
+1 -1
View File
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary,
start_time=start_timestamp,
end_time=end_timestamp,
sig=ActivityName.CONTEST,
activities=[ActivityName.CONTEST],
)
return alert
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,11 +34,12 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"],
comment=source_alert["comments"],
sig=ActivityName.SOTA,
sig_refs=[
activities=[ActivityName.SOTA],
activity_refs=[
ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
sig=ActivityName.SOTA,
activity=ActivityName.SOTA,
ref_type=ActivityRefType.SUMMIT,
name=summit_name,
activation_score=summit_points,
)
+1 -1
View File
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
url=url,
start_time=start_timestamp,
end_time=end_timestamp,
sig=ActivityName.CONTEST,
activities=[ActivityName.CONTEST],
)
return alert
+2 -2
View File
@@ -7,7 +7,7 @@ import pytz
from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -75,7 +75,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
sig_refs=[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name)] if ref else [],
activity_refs=[ActivityRef(id=ref, activity=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)] if ref else [],
start_time=time.timestamp(),
)
+3 -3
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,8 +28,8 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"],
sig=ActivityName.WWFF,
sig_refs=[ActivityRef(id=source_alert["reference"], sig=ActivityName.WWFF)],
activities=[ActivityName.WWFF],
activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF, ref_type=ActivityRefType.PARK)],
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC)
.timestamp(),
+35 -35
View File
@@ -68,10 +68,10 @@ class GMA(HTTPSpotProvider):
# Filter out some weird mode strings
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"],
sig_refs=[
activity_refs=[
ActivityRef(
id=source_spot["REF"],
sig="",
activity="",
name=source_spot["NAME"],
latitude=lat,
longitude=lon,
@@ -98,57 +98,57 @@ class GMA(HTTPSpotProvider):
and ref_response.text != "\n"
):
ref_info = ref_response.json()
if spot.sig_refs and ref_info and "reftype" in ref_info:
if spot.activity_refs and ref_info and "reftype" in ref_info:
match ref_info["reftype"]:
case "Summit":
# Summits are a bit complicated, they can be SOTA or GMA depending on the
# separate "sota" field:
if "sota" in ref_info and ref_info["sota"] != "":
spot.sig_refs[0].sig = ActivityName.SOTA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.SOTA
spot.activity_refs[0].activity = ActivityName.SOTA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.add_activity(ActivityName.SOTA)
else:
spot.sig_refs[0].sig = ActivityName.GMA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.GMA
spot.activity_refs[0].activity = ActivityName.GMA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.add_activity(ActivityName.GMA)
case "POTA":
spot.sig_refs[0].sig = ActivityName.POTA
spot.sig_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.POTA
spot.activity_refs[0].activity = ActivityName.POTA
spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.add_activity(ActivityName.POTA)
case "WWFF":
spot.sig_refs[0].sig = ActivityName.WWFF
spot.sig_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.WWFF
spot.activity_refs[0].activity = ActivityName.WWFF
spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.add_activity(ActivityName.WWFF)
case "IOTA Island":
spot.sig_refs[0].sig = ActivityName.IOTA
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = ActivityName.IOTA
spot.activity_refs[0].activity = ActivityName.IOTA
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.add_activity(ActivityName.IOTA)
case "GMA Island":
spot.sig_refs[0].sig = ActivityName.GMA_ISLANDS
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = ActivityName.GMA_ISLANDS
spot.activity_refs[0].activity = ActivityName.GMA_ISLANDS
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.add_activity(ActivityName.GMA_ISLANDS)
case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = ActivityName.ILLW
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = ActivityName.ILLW
spot.activity_refs[0].activity = ActivityName.ILLW
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.add_activity(ActivityName.ILLW)
case "Lighthouse (ARLHS)":
spot.sig_refs[0].sig = ActivityName.ARLHS
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = ActivityName.ARLHS
spot.activity_refs[0].activity = ActivityName.ARLHS
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.add_activity(ActivityName.ARLHS)
case "Castle":
spot.sig_refs[0].sig = ActivityName.WCA
spot.sig_refs[0].ref_type = ActivityRefType.CASTLE
spot.sig = ActivityName.WCA
spot.activity_refs[0].activity = ActivityName.WCA
spot.activity_refs[0].ref_type = ActivityRefType.CASTLE
spot.add_activity(ActivityName.WCA)
case "Mill":
spot.sig_refs[0].sig = ActivityName.MOTA
spot.sig_refs[0].ref_type = ActivityRefType.MILL
spot.sig = ActivityName.MOTA
spot.activity_refs[0].activity = ActivityName.MOTA
spot.activity_refs[0].ref_type = ActivityRefType.MILL
spot.add_activity(ActivityName.MOTA)
case _:
logger.warning(
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
)
spot.sig_refs[0].sig = ref_info["reftype"]
spot.sig = ref_info["reftype"]
spot.activity_refs[0].activity = ref_info["reftype"]
spot.add_activity(ref_info["reftype"])
elif not ref_response.from_cache:
if not ref_response.ok:
+3 -3
View File
@@ -62,11 +62,11 @@ class HEMA(HTTPSpotProvider):
freq=float(freq_mode_match.group(1)) * 1000000,
mode=Mode.from_name(freq_mode_match.group(2).upper()),
comment=spotter_comment_match.group(2),
sig=ActivityName.HEMA,
sig_refs=[
activities=[ActivityName.HEMA],
activity_refs=[
ActivityRef(
id=spot_items[3].upper(),
sig=ActivityName.HEMA,
activity=ActivityName.HEMA,
name=spot_items[4],
latitude=float(spot_items[7]),
longitude=float(spot_items[8]),
+3 -3
View File
@@ -34,11 +34,11 @@ class LLOTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment,
sig=ActivityName.LLOTA,
sig_refs=[
activities=[ActivityName.LLOTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
sig=ActivityName.LLOTA,
activity=ActivityName.LLOTA,
name=source_spot["reference_name"],
ref_type=ActivityRefType.LAKE,
)
+5 -5
View File
@@ -70,20 +70,20 @@ class ParksNPeaks(HTTPSpotProvider):
ref_id = source_spot["actSiteID"]
if activity:
spot.sig = activity
spot.add_activity(activity)
if ref_id:
activity_refs = [
ActivityRef(
id=ref_id,
sig=activity,
activity=activity,
# Free text location is not present in all spots, so only add it if it's set
name=source_spot["actLocation"]
if "actLocation" in source_spot and source_spot["actLocation"] != ""
else None,
)
]
spot.sig_refs = activity_refs
spot.activity_refs = activity_refs
else:
# If no actSiteID is set, e.g. because actClass is "QRP", sometimes we still have an actLocation
@@ -128,9 +128,9 @@ class ParksNPeaks(HTTPSpotProvider):
raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account."
)
ref_id = spot.sig_refs[0].id if spot.sig_refs else ""
ref_id = spot.activity_refs[0].id if spot.activity_refs else ""
body = {
"actClass": spot.sig or "",
"actClass": next((a for a in spot.activities if self.can_submit_spot(a)), ""),
"actCallsign": spot.dx_call,
"actSite": ref_id,
"mode": spot.mode or "",
+6 -6
View File
@@ -33,11 +33,11 @@ class POTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"],
sig=ActivityName.POTA,
sig_refs=[
activities=[ActivityName.POTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
sig=ActivityName.POTA,
activity=ActivityName.POTA,
name=source_spot["name"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"],
@@ -61,14 +61,14 @@ class POTA(HTTPSpotProvider):
return activity == ActivityName.POTA
def submit_spot(self, spot, credentials):
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
if sig_ref:
ref_id = spot.activity_refs[0].id if spot.activity_refs else None
if ref_id:
body = {
"activator": spot.dx_call,
"spotter": spot.de_call,
"frequency": str(spot.freq / 1000.0),
"mode": spot.mode or "",
"reference": sig_ref,
"reference": ref_id,
"comments": spot.comment or "",
"source": "Spothole",
}
+6 -6
View File
@@ -57,11 +57,11 @@ class SOTA(HTTPSpotProvider):
# Seen SOTA spots with no frequency!
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"],
sig=ActivityName.SOTA,
sig_refs=[
activities=[ActivityName.SOTA],
activity_refs=[
ActivityRef(
id=source_spot["summitCode"],
sig=ActivityName.SOTA,
activity=ActivityName.SOTA,
name=source_spot["summitName"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"],
@@ -92,10 +92,10 @@ class SOTA(HTTPSpotProvider):
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:
ref_id = spot.activity_refs[0].id if spot.activity_refs else ""
if ref_id:
# Split reference into association and summit codes
ref_split = sig_ref.split("/")
ref_split = ref_id.split("/")
# Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
+3 -3
View File
@@ -59,13 +59,13 @@ class Tiles(HTTPSpotProvider):
freq=freq,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"],
sig=ActivityName.TILES,
activities=[ActivityName.TILES],
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles activity reference.
sig_refs=[
activity_refs=[
ActivityRef(
id=source_spot["maidenhead_grid"],
sig=ActivityName.TILES,
activity=ActivityName.TILES,
name=source_spot["maidenhead_grid"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"],
+4 -2
View File
@@ -34,8 +34,10 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(),
freq=likely_freq,
comment=source_spot["comment"],
sig=ActivityName.TOWERS,
sig_refs=[ActivityRef(id=source_spot["ref"], sig=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER)],
activities=[ActivityName.TOWERS],
activity_refs=[
ActivityRef(id=source_spot["ref"], activity=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER)
],
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.utc)
.timestamp(),
+7 -3
View File
@@ -92,9 +92,13 @@ class WOTA(HTTPSpotProvider):
freq=freq_hz,
mode=Mode.from_name(mode),
comment=comment,
sig=ActivityName.WOTA,
sig_refs=(
[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)]
activities=[ActivityName.WOTA],
activity_refs=(
[
ActivityRef(
id=ref, activity=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT
)
]
if ref
else []
),
+3 -3
View File
@@ -23,7 +23,7 @@ class WWBOTA(SSESpotProvider):
for ref in source_spot["references"]:
activity_ref = ActivityRef(
id=ref["reference"],
sig=ActivityName.WWBOTA,
activity=ActivityName.WWBOTA,
name=ref["name"],
latitude=ref["lat"],
longitude=ref["long"],
@@ -38,8 +38,8 @@ class WWBOTA(SSESpotProvider):
freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
comment=source_spot["comment"],
sig=ActivityName.WWBOTA,
sig_refs=refs,
activities=[ActivityName.WWBOTA],
activity_refs=refs,
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
# now, we will just pick the first one to use as our grid, latitude and longitude.
+3 -3
View File
@@ -30,11 +30,11 @@ class WWFF(HTTPSpotProvider):
freq=float(source_spot["frequency_khz"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"],
sig=ActivityName.WWFF,
sig_refs=[
activities=[ActivityName.WWFF],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
sig=ActivityName.WWFF,
activity=ActivityName.WWFF,
name=source_spot["reference_name"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"],
+9 -7
View File
@@ -14,16 +14,18 @@ class XOTA(WebsocketSpotProvider):
The provider typically doesn't give us a lat/lon or activity explicitly, so our own config provides an activity
which we can then use for lookups. This functionality is implemented for Toilets on the Air events, of which
there are several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which
is why we also provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3
might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
is why we also provide an activity_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01"
at C3 might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
ACTIVITY = None
def __init__(self, provider_config):
name = provider_config.get("name", "xOTA")
super().__init__(name, provider_config, provider_config["url"])
self.ACTIVITY = str(provider_config["sig"]) if "sig" in provider_config else None
self._activity_ref_prefix = str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else ""
self.ACTIVITY = str(provider_config["activity"]) if "activity" in provider_config else None
self._activity_ref_prefix = (
str(provider_config["activity_ref_prefix"]) if "activity_ref_prefix" in provider_config else ""
)
def _ws_message_to_spot(self, b):
string = b.decode("utf-8")
@@ -35,11 +37,11 @@ class XOTA(WebsocketSpotProvider):
dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()),
sig=self.ACTIVITY,
sig_refs=[
activities=[self.ACTIVITY] if self.ACTIVITY else [],
activity_refs=[
ActivityRef(
id=ref_id,
sig=self.ACTIVITY or "",
activity=self.ACTIVITY or "",
url=source_spot["reference"]["website"],
)
],
+3 -3
View File
@@ -35,11 +35,11 @@ class ZLOTA(HTTPSpotProvider):
freq=freq_hz,
mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"],
sig=ActivityName.ZLOTA,
sig_refs=[
activities=[ActivityName.ZLOTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
sig=ActivityName.ZLOTA,
activity=ActivityName.ZLOTA,
name=source_spot["name"],
)
],
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "spothole"
version = "2.2"
version = "3.0-pre"
authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" },
]
+86 -56
View File
@@ -15,6 +15,25 @@ info:
## Changelog
### 3.0
The term "SIG" (Special Interest Group), which Spothole inherited from ADIF, has been replaced with "activity" throughout the API.
* **Breaking change:** In spot and alert data, the single `sig` value has been replaced with `activities`, a list of unique activity names. A spot or alert can now be associated with more than one activity (e.g. a POTA and WWFF dual activation, a /MM activation via satellite, etc). The array is empty if there is no associated activity. `sig_refs` has been renamed to `activity_refs`.
* **Breaking change:** In activity reference data (i.e. each entry in `activity_refs` of a spot or alert, and the response of the activity reference lookup), `sig` has been renamed to `activity`.
* **Breaking change:** The `dx_location_source` value "SIG REF LOOKUP" has been renamed to "ACTIVITY REF LOOKUP".
* **Breaking change:** The `/spots`, `/spots/stream`, `/alerts` and `/alerts/stream` query parameter `sig` has been renamed to `activity`, and its special value `NO_SIG` to `NO_ACTIVITY`. The `/spots` and `/spots/stream` query parameters `needs_sig` and `needs_sig_ref` have been renamed to `needs_activity` and `needs_activity_ref`. The `activity` filter now matches any spot or alert that has at least one of the requested activities.
* **Breaking change:** `/lookup/sigref` has been renamed to `/lookup/activityref`, and its `sig` query parameter has been renamed to `activity`.
* **Breaking change:** POST `/spot` now expects `activities` (a list) and `activity_refs` in the `spot` object, rather than `sig` and `sig_refs`.
* **Breaking change:** In the `/options` response, `sigs` has been renamed to `activities`, and within each activity, `sig_type` has been renamed to `activity_type`.
* **Breaking change:** In the `/status` response, `sig_ref_data_providers` has been renamed to `activity_ref_data_providers`, and within each provider, `sig_name` has been renamed to `activity_name`.
#### Upgrading a client from v2 to v3 API endpoints
In v3.0 of Spothole, the `v2` (and `v1`) API endpoints will be maintained for backwards compatibility, so if you have written a client against the `v2` API, it will continue to receive `sig`, `sig_refs` etc. as before. Where a spot or alert has more than one activity, the `v2` and `v1` APIs will return only the first one as `sig`.
You are encouraged to move to the `v3` API endpoints as soon as possible. To upgrade, replace `v2` with `v3` in the URLs your code calls, then rename any use of the fields, query parameters and values listed above, and handle `activities` being a list rather than a single `sig` value. If you use the activity reference lookup, call `/lookup/activityref?activity=...&id=...` instead of `/lookup/sigref?sig=...&id=...`.
### 2.2
* Renamed AMSAT SIG to "Satellite" as AMSAT is a specific organisation not just a general term for satellite QSOs
@@ -102,10 +121,10 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: 2.0
version: 3.0
servers:
- url: https://spothole.app/api/v2
- url: https://spothole.app/api/v3
tags:
- name: Spots
@@ -396,7 +415,7 @@ paths:
example: "Failed"
/lookup/sigref:
/lookup/activityref:
get:
tags:
- Utilities
@@ -405,7 +424,7 @@ paths:
Perform a lookup of data about a certain reference, providing the activity and the ID of the
reference. An ActivityRef structure will be returned containing the activity and ID, plus any other
information Spothole could find about it.
operationId: sigref
operationId: activityref
parameters:
- $ref: '#/components/parameters/ActivityRefLookupActivity'
- $ref: '#/components/parameters/ActivityRefLookupId'
@@ -461,7 +480,7 @@ paths:
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the
`/options` response to see which activities and providers support upstream submission. cURL example:
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`"
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v3/spot`"
operationId: spot
requestBody:
description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it.
@@ -559,32 +578,32 @@ components:
schema:
$ref: "#/components/schemas/Source"
SpotActivity:
name: sig
name: activity
in: query
description: >
Limit the spots to only ones from one or more activities provided as an argument.
To select more than one activity, supply a comma-separated list. The special `sig` name `NO_SIG`
matches spots with no activity set. You can use `sig=NO_SIG` to specifically only return generic
spots with no associated activity. You can also use combinations to request for example POTA + no
activity, but reject other activities. If you want to request 'every activity and not No Activity', see the
`needs_sig` query parameter for a shortcut.
To select more than one activity, supply a comma-separated list. A spot matches if any of its activities are
in the list. The special `activity` name `NO_ACTIVITY` matches spots with no activity set. You can use
`activity=NO_ACTIVITY` to specifically only return generic spots with no associated activity. You can also use
combinations to request for example POTA + no activity, but reject other activities. If you want to request
'every activity but not No Activity', see the `needs_activity` query parameter for a shortcut.
schema:
$ref: "#/components/schemas/ActivityNameIncludingNoSig"
$ref: "#/components/schemas/ActivityNameIncludingNoActivity"
SpotNeedsActivity:
name: needs_sig
name: needs_activity
in: query
description: >
Limit the spots to only ones with an activity such as POTA. Because supplying all
known activities as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots
known activities as an `activity` parameter is unwieldy, and leaving `activity` blank will also return spots
with *no* activity, this parameter can be set true to return only spots with an activity, regardless of
what it is, so long as it's not blank. This is the equivalent of supplying the `sig` query
param with a list of every known activity apart from the special `NO_SIG` value. This is what Field
what it is, so long as it's not blank. This is the equivalent of supplying the `activity` query
param with a list of every known activity apart from the special `NO_ACTIVITY` value. This is what Field
Spotter uses to exclude generic cluster spots and only retrieve xOTA things.
schema:
type: boolean
default: false
SpotNeedsActivityRef:
name: needs_sig_ref
name: needs_activity_ref
in: query
description: >
Limit the spots to only ones which have at least one reference (e.g. a park reference) for
@@ -723,14 +742,13 @@ components:
schema:
$ref: "#/components/schemas/Source"
AlertActivity:
name: sig
name: activity
in: query
description: >
Limit the alerts to only ones from one or more activities. To select more than one
activity, supply a comma-separated list. The special value 'NO_SIG' can be included to return alerts
specifically without an associated activity (i.e. general DXpeditions).
activity, supply a comma-separated list. An alert matches if any of its activities are in the list.
schema:
$ref: "#/components/schemas/ActivityNameIncludingNoSig"
$ref: "#/components/schemas/ActivityNameIncludingNoActivity"
AlertDxContinent:
name: dx_continent
in: query
@@ -839,9 +857,9 @@ components:
type: string
example: M0TRT
ActivityRefLookupActivity:
name: sig
name: activity
in: query
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
description: Activity, e.g. outdoor activity programme such as POTA
required: true
schema:
$ref: "#/components/schemas/ActivityName"
@@ -938,11 +956,11 @@ components:
- EVENT
example: TRADITIONAL
ActivityNameIncludingNoSig:
ActivityNameIncludingNoActivity:
oneOf:
- $ref: "#/components/schemas/ActivityName"
- type: string
enum: [ NO_SIG ]
enum: [ NO_ACTIVITY ]
example: POTA
ActivityRefType:
@@ -1068,7 +1086,7 @@ components:
type: string
enum:
- SPOT
- "SIG REF LOOKUP"
- "ACTIVITY REF LOOKUP"
- "GRID"
- "HOME QTH"
- DXCC
@@ -1090,8 +1108,8 @@ components:
type: string
description: Activity reference ID.
example: GB-0001
sig:
description: Activity that this reference is in. Still named "sig" in the API for backwards compatibility.
activity:
description: Activity that this reference is in.
$ref: "#/components/schemas/ActivityName"
name:
type: string
@@ -1205,7 +1223,7 @@ components:
itself, or from a lookup of the activity ref (e.g. park) it's likely quite accurate, but if
we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will
be a lot less accurate. "SPOT" indicates the location source was the spot itself from the
spotting service. "SIG REF LOOKUP" indicates that the spot didn't provide a location,
spotting service. "ACTIVITY REF LOOKUP" indicates that the spot didn't provide a location,
but we looked it up from reference data. "GRID" indicates that the spot provided some
location data such as a Maidenhead, UK Ordnance Survey or Irish grid reference, but the
location is likely less accurate than "SPOT". "HOME QTH" indicates we looked up the DX
@@ -1218,7 +1236,7 @@ components:
type: boolean
description: >
Does the software think the location is good enough to put a marker on a map? This is
true if the source is "SPOT", "SIG REF LOOKUP" or "GRID", or alternatively if
true if the source is "SPOT", "ACTIVITY REF LOOKUP" or "GRID", or alternatively if
the source is "HOME QTH" and the callsign doesn't have a slash in it (i.e. operator
likely at home).
example: true
@@ -1315,14 +1333,21 @@ components:
type: string
description: Comment left by the spotter, if any
example: "59 in NY 73"
sig:
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
$ref: "#/components/schemas/ActivityName"
sig_refs:
activities:
type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName"
description: >
Activities, e.g. outdoor activity programmes such as POTA. There may be more than one, e.g. for a POTA plus
WWFF dual activation, or none. Each activity appears at most once. The first activity is the "primary" one,
e.g. the activity of the programme the spot came from, and is the one used to choose the icon.
example: [ "POTA", "WWFF" ]
activity_refs:
type: array
items:
$ref: '#/components/schemas/ActivityRef'
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named "sig_refs" in the API for backwards compatibility.
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
qrt:
type: boolean
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
@@ -1363,10 +1388,9 @@ components:
submit_upstream:
type: boolean
description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
/options response to see which activities and providers support this.
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather than only adding it
to this Spothole server. Requires `upstream_provider` to be set. Check `spot_submit_providers` in the
`/options` response to see which activities and providers support this.
default: false
upstream_provider:
type: string
@@ -1503,14 +1527,21 @@ components:
type: string
description: Comment made by the activator, if any
example: "2025 DXpedition to null island"
sig:
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
$ref: "#/components/schemas/ActivityName"
sig_refs:
activities:
type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName"
description: >
Activities, e.g. outdoor activity programmes such as POTA. There may be more than one, e.g. for a POTA and
WWFF dual activation, or none. Each activity appears at most once. The first activity is the "primary" one,
e.g. the activity of the programme the spot came from, and is the one used to choose the icon.
example: [ "POTA", "WWFF" ]
activity_refs:
type: array
items:
$ref: '#/components/schemas/ActivityRef'
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named "sig_refs" in the API for backwards compatibility.
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
url:
type: string
description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
@@ -1604,8 +1635,8 @@ components:
Activity:
type: object
description: >
Represents an activity (a term which replaces the older "Special Interest Group" or "SIG" terminology,
though `sig`-prefixed field names remain for API backwards compatibility).
Represents an activity, such as an outdoor activity programme (e.g. POTA), or another kind of operating
(e.g. Contest, DXpedition).
properties:
name:
description: The abbreviated name of the activity
@@ -1614,12 +1645,11 @@ components:
type: string
description: The full name of the activity
example: Parks on the Air
sig_type:
type: boolean
activity_type:
description: >
Whether the activity is traditional (e.g. EME), adventure (e.g. POTA), regional (e.g. WAB), or for a
specific event (e.g. MOTA). Generally for Spothole's own internal use, clients probably won't need this.
Used to group them in the web UI. Still named "sig_type" in the API for backwards compatibility.
Used to group them in the web UI.
$ref: "#/components/schemas/ActivityType"
has_refs:
type: boolean
@@ -1998,7 +2028,7 @@ components:
StaticDataProviderStatus:
type: object
properties:
sig_name:
name:
type: string
description: The name of the provider.
example: K0SWE
@@ -2020,9 +2050,9 @@ components:
ActivityRefDataProviderStatus:
type: object
properties:
sig_name:
activity_name:
type: string
description: The name of the activity. Still named "sig_name" in the API for backwards compatibility.
description: The name of the activity.
example: WWFF
enabled:
type: boolean
@@ -2046,7 +2076,7 @@ components:
CallsignDataProviderStatus:
type: object
properties:
sig_name:
name:
type: string
description: The name of the provider.
example: Country Files
@@ -2185,7 +2215,7 @@ components:
description: An array of all the static reference data providers.
items:
$ref: '#/components/schemas/StaticDataProviderStatus'
sig_ref_data_providers:
activity_ref_data_providers:
type: array
description: An array of all the activity reference data providers.
items:
@@ -2216,7 +2246,7 @@ components:
items:
type: string
example: "PHONE"
sigs:
activities:
type: array
description: An array of all the supported activities.
items:
@@ -2254,7 +2284,7 @@ components:
type: integer
description: >
The maximum age, in seconds, of any spot before it will be deleted by the system. When
querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there
querying the /api/v3/spots endpoint and providing a "max_age" or "since" parameter, there
is no point providing a number larger than this, because the system drops all spots older
than this.
example: 3600
+13 -13
View File
@@ -29,7 +29,7 @@ const PROVIDER_CREDENTIAL_SCHEMAS = {
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
// any saved values from local storage.
function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) {
$.getJSON('/api/v3/options', function (jsonData) {
// Store options
options = jsonData;
@@ -42,8 +42,8 @@ function loadOptions() {
});
// Populate activity drop-down
$.each(options["sigs"], function (i, activity) {
$('#sig').append($('<option>', {
$.each(options["activities"], function (i, activity) {
$('#activity').append($('<option>', {
value: activity.name,
text: activity.name
}));
@@ -91,8 +91,8 @@ function updateUpstreamArea() {
return;
}
const sig = $("#sig").val();
const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
const activity = $("#activity").val();
const providers = (activity && options["spot_submit_providers"][activity]) ? options["spot_submit_providers"][activity] : [];
if (providers.length === 0) {
$("#upstream-area").hide();
@@ -131,8 +131,8 @@ function updateCredentialsButton() {
// Get the currently selected upstream provider name
function getSelectedUpstreamProvider() {
const providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || [])
const providers = (options && options["spot_submit_providers"] && $("#activity").val())
? (options["spot_submit_providers"][$("#activity").val()] || [])
: [];
if (providers.length === 0) return null;
if (providers.length === 1) return providers[0];
@@ -197,8 +197,8 @@ function addSpot() {
const dx = $("#dx-call").val().toUpperCase();
const freqStr = $("#freq").val();
const mode = $("#mode")[0].value;
const sig = $("#sig")[0].value;
const sigRef = $("#sig-ref").val();
const activity = $("#activity")[0].value;
const activityRef = $("#activity-ref").val();
const dxGrid = $("#dx-grid").val();
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
@@ -208,8 +208,8 @@ function addSpot() {
spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode;
if (sig !== "") spot["sig"] = sig;
if (sigRef !== "") spot["sig_refs"] = [{sig: sig, id: sigRef}];
if (activity !== "") spot["activities"] = [activity];
if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment;
spot["de_call"] = de;
@@ -232,7 +232,7 @@ function addSpot() {
handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
}
$.ajax("/api/v2/spot", {
$.ajax("/api/v3/spot", {
data: JSON.stringify({spot, handling}),
contentType: 'application/json',
type: 'POST',
@@ -291,7 +291,7 @@ $("#mode").change(function () {
});
// Update upstream area and credentials button when activity changes
$("#sig").change(function () {
$("#activity").change(function () {
updateUpstreamArea();
});
+15 -15
View File
@@ -10,7 +10,7 @@ let alerts = [];
// to alerts
function loadAlerts() {
$.ajax({
url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
url: '/api/v3/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
// Store data
@@ -24,7 +24,7 @@ function loadAlerts() {
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() {
let str = "?";
["dx_continent", "source", "sig"].forEach(fn => {
["dx_continent", "source", "activity"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&";
}
@@ -210,14 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["dx_calls"] != null) {
dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", ");
}
if (dx_calls_html === "" && a["sig"] === "Contest") {
if (dx_calls_html === "" && a["activities"] != null && a["activities"].includes("Contest")) {
// Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest"
}
// Format DXpedition country
let dx_country_html = "";
if (a["sig"] === "DXpedition" && a["dx_country"] != null && a["dx_country"] !== "") {
if (a["activities"] != null && a["activities"].includes("DXpedition") && a["dx_country"] != null && a["dx_country"] !== "") {
dx_country_html = `<br/>${a["dx_country"]}`;
}
@@ -252,23 +252,23 @@ function addAlertRowsToTable(tbody, alerts) {
// Activity or fallback to "General DX"
let activityText = "General DX";
if (a["sig"]) {
activityText = a["sig"];
if (a["activities"] != null && a["activities"].length > 0) {
activityText = a["activities"].join(", ");
}
// Format activity refs
let activityRefs = "";
if (a["sig_refs"] != null) {
if (a["activity_refs"] != null) {
const items = [];
for (let i = 0; i < a["sig_refs"].length; i++) {
if (a["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${encodeURI(a["sig_refs"][i]["url"])}' title='${escapeHtml(a["sig_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(a["sig_refs"][i]["id"])}</a>`
for (let i = 0; i < a["activity_refs"].length; i++) {
if (a["activity_refs"][i]["url"] != null) {
items[i] = `<a href='${encodeURI(a["activity_refs"][i]["url"])}' title='${escapeHtml(a["activity_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(a["activity_refs"][i]["id"])}</a>`
} else {
items[i] = `${escapeHtml(a["sig_refs"][i]["id"])}`
items[i] = `${escapeHtml(a["activity_refs"][i]["id"])}`
}
// If this is a satellite alert the ref will just be the satellite, but DX grid is also important, so
// show that if we can.
if (a["sig_refs"][i]["sig"] === "Satellite" && a["dx_grid"] != null) {
if (a["activity_refs"][i]["activity"] === "Satellite" && a["dx_grid"] != null) {
items[i] += " from " + a["dx_grid"];
}
}
@@ -283,7 +283,7 @@ function addAlertRowsToTable(tbody, alerts) {
$tr.append(`<td class='nowrap'>${end_time_formatted}</td>`);
}
if (showDX) {
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span>${dx_calls_html}${dx_country_html}</td>`);
$tr.append(`<td><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span>${dx_calls_html}${dx_country_html}</td>`);
}
if (showFreqsModes) {
$tr.append(`<td class='hideonmobile'>${freqsModesText}</td>`);
@@ -330,7 +330,7 @@ function addAlertRowsToTable(tbody, alerts) {
// Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) {
$.getJSON('/api/v3/options', function (jsonData) {
// Store options
options = jsonData;
@@ -338,7 +338,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
// Alerts can only ever be tagged with activities that have an alert source, so only offer those as filters here
generateActivitiesMultiToggleFilterCard(options["sigs"].filter(o => o["alerts_possible"]), false);
generateActivitiesMultiToggleFilterCard(options["activities"].filter(o => o["alerts_possible"]), false);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
+5 -5
View File
@@ -20,7 +20,7 @@ function loadSpots() {
sseAbortController.abort();
}
$.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data
spots = jsonData;
// Update bands display
@@ -39,7 +39,7 @@ function startSSEConnection() {
sseAbortController = new AbortController();
// No need to include QRZ/HamQTH credentials because the information wouldn't be displayed on the bands panel anyway
fetchEventSource('/api/v2/spots/stream' + buildQueryString(), {
fetchEventSource('/api/v3/spots/stream' + buildQueryString(), {
signal: sseAbortController.signal,
openWhenHidden: true,
@@ -80,7 +80,7 @@ function expireOldSpots() {
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
["dx_continent", "de_continent", "mode", "source", "band", "activity"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&";
}
@@ -281,7 +281,7 @@ function removeDuplicatesForBandPanel(spotList) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) {
$.getJSON('/api/v3/options', function (jsonData) {
// Store options
options = jsonData;
@@ -295,7 +295,7 @@ function loadOptions() {
// Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]);
generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
+9 -9
View File
@@ -67,7 +67,7 @@ function loadURLParams() {
updateSelectFromParam(params, "limit", "alerts_to_fetch"); // Only on Alerts page
updateSelectFromParam(params, "max_age", "max_spot_age"); // Only on Map & Bands pages
updateFilterFromParam(params, "band", "band");
updateFilterFromParam(params, "sig", "sig");
updateFilterFromParam(params, "activity", "activity");
updateFilterFromParam(params, "source", "source");
updateFilterFromParam(params, "mode", "mode");
updateFilterFromParam(params, "dx_continent", "dx_continent");
@@ -156,41 +156,41 @@ function buildActivityFilterGrid(activity_options) {
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-activity storeable-checkbox" id="filter-button-activity-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-activity-${domSafeName}" for="filter-button-activity-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
});
return $grid;
}
// Generate activities filter card. This one is also a special case. includeGeneralDX controls whether the "General
// DX" (NO_SIG) option is offered - this doesn't apply on the alerts page, where every alert has an activity.
// DX" (NO_ACTIVITY) option is offered - this doesn't apply on the alerts page, where every alert has an activity.
function generateActivitiesMultiToggleFilterCard(activity_options, includeGeneralDX = true) {
const $list = $('<ul class="list-unstyled filter-section-list ps-0">');
const traditional = activity_options.filter(o => o["sig_type"] === "TRADITIONAL");
const traditional = activity_options.filter(o => o["activity_type"] === "TRADITIONAL");
appendActivityFilterSection($list, 'traditional', 'Traditional', true, includeGeneralDX || traditional.length > 0, $body => {
if (includeGeneralDX) {
$body.append(`<div class="w-100 mb-1"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$body.append(`<div class="w-100 mb-1"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-activity storeable-checkbox" id="filter-button-activity-NO_ACTIVITY" value="NO_ACTIVITY" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-activity-NO_ACTIVITY" for="filter-button-activity-NO_ACTIVITY"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
}
$body.append(buildActivityFilterGrid(traditional));
});
const adventure = activity_options.filter(o => o["sig_type"] === "ADVENTURE");
const adventure = activity_options.filter(o => o["activity_type"] === "ADVENTURE");
appendActivityFilterSection($list, 'adventure', 'Adventure', true, adventure.length > 0, $body => {
$body.append(buildActivityFilterGrid(adventure));
});
const regional = activity_options.filter(o => o["sig_type"] === "REGIONAL");
const regional = activity_options.filter(o => o["activity_type"] === "REGIONAL");
appendActivityFilterSection($list, 'regional', 'Regional', false, regional.length > 0, $body => {
$body.append(buildActivityFilterGrid(regional));
});
const event = activity_options.filter(o => o["sig_type"] === "EVENT");
const event = activity_options.filter(o => o["activity_type"] === "EVENT");
appendActivityFilterSection($list, 'event', 'Event', false, event.length > 0, $body => {
$body.append(buildActivityFilterGrid(event));
});
$("#activity-options").append($list);
$("#activity-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
$("#activity-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('activity', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('activity', false); return false;">None</a></div>`);
}
// Method called when "All" or "None" is clicked
+2 -2
View File
@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v2/solar', function (jsonData) {
$.getJSON('/api/v3/solar', function (jsonData) {
// HF
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v2/dxstats', function (jsonData) {
$.getJSON('/api/v3/dxstats', function (jsonData) {
dxStatsData = jsonData;
renderDxStats();
});
+13 -13
View File
@@ -45,7 +45,7 @@ function loadSpots() {
// 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
// data if they can.
$.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data
spots = jsonData;
// Update map
@@ -58,7 +58,7 @@ function loadSpots() {
// OK, we have credentials and have loaded once without them so the user has a basic map. Now reload
// with the credentials and replace what's on the map, so we can improve the data.
$.ajax({
url: '/api/v2/spots' + buildQueryString(),
url: '/api/v3/spots' + buildQueryString(),
dataType: 'json',
headers: getCredentialHeaders(),
success: function (jsonData2) {
@@ -85,7 +85,7 @@ function startSSEConnection() {
sseAbortController = new AbortController();
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
fetchEventSource('/api/v2/spots/stream' + buildQueryString(), {
fetchEventSource('/api/v3/spots/stream' + buildQueryString(), {
headers: getCredentialHeaders(),
signal: sseAbortController.signal,
openWhenHidden: true,
@@ -183,7 +183,7 @@ function removeSpotFromMap(key) {
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
["dx_continent", "de_continent", "mode", "source", "band", "activity"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&";
}
@@ -276,19 +276,19 @@ function getTooltipText(s) {
// Activity or fallback to source
let activitySourceText = s["source"];
if (s["sig"]) {
activitySourceText = s["sig"];
if (s["activities"] != null && s["activities"].length > 0) {
activitySourceText = s["activities"].join(", ");
}
// Format activity refs
let activityRefs = "";
if (s["sig_refs"] != null) {
if (s["activity_refs"] != null) {
const items = [];
for (let i = 0; i < s["sig_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='activity-ref-link'>${s["sig_refs"][i]["id"]}</a>`
for (let i = 0; i < s["activity_refs"].length; i++) {
if (s["activity_refs"][i]["url"] != null) {
items[i] = `<a href='${s["activity_refs"][i]["url"]}' title='${s["activity_refs"][i]["name"]}' target='_new' class='activity-ref-link'>${s["activity_refs"][i]["id"]}</a>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
items[i] = `${s["activity_refs"][i]["id"]}`
}
}
activityRefs = items.join(", ");
@@ -325,7 +325,7 @@ function getTooltipText(s) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) {
$.getJSON('/api/v3/options', function (jsonData) {
// Store options
options = jsonData;
@@ -339,7 +339,7 @@ function loadOptions() {
// Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]);
generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
+12 -12
View File
@@ -20,7 +20,7 @@ function loadSpots() {
// Make the new query. No credential headers on the first load to keep things quick
$.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data
spots = jsonData;
// Update table
@@ -43,7 +43,7 @@ function startSSEConnection() {
sseAbortController = new AbortController();
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
fetchEventSource('/api/v2/spots/stream' + buildQueryString(), {
fetchEventSource('/api/v3/spots/stream' + buildQueryString(), {
headers: getCredentialHeaders(),
signal: sseAbortController.signal,
openWhenHidden: true,
@@ -100,7 +100,7 @@ function startSSEConnection() {
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
["dx_continent", "de_continent", "mode", "source", "band", "activity"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&";
}
@@ -329,19 +329,19 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Format activity
let activityText = "General DX";
if (s["sig"]) {
activityText = s["sig"];
if (s["activities"] != null && s["activities"].length > 0) {
activityText = s["activities"].join(", ");
}
// Format activity refs
let activityRefs = "";
if (s["sig_refs"] != null) {
if (s["activity_refs"] != null) {
const items = [];
for (let i = 0; i < s["sig_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) {
items[i] = `<span style="white-space: nowrap;"><a href='${encodeURI(s["sig_refs"][i]["url"])}' title='${escapeHtml(s["sig_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(s["sig_refs"][i]["id"])}</a></span>`
for (let i = 0; i < s["activity_refs"].length; i++) {
if (s["activity_refs"][i]["url"] != null) {
items[i] = `<span style="white-space: nowrap;"><a href='${encodeURI(s["activity_refs"][i]["url"])}' title='${escapeHtml(s["activity_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(s["activity_refs"][i]["id"])}</a></span>`
} else {
items[i] = `<span style="white-space: nowrap;">${escapeHtml(s["sig_refs"][i]["id"])}</span>`
items[i] = `<span style="white-space: nowrap;">${escapeHtml(s["activity_refs"][i]["id"])}</span>`
}
}
activityRefs = items.join(", ");
@@ -464,7 +464,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) {
$.getJSON('/api/v3/options', function (jsonData) {
// Store options
options = jsonData;
@@ -478,7 +478,7 @@ function loadOptions() {
// Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]);
generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]);
+4 -4
View File
@@ -1,6 +1,6 @@
// Load server status
function loadStatus() {
$.getJSON('/api/v2/status', function (jsonData) {
$.getJSON('/api/v3/status', function (jsonData) {
$("#software_version").text(jsonData["software_version"]);
$("#server_owner_callsign").text(jsonData["server_owner_callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
@@ -54,10 +54,10 @@ function loadStatus() {
</div>`);
});
jsonData["sig_ref_data_providers"].forEach(p => {
$("#sig_ref_data_providers-status-container").append(`
jsonData["activity_ref_data_providers"].forEach(p => {
$("#activity_ref_data_providers-status-container").append(`
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>${p["sig_name"]}</strong></div>
<div class="col"><strong>${p["activity_name"]}</strong></div>
<div class="col">Status: ${p["status"]}</div>
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
<div class="col">References: ${p["enabled"] ? p["reference_count"] : "N/A"}</div>
+4 -4
View File
@@ -41,14 +41,14 @@
</select>
</div>
<div class="col-auto">
<label for="sig" class="form-label">Activity</label>
<select id="sig" class="form-select">
<label for="activity" class="form-label">Activity</label>
<select id="activity" class="form-select">
<option value="" selected></option>
</select>
</div>
<div class="col-auto">
<label for="sig-ref" class="form-label">Activity Reference</label>
<input type="text" class="form-control input-narrow" id="sig-ref" placeholder="e.g. GB-0001">
<label for="activity-ref" class="form-label">Activity Reference</label>
<input type="text" class="form-control input-narrow" id="activity-ref" placeholder="e.g. GB-0001">
</div>
<div class="col-auto">
<label for="dx-grid" class="form-label">DX Grid</label>
+4 -4
View File
@@ -23,7 +23,7 @@
which you can automatically use to generate a client skeleton using various software.
</li>
<li>Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
<code>https://spothole.app/api/v2/spots</code> once every few minutes. Apply filters if necessary.
<code>https://spothole.app/api/v3/spots</code> once every few minutes. Apply filters if necessary.
</li>
<li>Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do
that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
@@ -38,11 +38,11 @@
once every two minutes, so if your client is interested in POTA data there's no need to poll Spothole any more often
than that.</p>
<p>If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints
instead, e.g. <code>https://spothole.app/api/v2/spots/stream</code>.</p>
instead, e.g. <code>https://spothole.app/api/v3/spots/stream</code>.</p>
<p>If you want to handle different types of spot or alert differently within your client, please consider making a
single request to the Spothole API to retrieve all the data, then filtering on your side. For example, call
<code>https://spothole.app/api/v2/spots?sig=POTA,SOTA</code> rather than making two separate calls to
<code>https://spothole.app/api/v2/spots?sig=POTA</code> and <code>https://spothole.app/api/v2/spots?sig=SOTA</code>.
<code>https://spothole.app/api/v3/spots?activity=POTA,SOTA</code> rather than making two separate calls to
<code>https://spothole.app/api/v3/spots?activity=POTA</code> and <code>https://spothole.app/api/v3/spots?activity=SOTA</code>.
</p>
<p>Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique
twist: we don't tell you which side of the decimal point the nines start! (Translation: This is a hobby project.
+3 -3
View File
@@ -13,7 +13,7 @@
<p>These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in
dark mode, use <code>https://spothole.app/bands?embedded=true&amp;dark-mode=true</code>. For an embedded version of
the main spots/home page in the system light/dark mode, use <code>https://spothole.app/?embedded=true</code>. For
dark mode showing 70cm TOTA spots only, use <code>https://spothole.app/?embedded=true&amp;dark-mode=true&amp;sig=TOTA&amp;band=70cm</code>.
dark mode showing 70cm TOTA spots only, use <code>https://spothole.app/?embedded=true&amp;dark-mode=true&amp;activity=TOTA&amp;band=70cm</code>.
Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's
browser.</p>
<p>The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API,
@@ -82,10 +82,10 @@
</td>
</tr>
<tr>
<td><code>sig</code></td>
<td><code>activity</code></td>
<td>Comma-separated list</td>
<td>(all)</td>
<td><code>?sig=POTA,SOTA,NO_SIG</code></td>
<td><code>?activity=POTA,SOTA,NO_ACTIVITY</code></td>
<td>Sets the list of activities that will be shown on the spots, bands and map pages. Available options
match the labels of the buttons in the standard web interface.
</td>
+1 -1
View File
@@ -82,7 +82,7 @@
<div class="card-header">
Activity Reference Data Providers
</div>
<div class="card-body" id="sig_ref_data_providers-status-container">
<div class="card-body" id="activity_ref_data_providers-status-container">
</div>
</div>
+22 -25
View File
@@ -21,7 +21,7 @@ RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)"""
"""API request handler for /api/v3/spot (POST)"""
def __init__(
self,
@@ -142,24 +142,19 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Reject if activity ref format incorrect for activity
if (
spot.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
):
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}."
# Reject if any activity ref format is incorrect for its activity
for activity_ref in spot.activity_refs:
ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None
if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{activity_ref.id}' does not look like a valid reference for {activity_ref.activity}."
)
)
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
@@ -171,13 +166,14 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.sig:
if not spot.activities:
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
self.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.sig_refs and upstream_provider_name != "Tiles":
if not spot.activity_refs and upstream_provider_name != "Tiles":
self.set_status(422)
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
self.set_header("Cache-Control", "no-store")
@@ -201,7 +197,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.sig)
provider = self._find_provider(upstream_provider_name, spot.activities)
if provider:
try:
# Submit spot to the upstream provider
@@ -216,12 +212,13 @@ class APISpotHandler(tornado.web.RequestHandler):
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
)
else:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.sig if spot.sig else ''} spots."
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} 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.source = "API"
spot.infer_missing()
self._spots.set(spot.id, spot)
@@ -241,11 +238,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activity) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity."""
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for at least one of the given activities."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(activity):
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
return p
return None
+10 -10
View File
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts"""
"""API request handler for /api/v3/alerts"""
def __init__(
self,
@@ -69,7 +69,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/alerts/stream"""
"""API request handler for /api/v3/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None
@@ -169,13 +169,13 @@ def alert_allowed_by_query(alert, query):
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check.
if (
alert.sig == ActivityName.DXPEDITION
ActivityName.DXPEDITION in alert.activities
and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
):
continue
if (
alert.sig == ActivityName.CONTEST
ActivityName.CONTEST in alert.activities
and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
):
@@ -186,14 +186,14 @@ def alert_allowed_by_query(alert, query):
sources = query.get(k).split(",")
if not alert.source or alert.source not in sources:
return False
case "sig":
# If a list of activities is provided, the alert must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches alerts with no activity.
case "activity":
# If a list of activities is provided, the alert must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not alert.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not alert.activities and not include_no_activity:
return False
if alert.sig and alert.sig not in activities:
if alert.activities and not any(a in activities for a in alert.activities):
return False
case "dx_continent":
dxconts = query.get(k).split(",")
@@ -0,0 +1,105 @@
import json
import tornado.web
from tornado_eventsource.handler import EventSourceHandler
from core.utils import safe_json_dumps
class CompatibilityWrapper(tornado.web.RequestHandler):
"""Base class for compatibility wrappers. These provide translation between the different API versions, so while
the code itself only has proper handlers for the latest API version, we can still support the older APIs by wrapping
our API in one of these. The wrapper handles translating the old format of incoming data into the latest format,
then translating the output back into the format that an older client will expect. We actually do this in stages
because the API version change sets sit on top of each other, so for example a v1 request might go through two
wrappers, one to bring it up to v2, and the next to bring it up to v3, then the API call happens, then we go back
down through the wrappers in the other direction."""
def prepare(self):
self.translate_request()
super().prepare()
def translate_request(self):
"""Translate self.request in place from an older API version to the newer one. Overrides should do their own
translation, *then* call super(), so translation proceeds from oldest to newest."""
def translate_response_object(self, obj):
"""Translate a decoded JSON response from the newer API version to the older one. Overrides should call super()
*first*, then do their own translation, so translation proceeds from newest to oldest."""
return obj
def _translate_response(self, chunk):
"""Translate a JSON string output by the newer API version into its older equivalent. Anything that isn't a
JSON string is passed through untouched."""
if not isinstance(chunk, str):
return chunk
try:
return safe_json_dumps(self.translate_response_object(json.loads(chunk)))
except ValueError:
return chunk
class RequestCompatibilityWrapper(CompatibilityWrapper):
"""Compatibility wrapper for normal requests, which translates everything the handler produces."""
def write(self, chunk):
super().write(self._translate_response(chunk))
class StreamCompatibilityWrapper(CompatibilityWrapper, EventSourceHandler):
"""Special case for the SSE stream handlers, which write messages one at a time."""
def write_message(self, name=None, msg=True, wait=None, evt_id=None):
if not name:
msg = self._translate_response(msg)
return super().write_message(name=name, msg=msg, wait=wait, evt_id=evt_id)
def rename_query_params(request, name_map, value_map):
"""Utility method to rename query parameters in the request according to name_map ({old: new}), and rename values
of query parameters according to value_map ({param name: {old: new}}). Comma-separated lists of values are
supported, because we need to handle mapping e.g. "sig=POTA,NO_SIG" to "activity=POTA,NO_ACTIVITY" between v2 and
v3. value_map uses the new parameter names."""
for arguments in (request.arguments, request.query_arguments):
for old_name, new_name in name_map.items():
if old_name in arguments:
arguments[new_name] = arguments.pop(old_name)
for name, values in value_map.items():
if name in arguments:
arguments[name] = [
",".join(values.get(item.strip(), item) for item in v.decode("utf-8").split(",")).encode("utf-8")
for v in arguments[name]
]
def rename_keys_and_values(obj, key_map, value_map):
"""Utility method to rename keys in a JSON object according to key_map ({old: new}), and string values according
to value_map ({old key name: {old: new}}). The object can be list-like or dict-like. This function calls itself
recursively as it goes down the tree of stuff inside a dict."""
if isinstance(obj, dict):
translated = {}
for k, v in obj.items():
if k in value_map and isinstance(v, str):
v = value_map[k].get(v, v)
translated[key_map.get(k, k)] = rename_keys_and_values(v, key_map, value_map)
return translated
if isinstance(obj, list):
return [rename_keys_and_values(i, key_map, value_map) for i in obj]
return obj
def translate_json_body(request, translate):
"""Utility method to replace the request body by decoding it as JSON, passing it through the supplied translate
function, and re-encoding it. This is used to handle the add spot API call where the user is supplying JSON data as
a request body that contains the spot information. If the body is empty or invalid JSON, it is returned as-is so the
handler can return the appropriate error."""
try:
body = json.loads(request.body)
except ValueError:
return
request.body = json.dumps(translate(body)).encode("utf-8")
@@ -0,0 +1,113 @@
from webserver.handlers.api.compatibility.compatibility import (
CompatibilityWrapper,
rename_keys_and_values,
translate_json_body,
)
from webserver.handlers.api.compatibility.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APIDxStatsHandler,
V2APILookupCallHandler,
V2APILookupGridHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISolarConditionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
# QRZ/HamQTH credentials were provided as query parameters in v1, but as headers in v2
_V1_QUERY_PARAMS_TO_V2_HEADERS = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
# DX location source of "GRID" from a v2 response becomes "SPOT" to a v1 client.
_V2_TO_V1_RESPONSE_VALUES = {
"dx_location_source": {"GRID": "SPOT"},
}
class V1CompatibilityWrapper(CompatibilityWrapper):
"""Translates v1 requests to v2 on the way in, and v2 responses to v1 on the way out. This logic captures the
majority of changes needed for each endpoint."""
def translate_request(self):
"""This one is a bit more than a simple translation of query params beceause we also need to move some query
params to instead be headers."""
for param, header in _V1_QUERY_PARAMS_TO_V2_HEADERS.items():
if header in self.request.headers:
continue
value = self.get_query_argument(param, default=None)
if value:
self.request.headers[header] = value
super().translate_request()
def translate_response_object(self, obj):
obj = super().translate_response_object(obj)
return rename_keys_and_values(obj, {}, _V2_TO_V1_RESPONSE_VALUES)
class V1APISpotsHandler(V1CompatibilityWrapper, V2APISpotsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISpotsStreamHandler(V1CompatibilityWrapper, V2APISpotsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIAlertsHandler(V1CompatibilityWrapper, V2APIAlertsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIAlertsStreamHandler(V1CompatibilityWrapper, V2APIAlertsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISolarConditionsHandler(V1CompatibilityWrapper, V2APISolarConditionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIDxStatsHandler(V1CompatibilityWrapper, V2APIDxStatsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIOptionsHandler(V1CompatibilityWrapper, V2APIOptionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APIStatusHandler(V1CompatibilityWrapper, V2APIStatusHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupCallHandler(V1CompatibilityWrapper, V2APILookupCallHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupSigRefHandler(V1CompatibilityWrapper, V2APILookupSigRefHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APILookupGridHandler(V1CompatibilityWrapper, V2APILookupGridHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V1APISpotHandler(V1CompatibilityWrapper, V2APISpotHandler):
"""Some special handling required for this one, the v1 add spot call took its data in from query parameters, but in
v2 we changed that to using a request body with the data in, so we need to recreate that here before we pass on
handling to the v2 call."""
def translate_request(self):
def translate(body):
if isinstance(body, dict):
return {"spot": body}
return body
translate_json_body(self.request, translate)
super().translate_request()
@@ -0,0 +1,157 @@
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.compatibility.compatibility import (
CompatibilityWrapper,
RequestCompatibilityWrapper,
StreamCompatibilityWrapper,
rename_keys_and_values,
rename_query_params,
translate_json_body,
)
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import APILookupActivityRefHandler, APILookupCallHandler, APILookupGridHandler
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
# Query parameters renamed in v3
_V2_TO_V3_QUERY_PARAMS = {
"sig": "activity",
"needs_sig": "needs_activity",
"needs_sig_ref": "needs_activity_ref",
}
# Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"},
"fields": {"sig": "activities", "sig_refs": "activity_refs"},
}
# Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = {
"activity": "sig",
"activity_refs": "sig_refs",
"activity_type": "sig_type",
"activities": "sigs",
"activity_ref_data_providers": "sig_ref_data_providers",
"activity_name": "sig_name",
}
# Values of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_VALUES = {
"dx_location_source": {"ACTIVITY REF LOOKUP": "SIG REF LOOKUP"},
}
class V2CompatibilityWrapper(CompatibilityWrapper):
"""Translates v2 requests to v3 on the way in, and v3 responses to v2 on the way out. This logic captures the
majority of changes needed for each endpoint."""
def translate_request(self):
rename_query_params(self.request, _V2_TO_V3_QUERY_PARAMS, _V2_TO_V3_QUERY_VALUES)
super().translate_request()
def translate_response_object(self, obj):
obj = super().translate_response_object(obj)
return rename_keys_and_values(obj, _V3_TO_V2_RESPONSE_KEYS, _V3_TO_V2_RESPONSE_VALUES)
class V2SpotsAlertsCompatibilityWrapper(V2CompatibilityWrapper):
"""Extra translation for spots and alerts. In v3 these have a list of "activities" rather than a single activity,
so for v2 we collapse this back down to a single value using the first activity in the list. This must happen
before the generic key renaming, which would otherwise rename "activities" to "sigs" as it does for /options."""
def translate_response_object(self, obj):
return super().translate_response_object(collapse_activities(obj))
class V2APISpotsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APISpotsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISpotsStreamHandler(V2SpotsAlertsCompatibilityWrapper, StreamCompatibilityWrapper, APISpotsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APIAlertsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsStreamHandler(V2SpotsAlertsCompatibilityWrapper, StreamCompatibilityWrapper, APIAlertsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISolarConditionsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISolarConditionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIDxStatsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIDxStatsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIOptionsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIOptionsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIStatusHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIStatusHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupCallHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupCallHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupSigRefHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupActivityRefHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APILookupGridHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APILookupGridHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISpotHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISpotHandler):
"""Some special handling for add spot, because unlike the other calls we have to deal with a request body.
Because the definition of a spot changed (e.g. sig to activity) we need to apply the same translation to
spots that are coming in via the add spot call."""
def translate_request(self):
def translate(body):
if isinstance(body, dict) and isinstance(body.get("spot"), dict):
body["spot"] = self._translate_v2_spot(body["spot"])
return body
translate_json_body(self.request, translate)
super().translate_request()
def _translate_v2_spot(self, spot_data):
"""Translate a spot provided by a client calling the add spot method in v2 format into v3 format"""
spot_data = dict(spot_data)
if "sig" in spot_data:
sig = spot_data.pop("sig")
spot_data["activities"] = [sig] if sig else []
if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list):
refs = []
for ref in spot_data["activity_refs"]:
if isinstance(ref, dict) and "sig" in ref:
ref = dict(ref)
ref["activity"] = ref.pop("sig")
refs.append(ref)
spot_data["activity_refs"] = refs
return spot_data
def collapse_activities(obj):
"""Utility method to replace the "activities" list in a spot or alert JSON object with a single "activity" value,
being the first activity in the list, or None if there are none. The object can be a single spot/alert dict, or a
list of them. Anything else is returned untouched. Used to translate v3's list of activities to the single sig
expected in v2 API calls."""
if isinstance(obj, list):
return [collapse_activities(i) for i in obj]
if isinstance(obj, dict) and "activities" in obj:
obj = dict(obj)
activities = obj.pop("activities")
obj["activity"] = activities[0] if activities else None
return obj
+1 -1
View File
@@ -20,7 +20,7 @@ HF_BANDS = [b.name for b in BANDS if b.is_ham_hf]
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats"""
"""API request handler for /api/v3/dxstats"""
def __init__(
self,
+9 -9
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call"""
"""API request handler for /api/v3/lookup/call"""
def __init__(
self,
@@ -63,7 +63,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupActivityRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/sigref"""
"""API request handler for /api/v3/lookup/activityref"""
def __init__(
self,
@@ -79,16 +79,16 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# "sig" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# "activity" and "id" query params must exist, the activity must be known, and if we have a reference regex for
# that activity, the provided id must match it.
if "sig" in query_params and "id" in query_params:
activity = str(query_params.get("sig")).upper()
if "activity" in query_params and "id" in query_params:
activity = str(query_params.get("activity")).upper()
ref_id = str(query_params.get("id")).upper()
if get_activity_by_name(activity):
if not get_ref_regex_for_activity(activity) or re.match(
get_ref_regex_for_activity(activity), ref_id
):
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, sig=activity))
data = populate_missing_activity_ref_info(ActivityRef(id=ref_id, activity=activity))
self.write(safe_json_dumps(data))
else:
@@ -99,10 +99,10 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
)
self.set_status(422)
else:
self.write(safe_json_dumps(f"Error - sig '{activity}' is not known."))
self.write(safe_json_dumps(f"Error - activity '{activity}' is not known."))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - sig and id must be provided"))
self.write(safe_json_dumps("Error - activity and id must be provided"))
self.set_status(422)
except Exception:
@@ -115,7 +115,7 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/grid"""
"""API request handler for /api/v3/lookup/grid"""
def __init__(
self,
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options"""
"""API request handler for /api/v3/options"""
def __init__(
self,
@@ -75,7 +75,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"bands": BANDS,
"modes": [m.value for m in Mode],
"mode_types": [t.value for t in ModeType],
"sigs": list(ACTIVITIES.values()),
"activities": list(ACTIVITIES.values()),
"spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers,
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar"""
"""API request handler for /api/v3/solar"""
def __init__(
self,
+13 -13
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots"""
"""API request handler for /api/v3/spots"""
def __init__(
self,
@@ -68,7 +68,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/spots/stream"""
"""API request handler for /api/v3/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None
@@ -196,25 +196,25 @@ def spot_allowed_by_query(spot, query):
sources = query.get(k).split(",")
if not spot.source or spot.source not in sources:
return False
case "sig":
# If a list of activities is provided, the spot must have an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches spots with no activity.
case "activity":
# If a list of activities is provided, the spot must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities
if not spot.sig and not include_no_activity:
include_no_activity = "NO_ACTIVITY" in activities
if not spot.activities and not include_no_activity:
return False
if spot.sig and spot.sig not in activities:
if spot.activities and not any(a in activities for a in spot.activities):
return False
case "needs_sig":
case "needs_activity":
# If true, an activity is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
# exclusive with supplying the special "NO_ACTIVITY" parameter to the "activity" query param.
needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.sig:
if needs_activity and not spot.activities:
return False
case "needs_sig_ref":
case "needs_activity_ref":
# If true, at least one activity ref is required, regardless of what it is, it just can't be missing.
needs_activity_ref = query.get(k).upper() == "TRUE"
if needs_activity_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
if needs_activity_ref and (not spot.activity_refs or len(spot.activity_refs) == 0):
return False
case "band":
bands = query.get(k).split(",")
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status"""
"""API request handler for /api/v3/status"""
def __init__(
self,
-141
View File
@@ -1,141 +0,0 @@
import logging
import re
from typing import Any
import tornado
from tornado import httputil
from tornado.web import Application
from core.activity_utils import get_ref_regex_for_activity
from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
logger = logging.getLogger(__name__)
class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in early Spothole v2 for backwards compatibility."""
def __init__(
self,
application: "Application",
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._spots = None
super().__init__(application, request, **kwargs)
def initialize(self, spots):
self._spots = spots
def post(self):
try:
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if format not 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")
self.set_header("Content-Type", "application/json")
return
# Reject if request body is empty
post_data = self.request.body
if not post_data:
self.set_status(422)
self.write(safe_json_dumps("Error - request body is empty"))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# 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)
# 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.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
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("Content-Type", "application/json")
return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
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("Content-Type", "application/json")
return
# 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.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(),
):
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")
self.set_header("Content-Type", "application/json")
return
# Reject if activity ref format incorrect for activity
if (
spot.sig
and spot.sig_refs
and len(spot.sig_refs) > 0
and spot.sig_refs[0].id
and get_ref_regex_for_activity(spot.sig)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id)
):
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.set_header("Cache-Control", "no-store")
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.set(spot.id, spot)
self.write(safe_json_dumps("OK"))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception:
logger.exception("Exception when handling client request to add spot API")
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
@@ -1,68 +0,0 @@
import logging
import tornado
from tornado.httpclient import AsyncHTTPClient
from tornado.httputil import HTTPHeaders
logger = logging.getLogger(__name__)
_LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
class V1RedirectHandler(tornado.web.RequestHandler):
"""Transparently proxies requests from the old API to the new one,
returning whatever the v2 endpoint returns, for endpoints with no breaking changes."""
async def _proxy(self, path):
new_url = f"{self.request.protocol}://{self.request.host}/api/v2/{path}"
if self.request.query:
new_url += f"?{self.request.query}"
# Copy the incoming headers so we can add translated legacy credentials without changing the original
# request.
headers = HTTPHeaders(self.request.headers)
for param, header in _LEGACY_PARAM_TO_HEADER_MAP.items():
value = self.get_query_argument(param, default=None)
if value:
headers[header] = value
client = AsyncHTTPClient()
try:
response = await client.fetch(
new_url,
method=self.request.method,
headers=headers,
body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False,
follow_redirects=False,
request_timeout=10.0,
)
except Exception as e:
logger.exception("Exception when proxying legacy v1 API request")
raise tornado.web.HTTPError(502, reason=str(e))
self.set_status(response.code, response.reason)
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",
):
self.add_header(name, value)
if response.body:
self.write(response.body)
async def get(self, path):
await self._proxy(path)
async def post(self, path):
await self._proxy(path)
-55
View File
@@ -1,55 +0,0 @@
import re
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
_LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
"qrz_session_key": "X-QRZ-Session-Key",
"hamqth_username": "X-HamQTH-Username",
"hamqth_password": "X-HamQTH-Password",
"hamqth_session_id": "X-HamQTH-Session-ID",
}
def _handle_legacy_params(handler):
"""Copy v1 query-string QRZ/HamQTH credentials into the v2 headers, so the v2 handler can see them"""
for param, header in _LEGACY_PARAM_TO_HEADER_MAP.items():
if header in handler.request.headers:
continue
value = handler.get_query_argument(param, default=None)
if value:
handler.request.headers[header] = value
class V1APISpotsHandler(APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in early Spothole v2 for backwards compatibility."""
def prepare(self):
_handle_legacy_params(self)
super().prepare()
def write(self, chunk):
if isinstance(chunk, str):
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."""
def prepare(self):
_handle_legacy_params(self)
super().prepare()
def write_message(self, *args, **kwargs):
args = list(args)
for i, a in enumerate(args):
if isinstance(a, str) and '"dx_location_source"' in a:
args[i] = _GRID_SOURCE_RE.sub('"dx_location_source": "SPOT"', a)
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)
+131 -19
View File
@@ -17,6 +17,34 @@ from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.compatibility.v1_compatibility import (
V1APIAlertsHandler,
V1APIAlertsStreamHandler,
V1APIDxStatsHandler,
V1APILookupCallHandler,
V1APILookupGridHandler,
V1APILookupSigRefHandler,
V1APIOptionsHandler,
V1APISolarConditionsHandler,
V1APISpotHandler,
V1APISpotsHandler,
V1APISpotsStreamHandler,
V1APIStatusHandler,
)
from webserver.handlers.api.compatibility.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APIDxStatsHandler,
V2APILookupCallHandler,
V2APILookupGridHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISolarConditionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import (
APILookupActivityRefHandler,
@@ -27,9 +55,6 @@ from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
from webserver.handlers.api.v1_addspot import V1APISpotHandler
from webserver.handlers.api.v1_compatability import V1RedirectHandler
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
from webserver.handlers.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
@@ -107,50 +132,50 @@ class WebServer:
# API endpoints are always enabled
api_routes = [
(
r"/api/v2/spots",
r"/api/v3/spots",
APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
r"/api/v3/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
r"/api/v3/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
r"/api/v3/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
r"/api/v3/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
r"/api/v3/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
r"/api/v3/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
r"/api/v3/status",
APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupActivityRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler),
(r"/api/v3/lookup/call", APILookupCallHandler),
(r"/api/v3/lookup/activityref", APILookupActivityRefHandler),
(r"/api/v3/lookup/grid", APILookupGridHandler),
(
r"/api/v2/spot",
r"/api/v3/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
@@ -159,27 +184,114 @@ class WebServer:
),
]
# 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.
# v2 API compatibility routes. Translation wrappers convert data to the v3 format and back.
v2_compat_routes = [
(
r"/api/v2/spots",
V2APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
V2APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
V2APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
V2APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
V2APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
V2APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
V2APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
V2APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", V2APILookupCallHandler),
(r"/api/v2/lookup/sigref", V2APILookupSigRefHandler),
(r"/api/v2/lookup/grid", V2APILookupGridHandler),
(
r"/api/v2/spot",
V2APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
]
# Translation wrappers convert data to the v3 format and back.
v1_compat_routes = [
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v1/alerts",
V1APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v1/alerts/stream",
V1APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v1/solar",
V1APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v1/dxstats",
V1APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v1/options",
V1APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v1/status",
V1APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v1/lookup/call", V1APILookupCallHandler),
(r"/api/v1/lookup/sigref", V1APILookupSigRefHandler),
(r"/api/v1/lookup/grid", V1APILookupGridHandler),
(
r"/api/v1/spot",
V1APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
(r"/api/v1/(.*)", V1RedirectHandler),
]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
@@ -253,7 +365,7 @@ class WebServer:
]
app = tornado.web.Application(
api_routes + v1_compat_routes + ui_routes + misc_routes,
api_routes + v2_compat_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False,