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