Compare commits

..
2 Commits
89 changed files with 793 additions and 496 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.
+10 -11
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,10 @@ class Alert:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility. # Activity (e.g. outdoor activity programme such as POTA).
sig: str | None = None activity: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
# "sig_refs" for API backwards compatibility. activity_refs: list = field(default_factory=list)
sig_refs: list = field(default_factory=list)
# Timing info # Timing info
@@ -131,8 +130,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:
@@ -148,8 +147,8 @@ class Alert:
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that # 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. # reference's activity and apply it to the whole spot.
if self.sig_refs and self.sig_refs[0] and not self.sig: if self.activity_refs and self.activity_refs[0] and not self.activity:
self.sig = self.sig_refs[0].sig self.activity = self.activity_refs[0].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):
@@ -182,7 +181,7 @@ class Alert:
# 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 activity if known, 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)): if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon self.icon = activity_icon
except Exception: except Exception:
+50 -49
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,10 @@ class Spot:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility. # Activity (e.g. outdoor activity programme such as POTA).
sig: str | None = None activity: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
# "sig_refs" for API backwards compatibility. activity_refs: list = field(default_factory=list)
sig_refs: list = field(default_factory=list)
# Timing info # Timing info
@@ -159,12 +158,12 @@ 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.."""
if self.sig_refs: if self.activity_refs:
self.sig_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):
@@ -270,18 +269,20 @@ class Spot:
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. # Set the top-level activity if it is missing but we have at least one activity ref.
if not self.sig and self.sig_refs: if not self.activity and self.activity_refs:
self.sig = self.sig_refs[0].sig.upper() self.activity = self.activity_refs[0].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.
@@ -292,11 +293,11 @@ class Spot:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers # First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# things like cluster spots where the comment is just "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: if not self.activity:
self.sig = found_activity self.activity = 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 +307,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
@@ -322,17 +323,17 @@ class Spot:
# First of all, if we haven't got an activity for this spot set yet, now we have. This # First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know # covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF. # it's WWFF.
if not self.sig: if not self.activity:
self.sig = activity.name self.activity = 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 +346,15 @@ 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 self.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 # 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. # reference's activity and apply it to the whole spot.
if self.sig_refs and not self.sig: if self.activity_refs and not self.activity:
self.sig = self.sig_refs[0].sig self.activity = self.activity_refs[0].activity
# 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 +407,32 @@ 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" and not self.activity:
self.sig = ActivityName.SATELLITE self.activity = ActivityName.SATELLITE
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig: if self.propagation_mode == "Earth-Moon-Earth" and not self.activity:
self.sig = ActivityName.EME self.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 and not self.activity:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE): if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
self.sig = ActivityName.AERONAUTICAL_MOBILE self.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.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.activity:
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.activity == ActivityName.DXPEDITION
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.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 +477,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 +513,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 ""))
) )
@@ -532,7 +533,7 @@ class Spot:
# 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 activity if known, 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)): if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon self.icon = activity_icon
except Exception: except Exception:
@@ -547,13 +548,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,
+2 -2
View File
@@ -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, activity=ActivityName.BOTA,
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)], activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA)],
start_time=date_time.timestamp(), start_time=date_time.timestamp(),
) )
+3 -3
View File
@@ -38,11 +38,11 @@ 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, activity=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"],
) )
], ],
+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, activity=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, activity=activity,
sig_refs=activity_refs, activity_refs=activity_refs,
start_time=start_time, start_time=start_time,
) )
+3 -3
View File
@@ -28,11 +28,11 @@ 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, activity=ActivityName.POTA,
sig_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_alert["reference"], id=source_alert["reference"],
sig=ActivityName.POTA, activity=ActivityName.POTA,
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, activity=ActivityName.CONTEST,
) )
return alert return alert
+3 -3
View File
@@ -34,11 +34,11 @@ 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, activity=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,
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, activity=ActivityName.CONTEST,
) )
return alert return alert
+1 -1
View File
@@ -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)] if ref else [],
start_time=time.timestamp(), start_time=time.timestamp(),
) )
+2 -2
View File
@@ -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, activity=ActivityName.WWFF,
sig_refs=[ActivityRef(id=source_alert["reference"], sig=ActivityName.WWFF)], activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF)],
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.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.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.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.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.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.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.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.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.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.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.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, activity=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, activity=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.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": spot.activity or "",
"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, activity=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, activity=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, activity=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, activity=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, activity=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, activity=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, activity=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, activity=self.ACTIVITY,
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, activity=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" },
] ]
+67 -49
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, `sig` has been renamed to `activity` and `sig_refs` to `activity_refs`.
* **Breaking change:** In activity reference data (i.e. each entry in `activity_refs`, 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`. When using the `fields` query parameter, use the new field names `activity` and `activity_refs`.
* **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 `activity` 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. However, 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. 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. The special `activity` name `NO_ACTIVITY`
matches spots with no activity set. You can use `sig=NO_SIG` to specifically only return generic matches spots with no activity set. You can use `activity=NO_ACTIVITY` to specifically only return generic
spots with no associated activity. You can also use combinations to request for example POTA + no spots with no associated activity. You can also use combinations to request for example POTA + no
activity, but reject other activities. If you want to request 'every activity and not No Activity', see the activity, but reject other activities. If you want to request 'every activity and not No Activity', see the
`needs_sig` query parameter for a shortcut. `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,14 @@ 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. The special value 'NO_ACTIVITY' can be included to return alerts
specifically without an associated activity (i.e. general DXpeditions). specifically without an associated activity.
schema: schema:
$ref: "#/components/schemas/ActivityNameIncludingNoSig" $ref: "#/components/schemas/ActivityNameIncludingNoActivity"
AlertDxContinent: AlertDxContinent:
name: dx_continent name: dx_continent
in: query in: query
@@ -839,9 +858,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 +957,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 +1087,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 +1109,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 +1224,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 +1237,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 +1334,14 @@ 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: activity:
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
$ref: "#/components/schemas/ActivityName" $ref: "#/components/schemas/ActivityName"
sig_refs: 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.
@@ -1364,7 +1383,7 @@ components:
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 to this Spothole server. Requires `sig`, at least one `sig_refs` than only adding it to this Spothole server. Requires `activity`, at least one `activity_refs`
entry, and `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
@@ -1503,14 +1522,14 @@ 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: activity:
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
$ref: "#/components/schemas/ActivityName" $ref: "#/components/schemas/ActivityName"
sig_refs: 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 +1623,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 +1633,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 +2016,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 +2038,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 +2064,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 +2203,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 +2234,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 +2272,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["activity"] = 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();
}); });
+14 -14
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["activity"] === "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["activity"] === "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["activity"]) {
activityText = a["sig"]; activityText = a["activity"];
} }
// 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"];
} }
} }
@@ -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["activity"]) {
activitySourceText = s["sig"]; activitySourceText = s["activity"];
} }
// 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["activity"]) {
activityText = s["sig"]; activityText = s["activity"];
} }
// 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>
+12 -12
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,
@@ -144,17 +144,17 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if activity ref format incorrect for activity # Reject if activity ref format incorrect for activity
if ( if (
spot.sig spot.activity
and spot.sig_refs and spot.activity_refs
and len(spot.sig_refs) > 0 and len(spot.activity_refs) > 0
and spot.sig_refs[0].id and spot.activity_refs[0].id
and get_ref_regex_for_activity(spot.sig) and get_ref_regex_for_activity(spot.activity)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) and not re.match(get_ref_regex_for_activity(spot.activity), spot.activity_refs[0].id)
): ):
self.set_status(422) self.set_status(422)
self.write( self.write(
safe_json_dumps( safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}." f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
) )
) )
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -171,13 +171,13 @@ 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.activity:
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 +201,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.activity)
if provider: if provider:
try: try:
# Submit spot to the upstream provider # Submit spot to the upstream provider
@@ -216,7 +216,7 @@ 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 {spot.activity if spot.activity else ''} 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
+9 -9
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 alert.activity == ActivityName.DXPEDITION
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 alert.activity == ActivityName.CONTEST
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 an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches alerts with no activity. # 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.activity and not include_no_activity:
return False return False
if alert.sig and alert.sig not in activities: if alert.activity and alert.activity not in activities:
return False return False
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") dxconts = query.get(k).split(",")
+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,
+12 -12
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 an activity and it must match one of them.
# The special activity "NO_SIG", when supplied in the list, matches spots with no activity. # 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.activity and not include_no_activity:
return False return False
if spot.sig and spot.sig not in activities: if spot.activity and spot.activity not in 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.activity:
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,
+13 -10
View File
@@ -11,12 +11,14 @@ from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq, safe_json_dumps from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot from data.spot import Spot
from webserver.handlers.api.v2_compatibility import translate_v2_spot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class V1APISpotHandler(tornado.web.RequestHandler): class V1APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST). Included in early Spothole v2 for backwards compatibility.""" """API request handler for /api/v1/spot (POST). Included in Spothole v2 onwards for backwards
compatibility."""
def __init__( def __init__(
self, self,
@@ -57,8 +59,9 @@ class V1APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Read in the request body as JSON then convert to a Spot object # Read in the request body as JSON then convert to a Spot object. The v1 spot format uses the same field
json_spot = tornado.escape.json_decode(post_data) # names as v2, so needs the same translation to v3 field names.
json_spot = translate_v2_spot(tornado.escape.json_decode(post_data))
spot = Spot(**json_spot) spot = Spot(**json_spot)
# Reject if no timestamp, frequency, dx_call or de_call # Reject if no timestamp, frequency, dx_call or de_call
@@ -106,17 +109,17 @@ class V1APISpotHandler(tornado.web.RequestHandler):
# Reject if activity ref format incorrect for activity # Reject if activity ref format incorrect for activity
if ( if (
spot.sig spot.activity
and spot.sig_refs and spot.activity_refs
and len(spot.sig_refs) > 0 and len(spot.activity_refs) > 0
and spot.sig_refs[0].id and spot.activity_refs[0].id
and get_ref_regex_for_activity(spot.sig) and get_ref_regex_for_activity(spot.activity)
and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) and not re.match(get_ref_regex_for_activity(spot.activity), spot.activity_refs[0].id)
): ):
self.set_status(422) self.set_status(422)
self.write( self.write(
safe_json_dumps( safe_json_dumps(
f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}." f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
) )
) )
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+7 -5
View File
@@ -1,6 +1,6 @@
import re import re
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler from webserver.handlers.api.v2_compatibility import V2APISpotsHandler, V2APISpotsStreamHandler
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"') _GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
_LEGACY_PARAM_TO_HEADER_MAP = { _LEGACY_PARAM_TO_HEADER_MAP = {
@@ -24,8 +24,9 @@ def _handle_legacy_params(handler):
handler.request.headers[header] = value handler.request.headers[header] = value
class V1APISpotsHandler(APISpotsHandler): class V1APISpotsHandler(V2APISpotsHandler):
"""API request handler for /api/v1/spots (GET). Included in early Spothole v2 for backwards compatibility.""" """API request handler for /api/v1/spots (GET). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self): def prepare(self):
_handle_legacy_params(self) _handle_legacy_params(self)
@@ -37,8 +38,9 @@ class V1APISpotsHandler(APISpotsHandler):
super().write(chunk) super().write(chunk)
class V1APISpotsStreamHandler(APISpotsStreamHandler): class V1APISpotsStreamHandler(V2APISpotsStreamHandler):
"""API request handler for /api/v1/spots/stream (SSE). Included in early Spothole v2 for backwards compatibility.""" """API request handler for /api/v1/spots/stream (SSE). Included in Spothole v2 onwards for backwards
compatibility."""
def prepare(self): def prepare(self):
_handle_legacy_params(self) _handle_legacy_params(self)
+169
View File
@@ -0,0 +1,169 @@
import json
import tornado
from core.utils import safe_json_dumps
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.lookups import APILookupActivityRefHandler
from webserver.handlers.api.options import APIOptionsHandler
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": "activity", "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"},
}
def _translate_v2_query_params(handler):
"""Rename any v2 query parameters (and values) in the request to their v3 equivalents, so the v3 handler can
understand them."""
for arguments in (handler.request.arguments, handler.request.query_arguments):
for v2_name, v3_name in _V2_TO_V3_QUERY_PARAMS.items():
if v2_name in arguments:
arguments[v3_name] = arguments.pop(v2_name)
for name, value_map in _V2_TO_V3_QUERY_VALUES.items():
if name in arguments:
arguments[name] = [
",".join(value_map.get(item.strip(), item) for item in v.decode("utf-8").split(",")).encode("utf-8")
for v in arguments[name]
]
def _translate_v3_response_object(obj):
"""Rename keys and values in an object from their v3 names to their v2 names."""
if isinstance(obj, dict):
translated = {}
for k, v in obj.items():
if k in _V3_TO_V2_RESPONSE_VALUES and isinstance(v, str):
v = _V3_TO_V2_RESPONSE_VALUES[k].get(v, v)
translated[_V3_TO_V2_RESPONSE_KEYS.get(k, k)] = _translate_v3_response_object(v)
return translated
if isinstance(obj, list):
return [_translate_v3_response_object(i) for i in obj]
return obj
def translate_v3_response(chunk):
"""Translate a JSON string output by a v3 handler into its v2 equivalent"""
if not isinstance(chunk, str):
return chunk
try:
return safe_json_dumps(_translate_v3_response_object(json.loads(chunk)))
except ValueError:
return chunk
def translate_v2_spot(spot_data):
"""Translate a spot provided by a client in v2 format into v3 format"""
spot_data = dict(spot_data)
if "sig" in spot_data:
spot_data["activity"] = spot_data.pop("sig")
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
class _V2ResponseTranslationMixin:
"""Mixin for request handlers that translates v2 query params to v3 on the way in, and v3 JSON responses to v2 on
the way out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write(self, chunk):
super().write(translate_v3_response(chunk))
class _V2StreamTranslationMixin:
"""Mixin for SSE handlers that translates v2 query params to v3 on the way in, and v3 JSON messages to v2 on the way
out"""
def prepare(self):
_translate_v2_query_params(self)
super().prepare()
def write_message(self, name=None, msg=True, wait=None, evt_id=None):
if not name:
msg = translate_v3_response(msg)
return super().write_message(name=name, msg=msg, wait=wait, evt_id=evt_id)
class V2APISpotsHandler(_V2ResponseTranslationMixin, APISpotsHandler):
"""API request handler for /api/v2/spots (GET). Included in Spothole v3 for backwards compatibility."""
class V2APISpotsStreamHandler(_V2StreamTranslationMixin, APISpotsStreamHandler):
"""API request handler for /api/v2/spots/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsHandler(_V2ResponseTranslationMixin, APIAlertsHandler):
"""API request handler for /api/v2/alerts (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIAlertsStreamHandler(_V2StreamTranslationMixin, APIAlertsStreamHandler):
"""API request handler for /api/v2/alerts/stream (SSE). Included in Spothole v3 for backwards compatibility."""
class V2APIOptionsHandler(_V2ResponseTranslationMixin, APIOptionsHandler):
"""API request handler for /api/v2/options (GET). Included in Spothole v3 for backwards compatibility."""
class V2APIStatusHandler(_V2ResponseTranslationMixin, APIStatusHandler):
"""API request handler for /api/v2/status (GET). Included in Spothole v3 for backwards compatibility."""
class V2APILookupSigRefHandler(_V2ResponseTranslationMixin, APILookupActivityRefHandler):
"""API request handler for /api/v2/lookup/sigref (GET). Included in Spothole v3 for backwards compatibility. This
is the v2 equivalent of /api/v3/lookup/activityref."""
class V2APISpotHandler(APISpotHandler):
"""API request handler for /api/v2/spot (POST). Included in Spothole v3 for backwards compatibility. Translates the
spot in the request body from v2 to v3 format. The response is a plain status message so needs no translation."""
def post(self):
# Translate the request body if we can. If the body is empty or invalid JSON, leave it alone and let the v3
# handler return the appropriate error.
try:
json_body = tornado.escape.json_decode(self.request.body)
if isinstance(json_body, dict) and isinstance(json_body.get("spot"), dict):
json_body["spot"] = translate_v2_spot(json_body["spot"])
self.request.body = json.dumps(json_body).encode("utf-8")
except ValueError:
pass
super().post()
+76 -11
View File
@@ -30,6 +30,16 @@ from webserver.handlers.api.status import APIStatusHandler
from webserver.handlers.api.v1_addspot import V1APISpotHandler from webserver.handlers.api.v1_addspot import V1APISpotHandler
from webserver.handlers.api.v1_compatability import V1RedirectHandler from webserver.handlers.api.v1_compatability import V1RedirectHandler
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
from webserver.handlers.api.v2_compatibility import (
V2APIAlertsHandler,
V2APIAlertsStreamHandler,
V2APILookupSigRefHandler,
V2APIOptionsHandler,
V2APISpotHandler,
V2APISpotsHandler,
V2APISpotsStreamHandler,
V2APIStatusHandler,
)
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,25 +117,80 @@ 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/v3/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v3/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v3/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v3/status",
APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v3/lookup/call", APILookupCallHandler),
(r"/api/v3/lookup/activityref", APILookupActivityRefHandler),
(r"/api/v3/lookup/grid", APILookupGridHandler),
(
r"/api/v3/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
]
# v2 API compatibility routes
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", r"/api/v2/solar",
APISolarConditionsHandler, APISolarConditionsHandler,
@@ -138,20 +203,20 @@ class WebServer:
), ),
( (
r"/api/v2/options", r"/api/v2/options",
APIOptionsHandler, V2APIOptionsHandler,
{"status_data": self._data_store.status.get()}, {"status_data": self._data_store.status.get()},
), ),
( (
r"/api/v2/status", r"/api/v2/status",
APIStatusHandler, V2APIStatusHandler,
{"status_data": self._data_store.status.get()}, {"status_data": self._data_store.status.get()},
), ),
(r"/api/v2/lookup/call", APILookupCallHandler), (r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupActivityRefHandler), (r"/api/v2/lookup/sigref", V2APILookupSigRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler), (r"/api/v2/lookup/grid", APILookupGridHandler),
( (
r"/api/v2/spot", r"/api/v2/spot",
APISpotHandler, V2APISpotHandler,
{ {
"spots": self._data_store.spots, "spots": self._data_store.spots,
"spot_providers": self._data_providers, "spot_providers": self._data_providers,
@@ -159,8 +224,8 @@ class WebServer:
), ),
] ]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones # v1 API redirects. Most v1 enpoints are unchanged in v2, and are proxied to the v2 API (which in turn is
# that have the major breaking changes get a bespoke handler. # translated from v3). The ones that have the major breaking changes get a bespoke handler.
v1_compat_routes = [ v1_compat_routes = [
( (
r"/api/v1/spots", r"/api/v1/spots",
@@ -253,7 +318,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,