Allow spots and alerts to have multiple activities #143

This commit is contained in:
Ian Renton
2026-09-24 22:57:46 +01:00
parent 81166dccab
commit 4477942bec
34 changed files with 217 additions and 157 deletions
+26 -9
View File
@@ -66,8 +66,11 @@ class Alert:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA).
activity: str | None = None
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
@@ -92,6 +95,11 @@ class Alert:
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def __post_init__(self):
"""Normalise the activities list, removing any duplicates while keeping the order."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -145,10 +153,10 @@ class Alert:
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.activity_refs and self.activity_refs[0] and not self.activity:
self.activity = self.activity_refs[0].activity
# Add the activities of any activity refs we have to the alert's list of activities.
for activity_ref in self.activity_refs:
if activity_ref and activity_ref.activity:
self.add_activity(activity_ref.activity)
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -179,14 +187,23 @@ class Alert:
if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
# Icon for the alert should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""
+40 -33
View File
@@ -125,8 +125,11 @@ class Spot:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA).
activity: str | None = None
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
@@ -158,8 +161,9 @@ 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 activity_refs list.."""
objects such as the activity_refs list, and de-duplicating the activities list."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
if self.activity_refs:
self.activity_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
@@ -268,9 +272,10 @@ class Spot:
if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level activity if it is missing but we have at least one activity ref.
if not self.activity and self.activity_refs:
self.activity = self.activity_refs[0].activity.upper()
# Add the activities of any activity refs we have to the top-level activities list.
for activity_ref in self.activity_refs:
if activity_ref.activity:
self.add_activity(activity_ref.activity.upper())
# See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
@@ -290,11 +295,10 @@ class Spot:
if self.comment:
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# First of all, add the activity to this spot's list of activities. This covers
# things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.activity:
self.activity = found_activity
self.add_activity(found_activity)
# Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the activity_refs list for this spot.
@@ -320,11 +324,9 @@ class Spot:
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
)
for ref_match in ref_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF.
if not self.activity:
self.activity = activity.name
# First of all, add the activity to this spot's list of activities. This covers things
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
self.add_activity(activity.name)
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), activity=activity.name)
)
@@ -346,16 +348,11 @@ class Spot:
):
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
if self.activity in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
if activity_ref.activity in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
self.dx_location_source = LocationSourceForSpot.GRID
else:
self.dx_location_source = LocationSourceForSpot.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.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
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
@@ -407,32 +404,33 @@ class Spot:
self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.activity:
self.activity = ActivityName.SATELLITE
if self.propagation_mode == "Earth-Moon-Earth" and not self.activity:
self.activity = ActivityName.EME
if self.propagation_mode == "Satellite":
self.add_activity(ActivityName.SATELLITE)
if self.propagation_mode == "Earth-Moon-Earth":
self.add_activity(ActivityName.EME)
# Set activities based on the DX callsign suffix
if self.dx_call and not self.activity:
if self.dx_call:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
self.activity = ActivityName.AERONAUTICAL_MOBILE
self.add_activity(ActivityName.AERONAUTICAL_MOBILE)
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
self.activity = ActivityName.MARITIME_MOBILE
self.add_activity(ActivityName.MARITIME_MOBILE)
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
# running DXpedition which we know from the alerts list.
if self.dx_call and not self.activity:
if self.dx_call and not self.activities:
now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values():
if (
alert.activity == ActivityName.DXPEDITION
alert.activities
and ActivityName.DXPEDITION in alert.activities
and alert.dx_calls
and alert.start_time
and alert.end_time
and alert.start_time < now < alert.end_time
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
):
self.activity = ActivityName.DXPEDITION
self.add_activity(ActivityName.DXPEDITION)
break
# DX Grid to lat/lon and vice versa in case one is missing
@@ -531,14 +529,23 @@ class Spot:
self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
# Icon for the spot should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -56,8 +56,8 @@ class BOTA(HTTPAlertProvider):
alert = Alert(
source=self.name,
dx_calls=[dx_call],
activity=ActivityName.BOTA,
activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA)],
activities=[ActivityName.BOTA],
activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA, ref_type=ActivityRefType.BEACH)],
start_time=date_time.timestamp(),
)
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -38,12 +38,13 @@ class Hamsat(HTTPAlertProvider):
dx_grid=source_alert["grids"][0],
freqs_modes=freqs_modes,
comment=source_alert["comment"],
activity=ActivityName.SATELLITE,
activities=[ActivityName.SATELLITE],
# Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
activity_refs=[
ActivityRef(
activity=ActivityName.SATELLITE,
id=source_alert["satellite"]["name"],
ref_type=ActivityRefType.SATELLITE
)
],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
+1 -1
View File
@@ -89,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp,
end_time=end_timestamp,
activity=ActivityName.DXPEDITION,
activities=[ActivityName.DXPEDITION],
)
# Add to our list.
+1 -1
View File
@@ -46,7 +46,7 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"],
activity=activity,
activities=[activity] if activity else [],
activity_refs=activity_refs,
start_time=start_time,
)
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,11 +28,12 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"],
activity=ActivityName.POTA,
activities=[ActivityName.POTA],
activity_refs=[
ActivityRef(
id=source_alert["reference"],
activity=ActivityName.POTA,
ref_type=ActivityRefType.PARK,
name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}",
)
+1 -1
View File
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary,
start_time=start_timestamp,
end_time=end_timestamp,
activity=ActivityName.CONTEST,
activities=[ActivityName.CONTEST],
)
return alert
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,11 +34,12 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"],
comment=source_alert["comments"],
activity=ActivityName.SOTA,
activities=[ActivityName.SOTA],
activity_refs=[
ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
activity=ActivityName.SOTA,
ref_type=ActivityRefType.SUMMIT,
name=summit_name,
activation_score=summit_points,
)
+1 -1
View File
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
url=url,
start_time=start_timestamp,
end_time=end_timestamp,
activity=ActivityName.CONTEST,
activities=[ActivityName.CONTEST],
)
return alert
+2 -2
View File
@@ -7,7 +7,7 @@ import pytz
from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -75,7 +75,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
activity_refs=[ActivityRef(id=ref, activity=ActivityName.WOTA, name=ref_name)] if ref else [],
activity_refs=[ActivityRef(id=ref, activity=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)] if ref else [],
start_time=time.timestamp(),
)
+3 -3
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz
from core.enums import ActivityName
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,8 +28,8 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"],
activity=ActivityName.WWFF,
activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF)],
activities=[ActivityName.WWFF],
activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF, ref_type=ActivityRefType.PARK)],
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC)
.timestamp(),
+11 -11
View File
@@ -106,49 +106,49 @@ class GMA(HTTPSpotProvider):
if "sota" in ref_info and ref_info["sota"] != "":
spot.activity_refs[0].activity = ActivityName.SOTA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.activity = ActivityName.SOTA
spot.add_activity(ActivityName.SOTA)
else:
spot.activity_refs[0].activity = ActivityName.GMA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.activity = ActivityName.GMA
spot.add_activity(ActivityName.GMA)
case "POTA":
spot.activity_refs[0].activity = ActivityName.POTA
spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.activity = ActivityName.POTA
spot.add_activity(ActivityName.POTA)
case "WWFF":
spot.activity_refs[0].activity = ActivityName.WWFF
spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.activity = ActivityName.WWFF
spot.add_activity(ActivityName.WWFF)
case "IOTA Island":
spot.activity_refs[0].activity = ActivityName.IOTA
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.activity = ActivityName.IOTA
spot.add_activity(ActivityName.IOTA)
case "GMA Island":
spot.activity_refs[0].activity = ActivityName.GMA_ISLANDS
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.activity = ActivityName.GMA_ISLANDS
spot.add_activity(ActivityName.GMA_ISLANDS)
case "Lighthouse (ILLW)":
spot.activity_refs[0].activity = ActivityName.ILLW
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.activity = ActivityName.ILLW
spot.add_activity(ActivityName.ILLW)
case "Lighthouse (ARLHS)":
spot.activity_refs[0].activity = ActivityName.ARLHS
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.activity = ActivityName.ARLHS
spot.add_activity(ActivityName.ARLHS)
case "Castle":
spot.activity_refs[0].activity = ActivityName.WCA
spot.activity_refs[0].ref_type = ActivityRefType.CASTLE
spot.activity = ActivityName.WCA
spot.add_activity(ActivityName.WCA)
case "Mill":
spot.activity_refs[0].activity = ActivityName.MOTA
spot.activity_refs[0].ref_type = ActivityRefType.MILL
spot.activity = ActivityName.MOTA
spot.add_activity(ActivityName.MOTA)
case _:
logger.warning(
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
)
spot.activity_refs[0].activity = ref_info["reftype"]
spot.activity = ref_info["reftype"]
spot.add_activity(ref_info["reftype"])
elif not ref_response.from_cache:
if not ref_response.ok:
+1 -1
View File
@@ -62,7 +62,7 @@ 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),
activity=ActivityName.HEMA,
activities=[ActivityName.HEMA],
activity_refs=[
ActivityRef(
id=spot_items[3].upper(),
+1 -1
View File
@@ -34,7 +34,7 @@ class LLOTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment,
activity=ActivityName.LLOTA,
activities=[ActivityName.LLOTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
+2 -2
View File
@@ -70,7 +70,7 @@ class ParksNPeaks(HTTPSpotProvider):
ref_id = source_spot["actSiteID"]
if activity:
spot.activity = activity
spot.add_activity(activity)
if ref_id:
activity_refs = [
@@ -130,7 +130,7 @@ class ParksNPeaks(HTTPSpotProvider):
)
ref_id = spot.activity_refs[0].id if spot.activity_refs else ""
body = {
"actClass": spot.activity or "",
"actClass": next((a for a in spot.activities if self.can_submit_spot(a)), ""),
"actCallsign": spot.dx_call,
"actSite": ref_id,
"mode": spot.mode or "",
+1 -1
View File
@@ -33,7 +33,7 @@ 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"],
activity=ActivityName.POTA,
activities=[ActivityName.POTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
+1 -1
View File
@@ -57,7 +57,7 @@ class SOTA(HTTPSpotProvider):
# Seen SOTA spots with no frequency!
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"],
activity=ActivityName.SOTA,
activities=[ActivityName.SOTA],
activity_refs=[
ActivityRef(
id=source_spot["summitCode"],
+1 -1
View File
@@ -59,7 +59,7 @@ class Tiles(HTTPSpotProvider):
freq=freq,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"],
activity=ActivityName.TILES,
activities=[ActivityName.TILES],
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles activity reference.
activity_refs=[
+1 -1
View File
@@ -34,7 +34,7 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(),
freq=likely_freq,
comment=source_spot["comment"],
activity=ActivityName.TOWERS,
activities=[ActivityName.TOWERS],
activity_refs=[
ActivityRef(id=source_spot["ref"], activity=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER)
],
+1 -1
View File
@@ -92,7 +92,7 @@ class WOTA(HTTPSpotProvider):
freq=freq_hz,
mode=Mode.from_name(mode),
comment=comment,
activity=ActivityName.WOTA,
activities=[ActivityName.WOTA],
activity_refs=(
[
ActivityRef(
+1 -1
View File
@@ -38,7 +38,7 @@ 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"],
activity=ActivityName.WWBOTA,
activities=[ActivityName.WWBOTA],
activity_refs=refs,
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
+1 -1
View File
@@ -30,7 +30,7 @@ class WWFF(HTTPSpotProvider):
freq=float(source_spot["frequency_khz"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"],
activity=ActivityName.WWFF,
activities=[ActivityName.WWFF],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
+1 -1
View File
@@ -37,7 +37,7 @@ class XOTA(WebsocketSpotProvider):
dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()),
activity=self.ACTIVITY,
activities=[self.ACTIVITY] if self.ACTIVITY else [],
activity_refs=[
ActivityRef(
id=ref_id,
+1 -1
View File
@@ -35,7 +35,7 @@ class ZLOTA(HTTPSpotProvider):
freq=freq_hz,
mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"],
activity=ActivityName.ZLOTA,
activities=[ActivityName.ZLOTA],
activity_refs=[
ActivityRef(
id=source_spot["reference"],
+35 -23
View File
@@ -19,20 +19,20 @@ info:
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:** In spot and alert data, the single `sig` value has been replaced with `activities`, a list of unique activity names. A spot or alert can now be associated with more than one activity (e.g. a POTA and WWFF dual activation, a /MM activation via satellite, etc). The array is empty if there is no associated activity. `sig_refs` has been renamed to `activity_refs`.
* **Breaking change:** In activity reference data (i.e. each entry in `activity_refs` of a spot or alert, and the response of the activity reference lookup), `sig` has been renamed to `activity`.
* **Breaking change:** The `dx_location_source` value "SIG REF LOOKUP" has been renamed to "ACTIVITY REF LOOKUP".
* **Breaking change:** The `/spots`, `/spots/stream`, `/alerts` and `/alerts/stream` query parameter `sig` has been renamed to `activity`, and its special value `NO_SIG` to `NO_ACTIVITY`. The `/spots` and `/spots/stream` query parameters `needs_sig` and `needs_sig_ref` have been renamed to `needs_activity` and `needs_activity_ref`. When using the `fields` query parameter, use the new field names `activity` and `activity_refs`.
* **Breaking change:** The `/spots`, `/spots/stream`, `/alerts` and `/alerts/stream` query parameter `sig` has been renamed to `activity`, and its special value `NO_SIG` to `NO_ACTIVITY`. The `/spots` and `/spots/stream` query parameters `needs_sig` and `needs_sig_ref` have been renamed to `needs_activity` and `needs_activity_ref`. The `activity` filter now matches any spot or alert that has at least one of the requested activities.
* **Breaking change:** `/lookup/sigref` has been renamed to `/lookup/activityref`, and its `sig` query parameter has been renamed to `activity`.
* **Breaking change:** POST `/spot` now expects `activity` and `activity_refs` in the `spot` object, rather than `sig` and `sig_refs`.
* **Breaking change:** POST `/spot` now expects `activities` (a list) and `activity_refs` in the `spot` object, rather than `sig` and `sig_refs`.
* **Breaking change:** In the `/options` response, `sigs` has been renamed to `activities`, and within each activity, `sig_type` has been renamed to `activity_type`.
* **Breaking change:** In the `/status` response, `sig_ref_data_providers` has been renamed to `activity_ref_data_providers`, and within each provider, `sig_name` has been renamed to `activity_name`.
#### Upgrading a client from v2 to v3 API endpoints
In v3.0 of Spothole, the `v2` (and `v1`) API endpoints will be maintained for backwards compatibility, so if you have written a client against the `v2` API, it will continue to receive `sig`, `sig_refs` etc. as before. However, you are encouraged to move to the `v3` API endpoints as soon as possible.
In v3.0 of Spothole, the `v2` (and `v1`) API endpoints will be maintained for backwards compatibility, so if you have written a client against the `v2` API, it will continue to receive `sig`, `sig_refs` etc. as before. Where a spot or alert has more than one activity, the `v2` and `v1` APIs will return only the first one as `sig`.
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=...`.
You are encouraged to move to the `v3` API endpoints as soon as possible. To upgrade, replace `v2` with `v3` in the URLs your code calls, then rename any use of the fields, query parameters and values listed above, and handle `activities` being a list rather than a single `sig` value. If you use the activity reference lookup, call `/lookup/activityref?activity=...&id=...` instead of `/lookup/sigref?sig=...&id=...`.
### 2.2
@@ -582,11 +582,11 @@ components:
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 `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_activity` query parameter for a shortcut.
To select more than one activity, supply a comma-separated list. A spot matches if any of its activities are
in the list. The special `activity` name `NO_ACTIVITY` matches spots with no activity set. You can use
`activity=NO_ACTIVITY` to specifically only return generic spots with no associated activity. You can also use
combinations to request for example POTA + no activity, but reject other activities. If you want to request
'every activity but not No Activity', see the `needs_activity` query parameter for a shortcut.
schema:
$ref: "#/components/schemas/ActivityNameIncludingNoActivity"
SpotNeedsActivity:
@@ -746,8 +746,7 @@ components:
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_ACTIVITY' can be included to return alerts
specifically without an associated activity.
activity, supply a comma-separated list. An alert matches if any of its activities are in the list.
schema:
$ref: "#/components/schemas/ActivityNameIncludingNoActivity"
AlertDxContinent:
@@ -1334,9 +1333,16 @@ components:
type: string
description: Comment left by the spotter, if any
example: "59 in NY 73"
activity:
description: Activity, e.g. outdoor activity programme such as POTA
$ref: "#/components/schemas/ActivityName"
activities:
type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName"
description: >
Activities, e.g. outdoor activity programmes such as POTA. There may be more than one, e.g. for a POTA plus
WWFF dual activation, or none. Each activity appears at most once. The first activity is the "primary" one,
e.g. the activity of the programme the spot came from, and is the one used to choose the icon.
example: [ "POTA", "WWFF" ]
activity_refs:
type: array
items:
@@ -1382,10 +1388,9 @@ components:
submit_upstream:
type: boolean
description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
than only adding it to this Spothole server. Requires `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.
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather than only adding it
to this Spothole server. Requires `upstream_provider` to be set. Check `spot_submit_providers` in the
`/options` response to see which activities and providers support this.
default: false
upstream_provider:
type: string
@@ -1522,9 +1527,16 @@ components:
type: string
description: Comment made by the activator, if any
example: "2025 DXpedition to null island"
activity:
description: Activity, e.g. outdoor activity programme such as POTA
$ref: "#/components/schemas/ActivityName"
activities:
type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName"
description: >
Activities, e.g. outdoor activity programmes such as POTA. There may be more than one, e.g. for a POTA and
WWFF dual activation, or none. Each activity appears at most once. The first activity is the "primary" one,
e.g. the activity of the programme the spot came from, and is the one used to choose the icon.
example: [ "POTA", "WWFF" ]
activity_refs:
type: array
items:
+1 -1
View File
@@ -208,7 +208,7 @@ function addSpot() {
spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode;
if (activity !== "") spot["activity"] = activity;
if (activity !== "") spot["activities"] = [activity];
if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment;
+4 -4
View File
@@ -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["activity"] === "Contest") {
if (dx_calls_html === "" && a["activities"] != null && a["activities"].includes("Contest")) {
// Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest"
}
// Format DXpedition country
let dx_country_html = "";
if (a["activity"] === "DXpedition" && a["dx_country"] != null && a["dx_country"] !== "") {
if (a["activities"] != null && a["activities"].includes("DXpedition") && a["dx_country"] != null && a["dx_country"] !== "") {
dx_country_html = `<br/>${a["dx_country"]}`;
}
@@ -252,8 +252,8 @@ function addAlertRowsToTable(tbody, alerts) {
// Activity or fallback to "General DX"
let activityText = "General DX";
if (a["activity"]) {
activityText = a["activity"];
if (a["activities"] != null && a["activities"].length > 0) {
activityText = a["activities"].join(", ");
}
// Format activity refs
+2 -2
View File
@@ -276,8 +276,8 @@ function getTooltipText(s) {
// Activity or fallback to source
let activitySourceText = s["source"];
if (s["activity"]) {
activitySourceText = s["activity"];
if (s["activities"] != null && s["activities"].length > 0) {
activitySourceText = s["activities"].join(", ");
}
// Format activity refs
+2 -2
View File
@@ -329,8 +329,8 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Format activity
let activityText = "General DX";
if (s["activity"]) {
activityText = s["activity"];
if (s["activities"] != null && s["activities"].length > 0) {
activityText = s["activities"].join(", ");
}
// Format activity refs
+19 -23
View File
@@ -142,24 +142,19 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Reject if activity ref format incorrect for activity
if (
spot.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.activity_refs[0].id}' does not look like a valid reference for {spot.activity}."
# Reject if any activity ref format is incorrect for its activity
for activity_ref in spot.activity_refs:
ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None
if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id):
self.set_status(422)
self.write(
safe_json_dumps(
f"Error - '{activity_ref.id}' does not look like a valid reference for {activity_ref.activity}."
)
)
)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
@@ -171,7 +166,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.activity:
if not spot.activities:
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
self.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store")
@@ -201,7 +197,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.activity)
provider = self._find_provider(upstream_provider_name, spot.activities)
if provider:
try:
# Submit spot to the upstream provider
@@ -216,7 +212,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.activity if spot.activity else ''} spots."
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
@@ -242,11 +238,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activity) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity."""
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for at least one of the given activities."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(activity):
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
return p
return None
+6 -6
View File
@@ -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.activity == ActivityName.DXPEDITION
ActivityName.DXPEDITION in alert.activities
and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
):
continue
if (
alert.activity == ActivityName.CONTEST
ActivityName.CONTEST in alert.activities
and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
):
@@ -187,13 +187,13 @@ def alert_allowed_by_query(alert, query):
if not alert.source or alert.source not in sources:
return False
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_ACTIVITY", when supplied in the list, matches alerts with no activity.
# If a list of activities is provided, the alert must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_ACTIVITY" in activities
if not alert.activity and not include_no_activity:
if not alert.activities and not include_no_activity:
return False
if alert.activity and alert.activity not in activities:
if alert.activities and not any(a in activities for a in alert.activities):
return False
case "dx_continent":
dxconts = query.get(k).split(",")
@@ -24,7 +24,7 @@ _V2_TO_V3_QUERY_PARAMS = {
# Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"},
"fields": {"sig": "activity", "sig_refs": "activity_refs"},
"fields": {"sig": "activities", "sig_refs": "activity_refs"},
}
# Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = {
@@ -54,19 +54,28 @@ class V2CompatibilityWrapper(CompatibilityWrapper):
return rename_keys_and_values(obj, _V3_TO_V2_RESPONSE_KEYS, _V3_TO_V2_RESPONSE_VALUES)
class V2APISpotsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APISpotsHandler):
class V2SpotsAlertsCompatibilityWrapper(V2CompatibilityWrapper):
"""Extra translation for spots and alerts. In v3 these have a list of "activities" rather than a single activity,
so for v2 we collapse this back down to a single value using the first activity in the list. This must happen
before the generic key renaming, which would otherwise rename "activities" to "sigs" as it does for /options."""
def translate_response_object(self, obj):
return super().translate_response_object(collapse_activities(obj))
class V2APISpotsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APISpotsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APISpotsStreamHandler(V2CompatibilityWrapper, StreamCompatibilityWrapper, APISpotsStreamHandler):
class V2APISpotsStreamHandler(V2SpotsAlertsCompatibilityWrapper, StreamCompatibilityWrapper, APISpotsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIAlertsHandler):
class V2APIAlertsHandler(V2SpotsAlertsCompatibilityWrapper, RequestCompatibilityWrapper, APIAlertsHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
class V2APIAlertsStreamHandler(V2CompatibilityWrapper, StreamCompatibilityWrapper, APIAlertsStreamHandler):
class V2APIAlertsStreamHandler(V2SpotsAlertsCompatibilityWrapper, StreamCompatibilityWrapper, APIAlertsStreamHandler):
"""No special handling for this, the compatibility wrapper will handle translation to and from the later version."""
@@ -118,7 +127,8 @@ class V2APISpotHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIS
spot_data = dict(spot_data)
if "sig" in spot_data:
spot_data["activity"] = spot_data.pop("sig")
sig = spot_data.pop("sig")
spot_data["activities"] = [sig] if sig else []
if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list):
@@ -130,3 +140,18 @@ class V2APISpotHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIS
refs.append(ref)
spot_data["activity_refs"] = refs
return spot_data
def collapse_activities(obj):
"""Utility method to replace the "activities" list in a spot or alert JSON object with a single "activity" value,
being the first activity in the list, or None if there are none. The object can be a single spot/alert dict, or a
list of them. Anything else is returned untouched. Used to translate v3's list of activities to the single sig
expected in v2 API calls."""
if isinstance(obj, list):
return [collapse_activities(i) for i in obj]
if isinstance(obj, dict) and "activities" in obj:
obj = dict(obj)
activities = obj.pop("activities")
obj["activity"] = activities[0] if activities else None
return obj
+5 -5
View File
@@ -197,19 +197,19 @@ def spot_allowed_by_query(spot, query):
if not spot.source or spot.source not in sources:
return False
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_ACTIVITY", when supplied in the list, matches spots with no activity.
# If a list of activities is provided, the spot must have at least one activity that matches one of
# them. The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",")
include_no_activity = "NO_ACTIVITY" in activities
if not spot.activity and not include_no_activity:
if not spot.activities and not include_no_activity:
return False
if spot.activity and spot.activity not in activities:
if spot.activities and not any(a in activities for a in spot.activities):
return False
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_ACTIVITY" parameter to the "activity" query param.
needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.activity:
if needs_activity and not spot.activities:
return False
case "needs_activity_ref":
# If true, at least one activity ref is required, regardless of what it is, it just can't be missing.