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" 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 # 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. # 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 # 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 # 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!) # 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" - class: "XOTA"
name: "EH23 TOTA" name: "EH23 TOTA"
enabled: false enabled: false
url: "wss://eh23.totawatch.de/api/spot/live" url: "wss://eh23.totawatch.de/api/spot/live"
sig: "Toilets" activity: "Toilets"
sig_ref_prefix: "EH" activity_ref_prefix: "EH"
- class: "XOTA" - class: "XOTA"
name: "HOPE26 TOTA" name: "HOPE26 TOTA"
enabled: false enabled: false
url: "wss://hope-26.totawatch.de/api/spot/live" url: "wss://hope-26.totawatch.de/api/spot/live"
sig: "Toilets" activity: "Toilets"
sig_ref_prefix: "HOPE" activity_ref_prefix: "HOPE"
# Alert providers to use. Same setup as the spot providers list above. # 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 # 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. # maps WWFF park IDs to their name and location.
sig_ref_data_providers: activity_ref_data_providers:
- class: "POTA" - class: "POTA"
enabled: true enabled: true
+3 -3
View File
@@ -27,7 +27,7 @@ def get_activity_ref_info(activity_name, ref_id):
ref_id = ref_id.replace(" ", "-") ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned # 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 # We can always get the reference type and the icon from the activity itself
activity = get_activity_by_name(activity_name) 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): 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 """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 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.""" 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: if lookup_data:
# Copy new activity ref data into existing object where data was previously missing # 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 from data.band import Band
# General software # General software
SOFTWARE_VERSION = "2.2" SOFTWARE_VERSION = "3.0-pre"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
+5 -5
View File
@@ -15,7 +15,7 @@ class DataProviders:
self.alert_providers = [] self.alert_providers = []
self.solar_condition_providers = [] self.solar_condition_providers = []
self.static_data_providers = [] self.static_data_providers = []
self.sig_ref_data_providers = [] self.activity_ref_data_providers = []
self.callsign_data_providers = [] self.callsign_data_providers = []
self._startup_timers = [] self._startup_timers = []
@@ -28,8 +28,8 @@ class DataProviders:
self.solar_condition_providers.append(create_provider_from_config("providers.solarconditions", entry)) self.solar_condition_providers.append(create_provider_from_config("providers.solarconditions", entry))
for entry in config.get("static_data_providers", []): for entry in config.get("static_data_providers", []):
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry)) self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
for entry in config.get("sig_ref_data_providers", []): for entry in config.get("activity_ref_data_providers", []):
self.sig_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry)) self.activity_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
for entry in config.get("callsign_data_providers", []): for entry in config.get("callsign_data_providers", []):
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry)) self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@@ -54,7 +54,7 @@ class DataProviders:
25.0, 25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"), 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: for t in self._startup_timers:
t.daemon = True t.daemon = True
@@ -72,7 +72,7 @@ class DataProviders:
self.spot_providers self.spot_providers
+ self.alert_providers + self.alert_providers
+ self.solar_condition_providers + self.solar_condition_providers
+ self.sig_ref_data_providers + self.activity_ref_data_providers
+ self.static_data_providers + self.static_data_providers
+ self.callsign_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.""" """Where the location data came from in a spot."""
SPOT = "SPOT" SPOT = "SPOT"
SIG_REF_LOOKUP = "SIG REF LOOKUP" ACTIVITY_REF_LOOKUP = "ACTIVITY REF LOOKUP"
GRID = "GRID" GRID = "GRID"
HOME_QTH = "HOME QTH" HOME_QTH = "HOME QTH"
DXCC = "DXCC" DXCC = "DXCC"
+3 -3
View File
@@ -111,9 +111,9 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.static_data_providers 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, "enabled": p.enabled,
"status": p.status, "status": p.status,
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp() "last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
@@ -121,7 +121,7 @@ class StatusReporter:
else 0, else 0,
"reference_count": p.reference_count, "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"] = [ DATA_STORE.status.get()["callsign_data_providers"] = [
{ {
+43 -43
View File
@@ -5,7 +5,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.CONTEST: Activity( ActivityName.CONTEST: Activity(
name=ActivityName.CONTEST, name=ActivityName.CONTEST,
description="Contest", description="Contest",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
# No sensible way to determine *which* contest, but if we set comment_names=["CONTEST"] then at least # 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( ActivityName.DXPEDITION: Activity(
name=ActivityName.DXPEDITION, name=ActivityName.DXPEDITION,
description="Radio expedition to a remote location", description="Radio expedition to a remote location",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
comment_names=["DXPEDITION"], comment_names=["DXPEDITION"],
@@ -27,7 +27,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SATELLITE: Activity( ActivityName.SATELLITE: Activity(
name=ActivityName.SATELLITE, name=ActivityName.SATELLITE,
description="Amateur Radio 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 # 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. # matches some of the most commonly used amateur radio satellites so they can be picked out of spot comments.
has_refs=True, has_refs=True,
@@ -41,7 +41,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.EME: Activity( ActivityName.EME: Activity(
name=ActivityName.EME, name=ActivityName.EME,
description="Earth-Moon-Earth (Moonbounce)", description="Earth-Moon-Earth (Moonbounce)",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
comment_names=[], comment_names=[],
@@ -50,7 +50,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.AERONAUTICAL_MOBILE: Activity( ActivityName.AERONAUTICAL_MOBILE: Activity(
name=ActivityName.AERONAUTICAL_MOBILE, name=ActivityName.AERONAUTICAL_MOBILE,
description="Aeronautical Mobile", description="Aeronautical Mobile",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
# Don't pick /AM out of comments, spot.py will handle picking it out of the callsign # 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( ActivityName.MARITIME_MOBILE: Activity(
name=ActivityName.MARITIME_MOBILE, name=ActivityName.MARITIME_MOBILE,
description="Maritime Mobile", description="Maritime Mobile",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
# Don't pick /MM out of comments, spot.py will handle picking it out of the callsign # 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( ActivityName.QRP: Activity(
name=ActivityName.QRP, name=ActivityName.QRP,
description="Low power", description="Low power",
sig_type=ActivityType.TRADITIONAL, activity_type=ActivityType.TRADITIONAL,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
comment_names=["QRP"], comment_names=["QRP"],
@@ -80,7 +80,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.POTA: Activity( ActivityName.POTA: Activity(
name=ActivityName.POTA, name=ActivityName.POTA,
description="Parks on the Air", description="Parks on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
@@ -92,7 +92,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SOTA: Activity( ActivityName.SOTA: Activity(
name=ActivityName.SOTA, name=ActivityName.SOTA,
description="Summits on the Air", description="Summits on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
@@ -104,7 +104,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WWFF: Activity( ActivityName.WWFF: Activity(
name=ActivityName.WWFF, name=ActivityName.WWFF,
description="World Wide Flora & Fauna", description="World Wide Flora & Fauna",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
@@ -116,7 +116,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.GMA: Activity( ActivityName.GMA: Activity(
name=ActivityName.GMA, name=ActivityName.GMA,
description="Global Mountain Activity", description="Global Mountain Activity",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
@@ -127,7 +127,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WWBOTA: Activity( ActivityName.WWBOTA: Activity(
name=ActivityName.WWBOTA, name=ActivityName.WWBOTA,
description="Worldwide Bunkers on the Air", description="Worldwide Bunkers on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.BUNKER, ref_type=ActivityRefType.BUNKER,
@@ -138,7 +138,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.HEMA: Activity( ActivityName.HEMA: Activity(
name=ActivityName.HEMA, name=ActivityName.HEMA,
description="HuMPs Excluding Marilyns Award", description="HuMPs Excluding Marilyns Award",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
@@ -150,7 +150,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.IOTA: Activity( ActivityName.IOTA: Activity(
name=ActivityName.IOTA, name=ActivityName.IOTA,
description="Islands on the Air", description="Islands on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.ISLAND, ref_type=ActivityRefType.ISLAND,
@@ -161,7 +161,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.GMA_ISLANDS: Activity( ActivityName.GMA_ISLANDS: Activity(
name=ActivityName.GMA_ISLANDS, name=ActivityName.GMA_ISLANDS,
description="Global Mountain Activity - Islands", description="Global Mountain Activity - Islands",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.ISLAND, ref_type=ActivityRefType.ISLAND,
@@ -172,7 +172,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ARLHS: Activity( ActivityName.ARLHS: Activity(
name=ActivityName.ARLHS, name=ActivityName.ARLHS,
description="Amateur Radio Lighthouse Society", description="Amateur Radio Lighthouse Society",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
@@ -183,7 +183,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ILLW: Activity( ActivityName.ILLW: Activity(
name=ActivityName.ILLW, name=ActivityName.ILLW,
description="International Lighthouse & Lightship Weekend", description="International Lighthouse & Lightship Weekend",
sig_type=ActivityType.EVENT, activity_type=ActivityType.EVENT,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
@@ -194,7 +194,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.MOTA: Activity( ActivityName.MOTA: Activity(
name=ActivityName.MOTA, name=ActivityName.MOTA,
description="Mills on the Air", description="Mills on the Air",
sig_type=ActivityType.EVENT, activity_type=ActivityType.EVENT,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.MILL, ref_type=ActivityRefType.MILL,
@@ -205,7 +205,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SIOTA: Activity( ActivityName.SIOTA: Activity(
name=ActivityName.SIOTA, name=ActivityName.SIOTA,
description="Silos on the Air", description="Silos on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.SILO, ref_type=ActivityRefType.SILO,
@@ -217,7 +217,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WCA: Activity( ActivityName.WCA: Activity(
name=ActivityName.WCA, name=ActivityName.WCA,
description="World Castles Award", description="World Castles Award",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
@@ -228,7 +228,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.ZLOTA: Activity( ActivityName.ZLOTA: Activity(
name=ActivityName.ZLOTA, name=ActivityName.ZLOTA,
description="New Zealand on the Air", description="New Zealand on the Air",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=None, ref_type=None,
@@ -241,7 +241,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WOTA: Activity( ActivityName.WOTA: Activity(
name=ActivityName.WOTA, name=ActivityName.WOTA,
description="Wainwrights on the Air", description="Wainwrights on the Air",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
@@ -254,7 +254,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.BOTA: Activity( ActivityName.BOTA: Activity(
name=ActivityName.BOTA, name=ActivityName.BOTA,
description="Beaches on the Air", description="Beaches on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.BEACH, ref_type=ActivityRefType.BEACH,
@@ -265,7 +265,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.KRMNPA: Activity( ActivityName.KRMNPA: Activity(
name=ActivityName.KRMNPA, name=ActivityName.KRMNPA,
description="Keith Roget Memorial National Parks Award", description="Keith Roget Memorial National Parks Award",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
@@ -278,7 +278,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.SANPCPA: Activity( ActivityName.SANPCPA: Activity(
name=ActivityName.SANPCPA, name=ActivityName.SANPCPA,
description="South Australian National Parks and Conservation Parks Award", description="South Australian National Parks and Conservation Parks Award",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
@@ -291,7 +291,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.LLOTA: Activity( ActivityName.LLOTA: Activity(
name=ActivityName.LLOTA, name=ActivityName.LLOTA,
description="Lagos y Lagunas on the Air", description="Lagos y Lagunas on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.LAKE, ref_type=ActivityRefType.LAKE,
@@ -303,7 +303,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TOWERS: Activity( ActivityName.TOWERS: Activity(
name=ActivityName.TOWERS, name=ActivityName.TOWERS,
description="Towers on the Air", description="Towers on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.TOWER, ref_type=ActivityRefType.TOWER,
@@ -314,7 +314,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TILES: Activity( ActivityName.TILES: Activity(
name=ActivityName.TILES, name=ActivityName.TILES,
description="Tiles on the Air", description="Tiles on the Air",
sig_type=ActivityType.ADVENTURE, activity_type=ActivityType.ADVENTURE,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.GRID, ref_type=ActivityRefType.GRID,
@@ -325,7 +325,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.RADAR_RALLY: Activity( ActivityName.RADAR_RALLY: Activity(
name=ActivityName.RADAR_RALLY, name=ActivityName.RADAR_RALLY,
description="RaDAR Rally", description="RaDAR Rally",
sig_type=ActivityType.EVENT, activity_type=ActivityType.EVENT,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
comment_names=["RaDAR"], comment_names=["RaDAR"],
@@ -334,7 +334,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WAB: Activity( ActivityName.WAB: Activity(
name=ActivityName.WAB, name=ActivityName.WAB,
description="Worked All Britain", description="Worked All Britain",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.GRID, ref_type=ActivityRefType.GRID,
@@ -346,7 +346,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.WAI: Activity( ActivityName.WAI: Activity(
name=ActivityName.WAI, name=ActivityName.WAI,
description="Worked All Ireland", description="Worked All Ireland",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.GRID, ref_type=ActivityRefType.GRID,
@@ -358,7 +358,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMF: Activity( ActivityName.DMF: Activity(
name=ActivityName.DMF, name=ActivityName.DMF,
description="Diplôme des Moulins de France", description="Diplôme des Moulins de France",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.MILL, ref_type=ActivityRefType.MILL,
@@ -369,7 +369,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DME: Activity( ActivityName.DME: Activity(
name=ActivityName.DME, name=ActivityName.DME,
description="Diploma Municipios de España", description="Diploma Municipios de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.TOWN, ref_type=ActivityRefType.TOWN,
@@ -381,7 +381,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.FEA: Activity( ActivityName.FEA: Activity(
name=ActivityName.FEA, name=ActivityName.FEA,
description="Diploma Faros de España", description="Diploma Faros de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
@@ -396,7 +396,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMUE: Activity( ActivityName.DMUE: Activity(
name=ActivityName.DMUE, name=ActivityName.DMUE,
description="Diploma Museos de España", description="Diploma Museos de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING, ref_type=ActivityRefType.BUILDING,
@@ -408,7 +408,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DMVE: Activity( ActivityName.DMVE: Activity(
name=ActivityName.DMVE, name=ActivityName.DMVE,
description="Diploma Monumentos y Vestigios de España", description="Diploma Monumentos y Vestigios de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING, ref_type=ActivityRefType.BUILDING,
@@ -420,7 +420,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DCE: Activity( ActivityName.DCE: Activity(
name=ActivityName.DCE, name=ActivityName.DCE,
description="Diploma Castillos de España", description="Diploma Castillos de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
@@ -432,7 +432,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DEFE: Activity( ActivityName.DEFE: Activity(
name=ActivityName.DEFE, name=ActivityName.DEFE,
description="Diploma Estaciones de Ferrocarril de España", description="Diploma Estaciones de Ferrocarril de España",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING, ref_type=ActivityRefType.BUILDING,
@@ -444,7 +444,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.DTMBA: Activity( ActivityName.DTMBA: Activity(
name=ActivityName.DTMBA, name=ActivityName.DTMBA,
description="Diploma Teatri Musei e Belle Arti", description="Diploma Teatri Musei e Belle Arti",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING, ref_type=ActivityRefType.BUILDING,
@@ -456,7 +456,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.BIWOTA: Activity( ActivityName.BIWOTA: Activity(
name=ActivityName.BIWOTA, name=ActivityName.BIWOTA,
description="British Inland Waterways on the Air", description="British Inland Waterways on the Air",
sig_type=ActivityType.EVENT, activity_type=ActivityType.EVENT,
has_refs=False, has_refs=False,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.WATERWAY, ref_type=ActivityRefType.WATERWAY,
@@ -467,7 +467,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.COTA: Activity( ActivityName.COTA: Activity(
name=ActivityName.COTA, name=ActivityName.COTA,
description="Castles on the Air", description="Castles on the Air",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
@@ -479,7 +479,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.PGA: Activity( ActivityName.PGA: Activity(
name=ActivityName.PGA, name=ActivityName.PGA,
description="Polish Gmina Award", description="Polish Gmina Award",
sig_type=ActivityType.REGIONAL, activity_type=ActivityType.REGIONAL,
has_refs=True, has_refs=True,
refs_globally_unique=False, refs_globally_unique=False,
ref_type=ActivityRefType.REGION, ref_type=ActivityRefType.REGION,
@@ -491,7 +491,7 @@ ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.TOILETS: Activity( ActivityName.TOILETS: Activity(
name=ActivityName.TOILETS, name=ActivityName.TOILETS,
description="Toilets on the Air", description="Toilets on the Air",
sig_type=ActivityType.EVENT, activity_type=ActivityType.EVENT,
has_refs=True, has_refs=True,
refs_globally_unique=True, refs_globally_unique=True,
ref_type=ActivityRefType.TOILET, ref_type=ActivityRefType.TOILET,
+8 -10
View File
@@ -5,22 +5,20 @@ from core.enums import ActivityName, ActivityRefType, ActivityType
@dataclass @dataclass
class Activity: class Activity:
"""Data class that defines an Activity (formerly referred to as a "Special Interest Group" or "SIG", a term """Data class that defines an Activity. Each contains a name and a longer form description. They also contain
which is still used for the `sig` field name in the API for backwards compatibility). Each contains a name and comment_names which attempts to separate out the way people might refer to it in cluster comments from how it is
a longer form description. They also contain comment_names which attempts to separate out the way people might referred to in the UI & API. (For example, "TOTA" in cluster spot comments almost always means Towers on the Air,
refer to it in cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster but no single programme is referred to in the UI as "TOTA" as it's ambiguous between Towers, Toilets and Tiles.
spot comments almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" And while Beaches got the name "BOTA" first, "BOTA" spots are much more likely to be bunkers.) Finally, there is a
as it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots ref_regex which provides a regular expression to match what references (such as parks and summits) look like for
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to that programme."""
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" # Activity name as used in the UI and API, e.g. "Towers"
name: ActivityName name: ActivityName
# Description, e.g. "Towers on the Air" # Description, e.g. "Towers on the Air"
description: str description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI. # 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. activity_type: ActivityType
sig_type: ActivityType
# Whether this activity has a fixed set of references (e.g. parks) with some sort of ID to "activate" # Whether this activity has a fixed set of references (e.g. parks) with some sort of ID to "activate"
has_refs: bool has_refs: bool
# Identifies that the activity's reference ID structure defined by its regex is unique across all programmes and # 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 """Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
name and a lookup URL.""" name and a lookup URL."""
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API. # Activity that this reference is in, e.g. "POTA".
sig: str activity: str
# Reference ID, e.g. "GB-0001". # Reference ID, e.g. "GB-0001".
id: str | None = None id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known. # 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 from datetime import datetime, timedelta
import pytz 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_lookup_helper import populate_missing_activity_ref_info
from core.activity_utils import get_icon_for_activity from core.activity_utils import get_icon_for_activity
@@ -66,11 +66,13 @@ class Alert:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility. # Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
sig: str | None = None # e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# "sig_refs" for API backwards compatibility. # the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
sig_refs: list = field(default_factory=list) 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 # 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 to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None 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): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -131,8 +138,8 @@ class Alert:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id) self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# Fetch activity data, and set a real position if we can get one. # Fetch activity data, and set a real position if we can get one.
if self.sig_refs: if self.activity_refs:
for activity_ref in self.sig_refs: for activity_ref in self.activity_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref) 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 the alert itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid: if activity_ref.grid and not self.dx_grid:
@@ -146,10 +153,10 @@ class Alert:
self.dx_latitude = activity_ref.latitude self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude 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 # Add the activities of any activity refs we have to the alert's list of activities.
# reference's activity and apply it to the whole spot. for activity_ref in self.activity_refs:
if self.sig_refs and self.sig_refs[0] and not self.sig: if activity_ref and activity_ref.activity:
self.sig = self.sig_refs[0].sig self.add_activity(activity_ref.activity)
# DX Grid to lat/lon and vice versa in case one is missing # 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): 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: if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls] 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" self.icon = "fa-tower-cell"
if self.sig and (activity_icon := get_icon_for_activity(self.sig)): for activity in self.activities:
self.icon = activity_icon if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") 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): def to_json(self):
"""JSON serialise""" """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. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot | None = None 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 # 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 # true if the location source is "SPOT", "ACTIVITY REF LOOKUP" or "GRID", or if the location source is "HOME QTH"
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.) # and the DX callsign doesn't have a suffix like /P.
dx_location_good: bool = False dx_location_good: bool = False
# DE (Spotter) info # DE (Spotter) info
@@ -125,11 +125,13 @@ class Spot:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility. # Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
sig: str | None = None # e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# "sig_refs" for API backwards compatibility. # the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
sig_refs: list = field(default_factory=list) 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 # Timing info
@@ -159,12 +161,13 @@ class Spot:
def __post_init__(self): def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API """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 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.activities = list(dict.fromkeys(self.activities)) if self.activities else []
self.sig_refs = [ if self.activity_refs:
self.activity_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref) 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): def infer_missing(self, credentials=None):
@@ -269,19 +272,22 @@ class Spot:
if self.dx_latitude or self.dx_grid: if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level activity if it is missing but we have at least one activity ref. # Add the activities of any activity refs we have to the top-level activities list.
if not self.sig and self.sig_refs: for activity_ref in self.activity_refs:
self.sig = self.sig_refs[0].sig.upper() 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 # 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". # 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: if self.comment and self.activity_refs and self.activity_refs[0].activity:
activity = self.sig_refs[0].sig.upper() activity = self.activity_refs[0].activity.upper()
regex = get_ref_regex_for_activity(activity) regex = get_ref_regex_for_activity(activity)
if regex: if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE) all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches: 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 # 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. # 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: if self.comment:
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE) activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches: 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". # things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2)) found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.sig: self.add_activity(found_activity)
self.sig = found_activity
# Now look to see if that activity name was followed by something that looks like a reference ID # 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) 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: if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
ref_matches = re.finditer( ref_matches = re.finditer(
@@ -306,7 +311,7 @@ class Spot:
) )
for ref_match in ref_matches: for ref_match in ref_matches:
self._append_activity_ref_if_missing( 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 # 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 r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
) )
for ref_match in ref_matches: 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 # First of all, add the activity to this spot's list of activities. This covers things
# covers things like cluster spots where the comment is just "OHFF-1234", now we know # like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
# it's WWFF. self.add_activity(activity.name)
if not self.sig:
self.sig = activity.name
self._append_activity_ref_if_missing( 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 # 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 # 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 # 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: if self.activity_refs:
for activity_ref in self.sig_refs: for activity_ref in self.activity_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref) 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 the spot itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid: if activity_ref.grid and not self.dx_grid:
@@ -345,15 +348,10 @@ class Spot:
): ):
self.dx_latitude = activity_ref.latitude self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude 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 self.dx_location_source = LocationSourceForSpot.GRID
else: else:
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP self.dx_location_source = LocationSourceForSpot.ACTIVITY_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
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG". # 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 # 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 self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode # Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.sig: if self.propagation_mode == "Satellite":
self.sig = ActivityName.SATELLITE self.add_activity(ActivityName.SATELLITE)
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig: if self.propagation_mode == "Earth-Moon-Earth":
self.sig = ActivityName.EME self.add_activity(ActivityName.EME)
# Set activities based on the DX callsign suffix # 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): 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): 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 # 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. # 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() now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values(): for alert in DATA_STORE.alerts.values():
if ( if (
alert.sig == ActivityName.DXPEDITION alert.activities
and ActivityName.DXPEDITION in alert.activities
and alert.dx_calls and alert.dx_calls
and alert.start_time and alert.start_time
and alert.end_time and alert.end_time
and alert.start_time < now < 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] 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 break
# DX Grid to lat/lon and vice versa in case one is missing # 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 # 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. # string, otherwise see what they have set on an online lookup service.
if self.sig_refs: if self.activity_refs:
qth = self.sig_refs[0].id qth = self.activity_refs[0].id
if self.sig_refs[0].name: if self.activity_refs[0].name:
qth += f" {self.sig_refs[0].name}" qth += f" {self.activity_refs[0].name}"
self.dx_qth = qth self.dx_qth = qth
else: else:
self.dx_qth = dx_call_info.qth self.dx_qth = dx_call_info.qth
@@ -512,7 +511,7 @@ class Spot:
and self.dx_longitude and self.dx_longitude
and ( and (
self.dx_location_source == LocationSourceForSpot.SPOT 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.GRID
or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or "")) 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_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid 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" self.icon = "fa-tower-cell"
if self.sig and (activity_icon := get_icon_for_activity(self.sig)): for activity in self.activities:
self.icon = activity_icon if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") 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): def to_json(self):
"""JSON serialise""" """JSON serialise"""
@@ -547,13 +555,13 @@ class Spot:
"""Append an activity ref to the list, so long as it's not already there.""" """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.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 == "": if new_activity_ref.id == "":
return return
for activity_ref in self.sig_refs: for activity_ref in self.activity_refs:
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig: if activity_ref.id == new_activity_ref.id and activity_ref.activity == new_activity_ref.activity:
return return
self.sig_refs.append(new_activity_ref) self.activity_refs.append(new_activity_ref)
def expired(self): 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 """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 """Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
data.""" data."""
def __init__(self, sig_name, provider_config): def __init__(self, activity_name, provider_config):
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's """Constructor"""
"sig" field name."""
self.sig_name = sig_name self.activity_name = activity_name
self.enabled = provider_config["enabled"] self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" 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. # 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): with DATA_STORE.activity_refs.transact(retry=True):
for d in new_data: 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 # 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 # 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 break
self.reference_count = len(new_data) 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"] ref_id = row["ARLHS"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=ActivityRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
+1 -1
View File
@@ -30,7 +30,7 @@ class COTA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=name, name=name,
ref_type=ActivityRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
+6 -1
View File
@@ -27,7 +27,12 @@ class DCE(FileDownloadActivityRefDataProvider):
for index, row in df.iterrows(): for index, row in df.iterrows():
if row.iloc[0] and row.iloc[2]: if row.iloc[0] and row.iloc[2]:
new_data.append( 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 # 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]: if row.iloc[0] and row.iloc[1]:
new_data.append( 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 # 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( ref = ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
ref_type=ActivityRefType.TOWN, ref_type=ActivityRefType.TOWN,
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}", 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=";"): for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]: if len(row) > 1 and row[0] and row[1]:
new_data.append( 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 # 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 continue
if ref and name: 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 # 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 # the data in this case
+3 -1
View File
@@ -21,7 +21,9 @@ class DTMBA(FileDownloadActivityRefDataProvider):
split = row.split(";") split = row.split(";")
ref_id = split[0] ref_id = split[0]
ref_name = split[1] 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 # 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 # 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. # 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_1 = row[0].strip()
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-") 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(
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE)) 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 # 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 # 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 """Generic activity ref data provider class for providers that fetch their data from the web by downloading a
file.""" 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*.""" """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._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None 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): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # 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. # 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.") 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.sig_name}", daemon=True) self._thread = Thread(
target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.activity_name}", daemon=True
)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
@@ -36,7 +38,9 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): 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): def _run(self):
while True: while True:
@@ -48,7 +52,7 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
try: try:
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for # 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. # 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) http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
# Check response code was good # Check response code was good
if http_response.ok: if http_response.ok:
@@ -60,20 +64,22 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) 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: else:
self.status = "Error" 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: except ConnectionError:
self.status = "Error" 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): except (ConnectTimeout, ReadTimeout):
self.status = "Error" 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: except Exception:
self.status = "Error" 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) self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
+1 -1
View File
@@ -24,7 +24,7 @@ class GMA(FileDownloadActivityRefDataProvider):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
+1 -1
View File
@@ -25,7 +25,7 @@ class ILLW(FileDownloadActivityRefDataProvider):
ref_id = row["ILLW"] ref_id = row["ILLW"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=ActivityRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
+1 -1
View File
@@ -42,7 +42,7 @@ class IOTA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type=ActivityRefType.ISLAND, ref_type=ActivityRefType.ISLAND,
+1 -1
View File
@@ -30,7 +30,7 @@ class LLOTA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=str(ref["name"]), name=str(ref["name"]),
ref_type=ActivityRefType.LAKE, ref_type=ActivityRefType.LAKE,
@@ -11,12 +11,12 @@ logger = logging.getLogger(__name__)
class LocalFileActivityRefDataProvider(ActivityRefDataProvider): class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from a local file on startup.""" """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): def __init__(self, activity_name, provider_config, path):
super().__init__(sig_name, provider_config) super().__init__(activity_name, provider_config)
self._path = path self._path = path
def start(self): 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: try:
new_data = self._file_to_data(self._path) new_data = self._file_to_data(self._path)
if new_data: if new_data:
@@ -25,10 +25,10 @@ class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
else: else:
self.status = "Error" 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: except Exception:
self.status = "Error" 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): def _file_to_data(self, path):
"""Load a file on the given path and turn it into activity ref data.""" """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"] ref_id = row["Reference"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=ActivityRefType.MILL, ref_type=ActivityRefType.MILL,
+1 -1
View File
@@ -39,7 +39,7 @@ class PGA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=name, name=name,
ref_type=ActivityRefType.REGION, ref_type=ActivityRefType.REGION,
@@ -17,9 +17,9 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
REF_PATTERN = re.compile(r"VKFF-\d+") 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*.""" """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): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -41,7 +41,7 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
longitude, latitude = placemark.geometry.x, placemark.geometry.y longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = ActivityRef( ref = ActivityRef(
sig=self.sig_name, activity=self.activity_name,
id=ref_id, id=ref_id,
name=placemark.name, name=placemark.name,
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -24,7 +24,7 @@ class POTA(FileDownloadActivityRefDataProvider):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -24,7 +24,7 @@ class SIOTA(FileDownloadActivityRefDataProvider):
ref_id = row["SILO_CODE"] ref_id = row["SILO_CODE"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("NAME", None), name=row.get("NAME", None),
ref_type=ActivityRefType.SILO, 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 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 altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
ref = ActivityRef( ref = ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("SummitName", None), name=row.get("SummitName", None),
ref_type=ActivityRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
+1 -1
View File
@@ -24,7 +24,7 @@ class Toilets(LocalFileActivityRefDataProvider):
for row in dr: for row in dr:
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=row["ref"], id=row["ref"],
name=row["ref"], name=row["ref"],
ref_type=ActivityRefType.TOILET, ref_type=ActivityRefType.TOILET,
+1 -1
View File
@@ -24,7 +24,7 @@ class Towers(FileDownloadActivityRefDataProvider):
ref_id = row["Ref"] ref_id = row["Ref"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Nazev", None), name=row.get("Nazev", None),
ref_type=ActivityRefType.TOWER, ref_type=ActivityRefType.TOWER,
+1 -1
View File
@@ -43,7 +43,7 @@ class WCA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("CLEAN NAME", None), name=row.get("CLEAN NAME", None),
ref_type=ActivityRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
+1 -1
View File
@@ -30,7 +30,7 @@ class WOTA(FileDownloadActivityRefDataProvider):
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=feature["properties"]["title"], name=feature["properties"]["title"],
url=url, url=url,
+1 -1
View File
@@ -24,7 +24,7 @@ class WWBOTA(FileDownloadActivityRefDataProvider):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=ActivityRefType.BUNKER, ref_type=ActivityRefType.BUNKER,
+1 -1
View File
@@ -24,7 +24,7 @@ class WWFF(FileDownloadActivityRefDataProvider):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
ActivityRef( ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type=ActivityRefType.PARK, ref_type=ActivityRefType.PARK,
+1 -1
View File
@@ -33,7 +33,7 @@ class ZLOTA(FileDownloadActivityRefDataProvider):
ref_type = None ref_type = None
new_ref = ActivityRef( new_ref = ActivityRef(
sig=self.ACTIVITY, activity=self.ACTIVITY,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type=ref_type, ref_type=ref_type,
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
import pytz import pytz
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -56,8 +56,8 @@ class BOTA(HTTPAlertProvider):
alert = Alert( alert = Alert(
source=self.name, source=self.name,
dx_calls=[dx_call], dx_calls=[dx_call],
sig=ActivityName.BOTA, activities=[ActivityName.BOTA],
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)], activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA, ref_type=ActivityRefType.BEACH)],
start_time=date_time.timestamp(), start_time=date_time.timestamp(),
) )
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -38,12 +38,13 @@ class Hamsat(HTTPAlertProvider):
dx_grid=source_alert["grids"][0], dx_grid=source_alert["grids"][0],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=source_alert["comment"], 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 # 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( ActivityRef(
sig=ActivityName.SATELLITE, activity=ActivityName.SATELLITE,
id=source_alert["satellite"]["name"], id=source_alert["satellite"]["name"],
ref_type=ActivityRefType.SATELLITE
) )
], ],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ") 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}", comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
sig=ActivityName.DXPEDITION, activities=[ActivityName.DXPEDITION],
) )
# Add to our list. # 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() 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 # Convert to our alert format
alert = Alert( alert = Alert(
@@ -46,8 +46,8 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()], dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}", freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"], comment=source_alert["Comments"],
sig=activity, activities=[activity] if activity else [],
sig_refs=activity_refs, activity_refs=activity_refs,
start_time=start_time, start_time=start_time,
) )
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,11 +28,12 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()], dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"], freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig=ActivityName.POTA, activities=[ActivityName.POTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_alert["reference"], id=source_alert["reference"],
sig=ActivityName.POTA, activity=ActivityName.POTA,
ref_type=ActivityRefType.PARK,
name=source_alert["name"], name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}", url=f"https://pota.app/#/park/{source_alert['reference']}",
) )
+1 -1
View File
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary, comment=summary,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
sig=ActivityName.CONTEST, activities=[ActivityName.CONTEST],
) )
return alert return alert
+5 -4
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,11 +34,12 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()], dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"], freqs_modes=source_alert["frequency"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig=ActivityName.SOTA, activities=[ActivityName.SOTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}", id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
sig=ActivityName.SOTA, activity=ActivityName.SOTA,
ref_type=ActivityRefType.SUMMIT,
name=summit_name, name=summit_name,
activation_score=summit_points, activation_score=summit_points,
) )
+1 -1
View File
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
url=url, url=url,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
sig=ActivityName.CONTEST, activities=[ActivityName.CONTEST],
) )
return alert return alert
+2 -2
View File
@@ -7,7 +7,7 @@ import pytz
from rss_parser import Parser as RSSParser from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS 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.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -75,7 +75,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call], dx_calls=[dx_call],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=comment, 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(), start_time=time.timestamp(),
) )
+3 -3
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,8 +28,8 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()], dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}", freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"], comment=source_alert["remarks"],
sig=ActivityName.WWFF, activities=[ActivityName.WWFF],
sig_refs=[ActivityRef(id=source_alert["reference"], sig=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") start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
+35 -35
View File
@@ -68,10 +68,10 @@ class GMA(HTTPSpotProvider):
# Filter out some weird mode strings # Filter out some weird mode strings
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None, mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["REF"], id=source_spot["REF"],
sig="", activity="",
name=source_spot["NAME"], name=source_spot["NAME"],
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
@@ -98,57 +98,57 @@ class GMA(HTTPSpotProvider):
and ref_response.text != "\n" and ref_response.text != "\n"
): ):
ref_info = ref_response.json() 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"]: match ref_info["reftype"]:
case "Summit": case "Summit":
# Summits are a bit complicated, they can be SOTA or GMA depending on the # Summits are a bit complicated, they can be SOTA or GMA depending on the
# separate "sota" field: # separate "sota" field:
if "sota" in ref_info and ref_info["sota"] != "": if "sota" in ref_info and ref_info["sota"] != "":
spot.sig_refs[0].sig = ActivityName.SOTA spot.activity_refs[0].activity = ActivityName.SOTA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.SOTA spot.add_activity(ActivityName.SOTA)
else: else:
spot.sig_refs[0].sig = ActivityName.GMA spot.activity_refs[0].activity = ActivityName.GMA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.GMA spot.add_activity(ActivityName.GMA)
case "POTA": case "POTA":
spot.sig_refs[0].sig = ActivityName.POTA spot.activity_refs[0].activity = ActivityName.POTA
spot.sig_refs[0].ref_type = ActivityRefType.PARK spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.POTA spot.add_activity(ActivityName.POTA)
case "WWFF": case "WWFF":
spot.sig_refs[0].sig = ActivityName.WWFF spot.activity_refs[0].activity = ActivityName.WWFF
spot.sig_refs[0].ref_type = ActivityRefType.PARK spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.WWFF spot.add_activity(ActivityName.WWFF)
case "IOTA Island": case "IOTA Island":
spot.sig_refs[0].sig = ActivityName.IOTA spot.activity_refs[0].activity = ActivityName.IOTA
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = ActivityName.IOTA spot.add_activity(ActivityName.IOTA)
case "GMA Island": case "GMA Island":
spot.sig_refs[0].sig = ActivityName.GMA_ISLANDS spot.activity_refs[0].activity = ActivityName.GMA_ISLANDS
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = ActivityName.GMA_ISLANDS spot.add_activity(ActivityName.GMA_ISLANDS)
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = ActivityName.ILLW spot.activity_refs[0].activity = ActivityName.ILLW
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = ActivityName.ILLW spot.add_activity(ActivityName.ILLW)
case "Lighthouse (ARLHS)": case "Lighthouse (ARLHS)":
spot.sig_refs[0].sig = ActivityName.ARLHS spot.activity_refs[0].activity = ActivityName.ARLHS
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = ActivityName.ARLHS spot.add_activity(ActivityName.ARLHS)
case "Castle": case "Castle":
spot.sig_refs[0].sig = ActivityName.WCA spot.activity_refs[0].activity = ActivityName.WCA
spot.sig_refs[0].ref_type = ActivityRefType.CASTLE spot.activity_refs[0].ref_type = ActivityRefType.CASTLE
spot.sig = ActivityName.WCA spot.add_activity(ActivityName.WCA)
case "Mill": case "Mill":
spot.sig_refs[0].sig = ActivityName.MOTA spot.activity_refs[0].activity = ActivityName.MOTA
spot.sig_refs[0].ref_type = ActivityRefType.MILL spot.activity_refs[0].ref_type = ActivityRefType.MILL
spot.sig = ActivityName.MOTA spot.add_activity(ActivityName.MOTA)
case _: case _:
logger.warning( logger.warning(
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!" 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.activity_refs[0].activity = ref_info["reftype"]
spot.sig = ref_info["reftype"] spot.add_activity(ref_info["reftype"])
elif not ref_response.from_cache: elif not ref_response.from_cache:
if not ref_response.ok: if not ref_response.ok:
+3 -3
View File
@@ -62,11 +62,11 @@ class HEMA(HTTPSpotProvider):
freq=float(freq_mode_match.group(1)) * 1000000, freq=float(freq_mode_match.group(1)) * 1000000,
mode=Mode.from_name(freq_mode_match.group(2).upper()), mode=Mode.from_name(freq_mode_match.group(2).upper()),
comment=spotter_comment_match.group(2), comment=spotter_comment_match.group(2),
sig=ActivityName.HEMA, activities=[ActivityName.HEMA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=spot_items[3].upper(), id=spot_items[3].upper(),
sig=ActivityName.HEMA, activity=ActivityName.HEMA,
name=spot_items[4], name=spot_items[4],
latitude=float(spot_items[7]), latitude=float(spot_items[7]),
longitude=float(spot_items[8]), longitude=float(spot_items[8]),
+3 -3
View File
@@ -34,11 +34,11 @@ class LLOTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000000, freq=float(source_spot["frequency"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment, comment=comment,
sig=ActivityName.LLOTA, activities=[ActivityName.LLOTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig=ActivityName.LLOTA, activity=ActivityName.LLOTA,
name=source_spot["reference_name"], name=source_spot["reference_name"],
ref_type=ActivityRefType.LAKE, ref_type=ActivityRefType.LAKE,
) )
+5 -5
View File
@@ -70,20 +70,20 @@ class ParksNPeaks(HTTPSpotProvider):
ref_id = source_spot["actSiteID"] ref_id = source_spot["actSiteID"]
if activity: if activity:
spot.sig = activity spot.add_activity(activity)
if ref_id: if ref_id:
activity_refs = [ activity_refs = [
ActivityRef( ActivityRef(
id=ref_id, id=ref_id,
sig=activity, activity=activity,
# Free text location is not present in all spots, so only add it if it's set # Free text location is not present in all spots, so only add it if it's set
name=source_spot["actLocation"] name=source_spot["actLocation"]
if "actLocation" in source_spot and source_spot["actLocation"] != "" if "actLocation" in source_spot and source_spot["actLocation"] != ""
else None, else None,
) )
] ]
spot.sig_refs = activity_refs spot.activity_refs = activity_refs
else: else:
# If no actSiteID is set, e.g. because actClass is "QRP", sometimes we still have an actLocation # 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( raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account." "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 = { body = {
"actClass": spot.sig or "", "actClass": next((a for a in spot.activities if self.can_submit_spot(a)), ""),
"actCallsign": spot.dx_call, "actCallsign": spot.dx_call,
"actSite": ref_id, "actSite": ref_id,
"mode": spot.mode or "", "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, freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig=ActivityName.POTA, activities=[ActivityName.POTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig=ActivityName.POTA, activity=ActivityName.POTA,
name=source_spot["name"], name=source_spot["name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
@@ -61,14 +61,14 @@ class POTA(HTTPSpotProvider):
return activity == ActivityName.POTA return activity == ActivityName.POTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None ref_id = spot.activity_refs[0].id if spot.activity_refs else None
if sig_ref: if ref_id:
body = { body = {
"activator": spot.dx_call, "activator": spot.dx_call,
"spotter": spot.de_call, "spotter": spot.de_call,
"frequency": str(spot.freq / 1000.0), "frequency": str(spot.freq / 1000.0),
"mode": spot.mode or "", "mode": spot.mode or "",
"reference": sig_ref, "reference": ref_id,
"comments": spot.comment or "", "comments": spot.comment or "",
"source": "Spothole", "source": "Spothole",
} }
+6 -6
View File
@@ -57,11 +57,11 @@ class SOTA(HTTPSpotProvider):
# Seen SOTA spots with no frequency! # Seen SOTA spots with no frequency!
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig=ActivityName.SOTA, activities=[ActivityName.SOTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["summitCode"], id=source_spot["summitCode"],
sig=ActivityName.SOTA, activity=ActivityName.SOTA,
name=source_spot["summitName"], name=source_spot["summitName"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
@@ -92,10 +92,10 @@ class SOTA(HTTPSpotProvider):
id_token = credentials.get("id_token", "") id_token = credentials.get("id_token", "")
if not access_token or not 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.") 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 "" ref_id = spot.activity_refs[0].id if spot.activity_refs else ""
if sig_ref: if ref_id:
# Split reference into association and summit codes # 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 :) # Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js # 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, freq=freq,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"], 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. # 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. # Just take the grid reference itself as the single Tiles activity reference.
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["maidenhead_grid"], id=source_spot["maidenhead_grid"],
sig=ActivityName.TILES, activity=ActivityName.TILES,
name=source_spot["maidenhead_grid"], name=source_spot["maidenhead_grid"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
+4 -2
View File
@@ -34,8 +34,10 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
freq=likely_freq, freq=likely_freq,
comment=source_spot["comment"], comment=source_spot["comment"],
sig=ActivityName.TOWERS, activities=[ActivityName.TOWERS],
sig_refs=[ActivityRef(id=source_spot["ref"], sig=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER)], 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") time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.utc) .replace(tzinfo=pytz.utc)
.timestamp(), .timestamp(),
+7 -3
View File
@@ -92,9 +92,13 @@ class WOTA(HTTPSpotProvider):
freq=freq_hz, freq=freq_hz,
mode=Mode.from_name(mode), mode=Mode.from_name(mode),
comment=comment, comment=comment,
sig=ActivityName.WOTA, activities=[ActivityName.WOTA],
sig_refs=( activity_refs=(
[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)] [
ActivityRef(
id=ref, activity=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT
)
]
if ref if ref
else [] else []
), ),
+3 -3
View File
@@ -23,7 +23,7 @@ class WWBOTA(SSESpotProvider):
for ref in source_spot["references"]: for ref in source_spot["references"]:
activity_ref = ActivityRef( activity_ref = ActivityRef(
id=ref["reference"], id=ref["reference"],
sig=ActivityName.WWBOTA, activity=ActivityName.WWBOTA,
name=ref["name"], name=ref["name"],
latitude=ref["lat"], latitude=ref["lat"],
longitude=ref["long"], longitude=ref["long"],
@@ -38,8 +38,8 @@ class WWBOTA(SSESpotProvider):
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None, mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
comment=source_spot["comment"], comment=source_spot["comment"],
sig=ActivityName.WWBOTA, activities=[ActivityName.WWBOTA],
sig_refs=refs, activity_refs=refs,
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # 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. # 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, freq=float(source_spot["frequency_khz"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"], comment=source_spot["remarks"],
sig=ActivityName.WWFF, activities=[ActivityName.WWFF],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig=ActivityName.WWFF, activity=ActivityName.WWFF,
name=source_spot["reference_name"], name=source_spot["reference_name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], 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 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 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 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 is why we also provide an activity_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01"
might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences.""" at C3 might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
ACTIVITY = None ACTIVITY = None
def __init__(self, provider_config): def __init__(self, provider_config):
name = provider_config.get("name", "xOTA") name = provider_config.get("name", "xOTA")
super().__init__(name, provider_config, provider_config["url"]) super().__init__(name, provider_config, provider_config["url"])
self.ACTIVITY = str(provider_config["sig"]) if "sig" in provider_config else None self.ACTIVITY = str(provider_config["activity"]) if "activity" 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_ref_prefix = (
str(provider_config["activity_ref_prefix"]) if "activity_ref_prefix" in provider_config else ""
)
def _ws_message_to_spot(self, b): def _ws_message_to_spot(self, b):
string = b.decode("utf-8") string = b.decode("utf-8")
@@ -35,11 +37,11 @@ class XOTA(WebsocketSpotProvider):
dx_call=source_spot["stationCallSign"].upper(), dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000, freq=float(source_spot["freq"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
sig=self.ACTIVITY, activities=[self.ACTIVITY] if self.ACTIVITY else [],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=ref_id, id=ref_id,
sig=self.ACTIVITY or "", activity=self.ACTIVITY or "",
url=source_spot["reference"]["website"], url=source_spot["reference"]["website"],
) )
], ],
+3 -3
View File
@@ -35,11 +35,11 @@ class ZLOTA(HTTPSpotProvider):
freq=freq_hz, freq=freq_hz,
mode=Mode.from_name(source_spot["mode"].upper().strip()), mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig=ActivityName.ZLOTA, activities=[ActivityName.ZLOTA],
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig=ActivityName.ZLOTA, activity=ActivityName.ZLOTA,
name=source_spot["name"], name=source_spot["name"],
) )
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "spothole" name = "spothole"
version = "2.2" version = "3.0-pre"
authors = [ authors = [
{ name = "Ian Renton", email = "ian@ianrenton.com" }, { name = "Ian Renton", email = "ian@ianrenton.com" },
] ]
+86 -56
View File
@@ -15,6 +15,25 @@ info:
## Changelog ## 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 ### 2.2
* Renamed AMSAT SIG to "Satellite" as AMSAT is a specific organisation not just a general term for satellite QSOs * 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: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: 2.0 version: 3.0
servers: servers:
- url: https://spothole.app/api/v2 - url: https://spothole.app/api/v3
tags: tags:
- name: Spots - name: Spots
@@ -396,7 +415,7 @@ paths:
example: "Failed" example: "Failed"
/lookup/sigref: /lookup/activityref:
get: get:
tags: tags:
- Utilities - Utilities
@@ -405,7 +424,7 @@ paths:
Perform a lookup of data about a certain reference, providing the activity and the ID of the 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 reference. An ActivityRef structure will be returned containing the activity and ID, plus any other
information Spothole could find about it. information Spothole could find about it.
operationId: sigref operationId: activityref
parameters: parameters:
- $ref: '#/components/parameters/ActivityRefLookupActivity' - $ref: '#/components/parameters/ActivityRefLookupActivity'
- $ref: '#/components/parameters/ActivityRefLookupId' - $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 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 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: `/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 operationId: spot
requestBody: 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. 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: schema:
$ref: "#/components/schemas/Source" $ref: "#/components/schemas/Source"
SpotActivity: SpotActivity:
name: sig name: activity
in: query in: query
description: > description: >
Limit the spots to only ones from one or more activities provided as an argument. 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` To select more than one activity, supply a comma-separated list. A spot matches if any of its activities are
matches spots with no activity set. You can use `sig=NO_SIG` to specifically only return generic in the list. The special `activity` name `NO_ACTIVITY` matches spots with no activity set. You can use
spots with no associated activity. You can also use combinations to request for example POTA + no `activity=NO_ACTIVITY` to specifically only return generic spots with no associated activity. You can also use
activity, but reject other activities. If you want to request 'every activity and not No Activity', see the combinations to request for example POTA + no activity, but reject other activities. If you want to request
`needs_sig` query parameter for a shortcut. 'every activity but not No Activity', see the `needs_activity` query parameter for a shortcut.
schema: schema:
$ref: "#/components/schemas/ActivityNameIncludingNoSig" $ref: "#/components/schemas/ActivityNameIncludingNoActivity"
SpotNeedsActivity: SpotNeedsActivity:
name: needs_sig name: needs_activity
in: query in: query
description: > description: >
Limit the spots to only ones with an activity such as POTA. Because supplying all 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 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 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_SIG` value. This is what Field 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. Spotter uses to exclude generic cluster spots and only retrieve xOTA things.
schema: schema:
type: boolean type: boolean
default: false default: false
SpotNeedsActivityRef: SpotNeedsActivityRef:
name: needs_sig_ref name: needs_activity_ref
in: query in: query
description: > description: >
Limit the spots to only ones which have at least one reference (e.g. a park reference) for Limit the spots to only ones which have at least one reference (e.g. a park reference) for
@@ -723,14 +742,13 @@ components:
schema: schema:
$ref: "#/components/schemas/Source" $ref: "#/components/schemas/Source"
AlertActivity: AlertActivity:
name: sig name: activity
in: query in: query
description: > description: >
Limit the alerts to only ones from one or more activities. To select more than one 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 activity, supply a comma-separated list. An alert matches if any of its activities are in the list.
specifically without an associated activity (i.e. general DXpeditions).
schema: schema:
$ref: "#/components/schemas/ActivityNameIncludingNoSig" $ref: "#/components/schemas/ActivityNameIncludingNoActivity"
AlertDxContinent: AlertDxContinent:
name: dx_continent name: dx_continent
in: query in: query
@@ -839,9 +857,9 @@ components:
type: string type: string
example: M0TRT example: M0TRT
ActivityRefLookupActivity: ActivityRefLookupActivity:
name: sig name: activity
in: query 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 required: true
schema: schema:
$ref: "#/components/schemas/ActivityName" $ref: "#/components/schemas/ActivityName"
@@ -938,11 +956,11 @@ components:
- EVENT - EVENT
example: TRADITIONAL example: TRADITIONAL
ActivityNameIncludingNoSig: ActivityNameIncludingNoActivity:
oneOf: oneOf:
- $ref: "#/components/schemas/ActivityName" - $ref: "#/components/schemas/ActivityName"
- type: string - type: string
enum: [ NO_SIG ] enum: [ NO_ACTIVITY ]
example: POTA example: POTA
ActivityRefType: ActivityRefType:
@@ -1068,7 +1086,7 @@ components:
type: string type: string
enum: enum:
- SPOT - SPOT
- "SIG REF LOOKUP" - "ACTIVITY REF LOOKUP"
- "GRID" - "GRID"
- "HOME QTH" - "HOME QTH"
- DXCC - DXCC
@@ -1090,8 +1108,8 @@ components:
type: string type: string
description: Activity reference ID. description: Activity reference ID.
example: GB-0001 example: GB-0001
sig: activity:
description: Activity that this reference is in. Still named "sig" in the API for backwards compatibility. description: Activity that this reference is in.
$ref: "#/components/schemas/ActivityName" $ref: "#/components/schemas/ActivityName"
name: name:
type: string 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 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 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 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 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 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 location is likely less accurate than "SPOT". "HOME QTH" indicates we looked up the DX
@@ -1218,7 +1236,7 @@ components:
type: boolean type: boolean
description: > description: >
Does the software think the location is good enough to put a marker on a map? This is 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 the source is "HOME QTH" and the callsign doesn't have a slash in it (i.e. operator
likely at home). likely at home).
example: true example: true
@@ -1315,14 +1333,21 @@ components:
type: string type: string
description: Comment left by the spotter, if any description: Comment left by the spotter, if any
example: "59 in NY 73" example: "59 in NY 73"
sig: activities:
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility) type: array
$ref: "#/components/schemas/ActivityName" uniqueItems: true
sig_refs: 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 type: array
items: items:
$ref: '#/components/schemas/ActivityRef' $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: qrt:
type: boolean type: boolean
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments. description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
@@ -1363,10 +1388,9 @@ components:
submit_upstream: submit_upstream:
type: boolean type: boolean
description: > description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather than only adding it
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs` to this Spothole server. Requires `upstream_provider` to be set. Check `spot_submit_providers` in the
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the `/options` response to see which activities and providers support this.
/options response to see which activities and providers support this.
default: false default: false
upstream_provider: upstream_provider:
type: string type: string
@@ -1503,14 +1527,21 @@ components:
type: string type: string
description: Comment made by the activator, if any description: Comment made by the activator, if any
example: "2025 DXpedition to null island" example: "2025 DXpedition to null island"
sig: activities:
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility) type: array
$ref: "#/components/schemas/ActivityName" uniqueItems: true
sig_refs: 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 type: array
items: items:
$ref: '#/components/schemas/ActivityRef' $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: url:
type: string type: string
description: A URL linking to more information about the alert, e.g. DXpedition or contest info. description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
@@ -1604,8 +1635,8 @@ components:
Activity: Activity:
type: object type: object
description: > description: >
Represents an activity (a term which replaces the older "Special Interest Group" or "SIG" terminology, Represents an activity, such as an outdoor activity programme (e.g. POTA), or another kind of operating
though `sig`-prefixed field names remain for API backwards compatibility). (e.g. Contest, DXpedition).
properties: properties:
name: name:
description: The abbreviated name of the activity description: The abbreviated name of the activity
@@ -1614,12 +1645,11 @@ components:
type: string type: string
description: The full name of the activity description: The full name of the activity
example: Parks on the Air example: Parks on the Air
sig_type: activity_type:
type: boolean
description: > description: >
Whether the activity is traditional (e.g. EME), adventure (e.g. POTA), regional (e.g. WAB), or for a 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. 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" $ref: "#/components/schemas/ActivityType"
has_refs: has_refs:
type: boolean type: boolean
@@ -1998,7 +2028,7 @@ components:
StaticDataProviderStatus: StaticDataProviderStatus:
type: object type: object
properties: properties:
sig_name: name:
type: string type: string
description: The name of the provider. description: The name of the provider.
example: K0SWE example: K0SWE
@@ -2020,9 +2050,9 @@ components:
ActivityRefDataProviderStatus: ActivityRefDataProviderStatus:
type: object type: object
properties: properties:
sig_name: activity_name:
type: string 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 example: WWFF
enabled: enabled:
type: boolean type: boolean
@@ -2046,7 +2076,7 @@ components:
CallsignDataProviderStatus: CallsignDataProviderStatus:
type: object type: object
properties: properties:
sig_name: name:
type: string type: string
description: The name of the provider. description: The name of the provider.
example: Country Files example: Country Files
@@ -2185,7 +2215,7 @@ components:
description: An array of all the static reference data providers. description: An array of all the static reference data providers.
items: items:
$ref: '#/components/schemas/StaticDataProviderStatus' $ref: '#/components/schemas/StaticDataProviderStatus'
sig_ref_data_providers: activity_ref_data_providers:
type: array type: array
description: An array of all the activity reference data providers. description: An array of all the activity reference data providers.
items: items:
@@ -2216,7 +2246,7 @@ components:
items: items:
type: string type: string
example: "PHONE" example: "PHONE"
sigs: activities:
type: array type: array
description: An array of all the supported activities. description: An array of all the supported activities.
items: items:
@@ -2254,7 +2284,7 @@ components:
type: integer type: integer
description: > description: >
The maximum age, in seconds, of any spot before it will be deleted by the system. When 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 is no point providing a number larger than this, because the system drops all spots older
than this. than this.
example: 3600 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 // 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. // any saved values from local storage.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v3/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -42,8 +42,8 @@ function loadOptions() {
}); });
// Populate activity drop-down // Populate activity drop-down
$.each(options["sigs"], function (i, activity) { $.each(options["activities"], function (i, activity) {
$('#sig').append($('<option>', { $('#activity').append($('<option>', {
value: activity.name, value: activity.name,
text: activity.name text: activity.name
})); }));
@@ -91,8 +91,8 @@ function updateUpstreamArea() {
return; return;
} }
const sig = $("#sig").val(); const activity = $("#activity").val();
const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : []; const providers = (activity && options["spot_submit_providers"][activity]) ? options["spot_submit_providers"][activity] : [];
if (providers.length === 0) { if (providers.length === 0) {
$("#upstream-area").hide(); $("#upstream-area").hide();
@@ -131,8 +131,8 @@ function updateCredentialsButton() {
// Get the currently selected upstream provider name // Get the currently selected upstream provider name
function getSelectedUpstreamProvider() { function getSelectedUpstreamProvider() {
const providers = (options && options["spot_submit_providers"] && $("#sig").val()) const providers = (options && options["spot_submit_providers"] && $("#activity").val())
? (options["spot_submit_providers"][$("#sig").val()] || []) ? (options["spot_submit_providers"][$("#activity").val()] || [])
: []; : [];
if (providers.length === 0) return null; if (providers.length === 0) return null;
if (providers.length === 1) return providers[0]; if (providers.length === 1) return providers[0];
@@ -197,8 +197,8 @@ function addSpot() {
const dx = $("#dx-call").val().toUpperCase(); const dx = $("#dx-call").val().toUpperCase();
const freqStr = $("#freq").val(); const freqStr = $("#freq").val();
const mode = $("#mode")[0].value; const mode = $("#mode")[0].value;
const sig = $("#sig")[0].value; const activity = $("#activity")[0].value;
const sigRef = $("#sig-ref").val(); const activityRef = $("#activity-ref").val();
const dxGrid = $("#dx-grid").val(); const dxGrid = $("#dx-grid").val();
const comment = $("#comment").val(); const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase(); const de = $("#de-call").val().toUpperCase();
@@ -208,8 +208,8 @@ function addSpot() {
spot["dx_call"] = dx; spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000; spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode; if (mode !== "") spot["mode"] = mode;
if (sig !== "") spot["sig"] = sig; if (activity !== "") spot["activities"] = [activity];
if (sigRef !== "") spot["sig_refs"] = [{sig: sig, id: sigRef}]; if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid; if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment; if (comment !== "") spot["comment"] = comment;
spot["de_call"] = de; spot["de_call"] = de;
@@ -232,7 +232,7 @@ function addSpot() {
handling["upstream_credentials"] = loadCredentials(upstreamProviderName); handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
} }
$.ajax("/api/v2/spot", { $.ajax("/api/v3/spot", {
data: JSON.stringify({spot, handling}), data: JSON.stringify({spot, handling}),
contentType: 'application/json', contentType: 'application/json',
type: 'POST', type: 'POST',
@@ -291,7 +291,7 @@ $("#mode").change(function () {
}); });
// Update upstream area and credentials button when activity changes // Update upstream area and credentials button when activity changes
$("#sig").change(function () { $("#activity").change(function () {
updateUpstreamArea(); updateUpstreamArea();
}); });
+15 -15
View File
@@ -10,7 +10,7 @@ let alerts = [];
// to alerts // to alerts
function loadAlerts() { function loadAlerts() {
$.ajax({ $.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 // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
// Store data // Store data
@@ -24,7 +24,7 @@ function loadAlerts() {
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "source", "sig"].forEach(fn => { ["dx_continent", "source", "activity"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -210,14 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["dx_calls"] != null) { 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(", "); 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" // Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest" dx_calls_html = "Contest"
} }
// Format DXpedition country // Format DXpedition country
let dx_country_html = ""; 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"]}`; dx_country_html = `<br/>${a["dx_country"]}`;
} }
@@ -252,23 +252,23 @@ function addAlertRowsToTable(tbody, alerts) {
// Activity or fallback to "General DX" // Activity or fallback to "General DX"
let activityText = "General DX"; let activityText = "General DX";
if (a["sig"]) { if (a["activities"] != null && a["activities"].length > 0) {
activityText = a["sig"]; activityText = a["activities"].join(", ");
} }
// Format activity refs // Format activity refs
let activityRefs = ""; let activityRefs = "";
if (a["sig_refs"] != null) { if (a["activity_refs"] != null) {
const items = []; const items = [];
for (let i = 0; i < a["sig_refs"].length; i++) { for (let i = 0; i < a["activity_refs"].length; i++) {
if (a["sig_refs"][i]["url"] != null) { if (a["activity_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>` 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 { } 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 // 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. // 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"]; items[i] += " from " + a["dx_grid"];
} }
} }
@@ -283,7 +283,7 @@ function addAlertRowsToTable(tbody, alerts) {
$tr.append(`<td class='nowrap'>${end_time_formatted}</td>`); $tr.append(`<td class='nowrap'>${end_time_formatted}</td>`);
} }
if (showDX) { 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) { if (showFreqsModes) {
$tr.append(`<td class='hideonmobile'>${freqsModesText}</td>`); $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. // Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v3/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -338,7 +338,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]); 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 // 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 // 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 // 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(); sseAbortController.abort();
} }
$.ajax({ $.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update bands display // Update bands display
@@ -39,7 +39,7 @@ function startSSEConnection() {
sseAbortController = new AbortController(); sseAbortController = new AbortController();
// No need to include QRZ/HamQTH credentials because the information wouldn't be displayed on the bands panel anyway // 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, signal: sseAbortController.signal,
openWhenHidden: true, 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. // in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
function buildQueryString() { function buildQueryString() {
let str = "?"; 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)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(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 // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v3/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -295,7 +295,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]); generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]); 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, "limit", "alerts_to_fetch"); // Only on Alerts page
updateSelectFromParam(params, "max_age", "max_spot_age"); // Only on Map & Bands pages updateSelectFromParam(params, "max_age", "max_spot_age"); // Only on Map & Bands pages
updateFilterFromParam(params, "band", "band"); updateFilterFromParam(params, "band", "band");
updateFilterFromParam(params, "sig", "sig"); updateFilterFromParam(params, "activity", "activity");
updateFilterFromParam(params, "source", "source"); updateFilterFromParam(params, "source", "source");
updateFilterFromParam(params, "mode", "mode"); updateFilterFromParam(params, "mode", "mode");
updateFilterFromParam(params, "dx_continent", "dx_continent"); 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">'); const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
activity_options.forEach(o => { activity_options.forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, ""); 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; return $grid;
} }
// Generate activities filter card. This one is also a special case. includeGeneralDX controls whether the "General // 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) { function generateActivitiesMultiToggleFilterCard(activity_options, includeGeneralDX = true) {
const $list = $('<ul class="list-unstyled filter-section-list ps-0">'); 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 => { appendActivityFilterSection($list, 'traditional', 'Traditional', true, includeGeneralDX || traditional.length > 0, $body => {
if (includeGeneralDX) { 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)); $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 => { appendActivityFilterSection($list, 'adventure', 'Adventure', true, adventure.length > 0, $body => {
$body.append(buildActivityFilterGrid(adventure)); $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 => { appendActivityFilterSection($list, 'regional', 'Regional', false, regional.length > 0, $body => {
$body.append(buildActivityFilterGrid(regional)); $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 => { appendActivityFilterSection($list, 'event', 'Event', false, event.length > 0, $body => {
$body.append(buildActivityFilterGrid(event)); $body.append(buildActivityFilterGrid(event));
}); });
$("#activity-options").append($list); $("#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 // Method called when "All" or "None" is clicked
+2 -2
View File
@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions // Load solar conditions
function loadSolarConditions() { function loadSolarConditions() {
$.getJSON('/api/v2/solar', function (jsonData) { $.getJSON('/api/v3/solar', function (jsonData) {
// HF // HF
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render // Fetch DX stats from the API and render
function loadDxStats() { function loadDxStats() {
$.getJSON('/api/v2/dxstats', function (jsonData) { $.getJSON('/api/v3/dxstats', function (jsonData) {
dxStatsData = jsonData; dxStatsData = jsonData;
renderDxStats(); 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 // 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
// data if they can. // data if they can.
$.ajax({ $.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update map // 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 // 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. // with the credentials and replace what's on the map, so we can improve the data.
$.ajax({ $.ajax({
url: '/api/v2/spots' + buildQueryString(), url: '/api/v3/spots' + buildQueryString(),
dataType: 'json', dataType: 'json',
headers: getCredentialHeaders(), headers: getCredentialHeaders(),
success: function (jsonData2) { success: function (jsonData2) {
@@ -85,7 +85,7 @@ function startSSEConnection() {
sseAbortController = new AbortController(); 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. // 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(), headers: getCredentialHeaders(),
signal: sseAbortController.signal, signal: sseAbortController.signal,
openWhenHidden: true, 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. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString() {
let str = "?"; 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)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -276,19 +276,19 @@ function getTooltipText(s) {
// Activity or fallback to source // Activity or fallback to source
let activitySourceText = s["source"]; let activitySourceText = s["source"];
if (s["sig"]) { if (s["activities"] != null && s["activities"].length > 0) {
activitySourceText = s["sig"]; activitySourceText = s["activities"].join(", ");
} }
// Format activity refs // Format activity refs
let activityRefs = ""; let activityRefs = "";
if (s["sig_refs"] != null) { if (s["activity_refs"] != null) {
const items = []; const items = [];
for (let i = 0; i < s["sig_refs"].length; i++) { for (let i = 0; i < s["activity_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) { if (s["activity_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>` 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 { } else {
items[i] = `${s["sig_refs"][i]["id"]}` items[i] = `${s["activity_refs"][i]["id"]}`
} }
} }
activityRefs = items.join(", "); 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 // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v3/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -339,7 +339,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]); generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]); 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 // Make the new query. No credential headers on the first load to keep things quick
$.ajax({ $.ajax({
url: '/api/v2/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) { url: '/api/v3/spots' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update table // Update table
@@ -43,7 +43,7 @@ function startSSEConnection() {
sseAbortController = new AbortController(); 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. // 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(), headers: getCredentialHeaders(),
signal: sseAbortController.signal, signal: sseAbortController.signal,
openWhenHidden: true, openWhenHidden: true,
@@ -100,7 +100,7 @@ function startSSEConnection() {
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString() {
let str = "?"; 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)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -329,19 +329,19 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Format activity // Format activity
let activityText = "General DX"; let activityText = "General DX";
if (s["sig"]) { if (s["activities"] != null && s["activities"].length > 0) {
activityText = s["sig"]; activityText = s["activities"].join(", ");
} }
// Format activity refs // Format activity refs
let activityRefs = ""; let activityRefs = "";
if (s["sig_refs"] != null) { if (s["activity_refs"] != null) {
const items = []; const items = [];
for (let i = 0; i < s["sig_refs"].length; i++) { for (let i = 0; i < s["activity_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) { if (s["activity_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>` 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 { } 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(", "); 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 // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v3/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -478,7 +478,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
generateActivitiesMultiToggleFilterCard(options["sigs"]); generateActivitiesMultiToggleFilterCard(options["activities"]);
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
generateModesMultiToggleFilterCard(options["modes"]); generateModesMultiToggleFilterCard(options["modes"]);
+4 -4
View File
@@ -1,6 +1,6 @@
// Load server status // Load server status
function loadStatus() { function loadStatus() {
$.getJSON('/api/v2/status', function (jsonData) { $.getJSON('/api/v3/status', function (jsonData) {
$("#software_version").text(jsonData["software_version"]); $("#software_version").text(jsonData["software_version"]);
$("#server_owner_callsign").text(jsonData["server_owner_callsign"]); $("#server_owner_callsign").text(jsonData["server_owner_callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow()); $("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
@@ -54,10 +54,10 @@ function loadStatus() {
</div>`); </div>`);
}); });
jsonData["sig_ref_data_providers"].forEach(p => { jsonData["activity_ref_data_providers"].forEach(p => {
$("#sig_ref_data_providers-status-container").append(` $("#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="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">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">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> <div class="col">References: ${p["enabled"] ? p["reference_count"] : "N/A"}</div>
+4 -4
View File
@@ -41,14 +41,14 @@
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label for="sig" class="form-label">Activity</label> <label for="activity" class="form-label">Activity</label>
<select id="sig" class="form-select"> <select id="activity" class="form-select">
<option value="" selected></option> <option value="" selected></option>
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label for="sig-ref" class="form-label">Activity Reference</label> <label for="activity-ref" class="form-label">Activity Reference</label>
<input type="text" class="form-control input-narrow" id="sig-ref" placeholder="e.g. GB-0001"> <input type="text" class="form-control input-narrow" id="activity-ref" placeholder="e.g. GB-0001">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label for="dx-grid" class="form-label">DX Grid</label> <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. which you can automatically use to generate a client skeleton using various software.
</li> </li>
<li>Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call <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>
<li>Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do <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. 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 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> than that.</p>
<p>If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints <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 <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 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/v3/spots?activity=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</code> and <code>https://spothole.app/api/v3/spots?activity=SOTA</code>.
</p> </p>
<p>Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique <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. 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 <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 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 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 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> browser.</p>
<p>The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, <p>The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API,
@@ -82,10 +82,10 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><code>sig</code></td> <td><code>activity</code></td>
<td>Comma-separated list</td> <td>Comma-separated list</td>
<td>(all)</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 <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. match the labels of the buttons in the standard web interface.
</td> </td>
+1 -1
View File
@@ -82,7 +82,7 @@
<div class="card-header"> <div class="card-header">
Activity Reference Data Providers Activity Reference Data Providers
</div> </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>
</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): class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)""" """API request handler for /api/v3/spot (POST)"""
def __init__( def __init__(
self, self,
@@ -142,24 +142,19 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Reject if activity ref format incorrect for activity # Reject if any activity ref format is incorrect for its activity
if ( for activity_ref in spot.activity_refs:
spot.sig ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None
and spot.sig_refs if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id):
and len(spot.sig_refs) > 0 self.set_status(422)
and spot.sig_refs[0].id self.write(
and get_ref_regex_for_activity(spot.sig) safe_json_dumps(
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) f"Error - '{activity_ref.id}' does not look like a valid reference for {activity_ref.activity}."
): )
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("Cache-Control", "no-store") self.set_header("Content-Type", "application/json")
self.set_header("Content-Type", "application/json") return
return
# Reject upstream submission if not permitted # Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING: if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
@@ -171,13 +166,14 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements # Validate upstream submission requirements
if submit_upstream and upstream_provider_name: 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.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream.")) self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not spot.sig_refs and upstream_provider_name != "Tiles": if not spot.activity_refs and upstream_provider_name != "Tiles":
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream.")) self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -201,7 +197,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested # Submit upstream if requested
upstream_warning = None upstream_warning = None
if submit_upstream and upstream_provider_name: 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: if provider:
try: try:
# Submit spot to the upstream provider # 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." f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
) )
else: 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 # 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 # 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. # we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning: if not submit_upstream or upstream_warning:
spot.source = "API"
spot.infer_missing() spot.infer_missing()
self._spots.set(spot.id, spot) 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("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activity) -> SpotProvider | None: def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity.""" """Find an enabled provider by name that can submit spots for at least one of the given activities."""
for p in self._spot_providers: 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 p
return None return None
+10 -10
View File
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
class APIAlertsHandler(tornado.web.RequestHandler): class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts""" """API request handler for /api/v3/alerts"""
def __init__( def __init__(
self, self,
@@ -69,7 +69,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): 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): def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None 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 # the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check. # always passes the check.
if ( if (
alert.sig == ActivityName.DXPEDITION ActivityName.DXPEDITION in alert.activities
and "dxpeditions_skip_max_duration_check" in query and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE" and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
): ):
continue continue
if ( if (
alert.sig == ActivityName.CONTEST ActivityName.CONTEST in alert.activities
and "contests_skip_max_duration_check" in query and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE" 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(",") sources = query.get(k).split(",")
if not alert.source or alert.source not in sources: if not alert.source or alert.source not in sources:
return False return False
case "sig": case "activity":
# If a list of activities is provided, the alert must have an activity and it must match one of them. # If a list of activities is provided, the alert must have at least one activity that matches one of
# The special activity "NO_SIG", when supplied in the list, matches alerts with no activity. # them. The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",") activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities include_no_activity = "NO_ACTIVITY" in activities
if not alert.sig and not include_no_activity: if not alert.activities and not include_no_activity:
return False 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 return False
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") 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): class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats""" """API request handler for /api/v3/dxstats"""
def __init__( def __init__(
self, self,
+9 -9
View File
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class APILookupCallHandler(tornado.web.RequestHandler): class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call""" """API request handler for /api/v3/lookup/call"""
def __init__( def __init__(
self, self,
@@ -63,7 +63,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupActivityRefHandler(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__( def __init__(
self, self,
@@ -79,16 +79,16 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string # 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()} 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. # that activity, the provided id must match it.
if "sig" in query_params and "id" in query_params: if "activity" in query_params and "id" in query_params:
activity = str(query_params.get("sig")).upper() activity = str(query_params.get("activity")).upper()
ref_id = str(query_params.get("id")).upper() ref_id = str(query_params.get("id")).upper()
if get_activity_by_name(activity): if get_activity_by_name(activity):
if not get_ref_regex_for_activity(activity) or re.match( if not get_ref_regex_for_activity(activity) or re.match(
get_ref_regex_for_activity(activity), ref_id 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)) self.write(safe_json_dumps(data))
else: else:
@@ -99,10 +99,10 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
) )
self.set_status(422) self.set_status(422)
else: 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) self.set_status(422)
else: 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) self.set_status(422)
except Exception: except Exception:
@@ -115,7 +115,7 @@ class APILookupActivityRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(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__( def __init__(
self, self,
+2 -2
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options""" """API request handler for /api/v3/options"""
def __init__( def __init__(
self, self,
@@ -75,7 +75,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
"bands": BANDS, "bands": BANDS,
"modes": [m.value for m in Mode], "modes": [m.value for m in Mode],
"mode_types": [t.value for t in ModeType], "mode_types": [t.value for t in ModeType],
"sigs": list(ACTIVITIES.values()), "activities": list(ACTIVITIES.values()),
"spot_providers": spot_providers, "spot_providers": spot_providers,
"spot_providers_enabled_by_default": spot_providers_enabled_by_default, "spot_providers_enabled_by_default": spot_providers_enabled_by_default,
"alert_providers": alert_providers, "alert_providers": alert_providers,
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APISolarConditionsHandler(tornado.web.RequestHandler): class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar""" """API request handler for /api/v3/solar"""
def __init__( def __init__(
self, self,
+13 -13
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
class APISpotsHandler(tornado.web.RequestHandler): class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots""" """API request handler for /api/v3/spots"""
def __init__( def __init__(
self, self,
@@ -68,7 +68,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): 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): def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None self._sse_spot_broadcaster = None
@@ -196,25 +196,25 @@ def spot_allowed_by_query(spot, query):
sources = query.get(k).split(",") sources = query.get(k).split(",")
if not spot.source or spot.source not in sources: if not spot.source or spot.source not in sources:
return False return False
case "sig": case "activity":
# If a list of activities is provided, the spot must have an activity and it must match one of them. # If a list of activities is provided, the spot must have at least one activity that matches one of
# The special activity "NO_SIG", when supplied in the list, matches spots with no activity. # them. The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",") activities = query.get(k).split(",")
include_no_activity = "NO_SIG" in activities include_no_activity = "NO_ACTIVITY" in activities
if not spot.sig and not include_no_activity: if not spot.activities and not include_no_activity:
return False 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 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 # 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" needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.sig: if needs_activity and not spot.activities:
return False 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. # 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" 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 return False
case "band": case "band":
bands = query.get(k).split(",") bands = query.get(k).split(",")
+1 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
class APIStatusHandler(tornado.web.RequestHandler): class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status""" """API request handler for /api/v3/status"""
def __init__( def __init__(
self, 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 core.data_store import DATA_STORE
from webserver.handlers.api.addspot import APISpotHandler from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler 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.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import ( from webserver.handlers.api.lookups import (
APILookupActivityRefHandler, APILookupActivityRefHandler,
@@ -27,9 +55,6 @@ from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler 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.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler from webserver.handlers.pagetemplate import PageTemplateHandler
@@ -107,50 +132,50 @@ class WebServer:
# API endpoints are always enabled # API endpoints are always enabled
api_routes = [ api_routes = [
( (
r"/api/v2/spots", r"/api/v3/spots",
APISpotsHandler, APISpotsHandler,
{"spots": self._data_store.spots}, {"spots": self._data_store.spots},
), ),
( (
r"/api/v2/alerts", r"/api/v3/alerts",
APIAlertsHandler, APIAlertsHandler,
{"alerts": self._data_store.alerts}, {"alerts": self._data_store.alerts},
), ),
( (
r"/api/v2/spots/stream", r"/api/v3/spots/stream",
APISpotsStreamHandler, APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster}, {"sse_spot_broadcaster": self._spot_broadcaster},
), ),
( (
r"/api/v2/alerts/stream", r"/api/v3/alerts/stream",
APIAlertsStreamHandler, APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster}, {"sse_alert_broadcaster": self._alert_broadcaster},
), ),
( (
r"/api/v2/solar", r"/api/v3/solar",
APISolarConditionsHandler, APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()}, {"solar_conditions": self._data_store.solar_conditions.get()},
), ),
( (
r"/api/v2/dxstats", r"/api/v3/dxstats",
APIDxStatsHandler, APIDxStatsHandler,
{"spots": self._data_store.spots}, {"spots": self._data_store.spots},
), ),
( (
r"/api/v2/options", r"/api/v3/options",
APIOptionsHandler, APIOptionsHandler,
{"status_data": self._data_store.status.get()}, {"status_data": self._data_store.status.get()},
), ),
( (
r"/api/v2/status", r"/api/v3/status",
APIStatusHandler, APIStatusHandler,
{"status_data": self._data_store.status.get()}, {"status_data": self._data_store.status.get()},
), ),
(r"/api/v2/lookup/call", APILookupCallHandler), (r"/api/v3/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupActivityRefHandler), (r"/api/v3/lookup/activityref", APILookupActivityRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler), (r"/api/v3/lookup/grid", APILookupGridHandler),
( (
r"/api/v2/spot", r"/api/v3/spot",
APISpotHandler, APISpotHandler,
{ {
"spots": self._data_store.spots, "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 # v2 API compatibility routes. Translation wrappers convert data to the v3 format and back.
# that have the major breaking changes get a bespoke handler. 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 = [ v1_compat_routes = [
( (
r"/api/v1/spots", r"/api/v1/spots",
V1APISpotsHandler, V1APISpotsHandler,
{"spots": self._data_store.spots}, {"spots": self._data_store.spots},
), ),
(
r"/api/v1/alerts",
V1APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
( (
r"/api/v1/spots/stream", r"/api/v1/spots/stream",
V1APISpotsStreamHandler, V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster}, {"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", r"/api/v1/spot",
V1APISpotHandler, V1APISpotHandler,
{ {
"spots": self._data_store.spots, "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 # 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( 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"), template_path=os.path.join(_HERE, "../templates"),
log_function=request_log, log_function=request_log,
debug=False, debug=False,