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
+25 -8
View File
@@ -66,8 +66,11 @@ class Alert:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). # Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
activity: str | None = None # e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# 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 references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list) 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 to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None icon: str | None = None
def __post_init__(self):
"""Normalise the activities list, removing any duplicates while keeping the order."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -145,10 +153,10 @@ class Alert:
self.dx_latitude = activity_ref.latitude self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude self.dx_longitude = activity_ref.longitude
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that # Add the activities of any activity refs we have to the alert's list of activities.
# reference's activity and apply it to the whole spot. for activity_ref in self.activity_refs:
if self.activity_refs and self.activity_refs[0] and not self.activity: if activity_ref and activity_ref.activity:
self.activity = self.activity_refs[0].activity self.add_activity(activity_ref.activity)
# DX Grid to lat/lon and vice versa in case one is missing # DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude): if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -179,14 +187,23 @@ class Alert:
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls] self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower # Icon for the alert should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell" self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)): for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon self.icon = activity_icon
break
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""
+39 -32
View File
@@ -125,8 +125,11 @@ class Spot:
# Activity info # Activity info
# Activity (e.g. outdoor activity programme such as POTA). # Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
activity: str | None = None # e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# 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 references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list) activity_refs: list = field(default_factory=list)
@@ -158,8 +161,9 @@ class Spot:
def __post_init__(self): def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API """Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the 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: if self.activity_refs:
self.activity_refs = [ self.activity_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref) activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
@@ -268,9 +272,10 @@ class Spot:
if self.dx_latitude or self.dx_grid: if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level activity if it is missing but we have at least one activity ref. # Add the activities of any activity refs we have to the top-level activities list.
if not self.activity and self.activity_refs: for activity_ref in self.activity_refs:
self.activity = self.activity_refs[0].activity.upper() if activity_ref.activity:
self.add_activity(activity_ref.activity.upper())
# See if we already have an activity reference, but the comment looks like it contains more for the same # See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002". # activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
@@ -290,11 +295,10 @@ class Spot:
if self.comment: if self.comment:
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE) activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches: for activity_match in activity_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers # First of all, add the activity to this spot's list of activities. This covers
# things like cluster spots where the comment is just "POTA". # things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2)) found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.activity: self.add_activity(found_activity)
self.activity = found_activity
# Now look to see if that activity name was followed by something that looks like a reference ID # Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the activity_refs list for this spot. # 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 r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
) )
for ref_match in ref_matches: for ref_match in ref_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This # First of all, add the activity to this spot's list of activities. This covers things
# covers things like cluster spots where the comment is just "OHFF-1234", now we know # like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
# it's WWFF. self.add_activity(activity.name)
if not self.activity:
self.activity = activity.name
self._append_activity_ref_if_missing( self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), activity=activity.name) ActivityRef(id=ref_match.group(2).upper(), activity=activity.name)
) )
@@ -346,16 +348,11 @@ class Spot:
): ):
self.dx_latitude = activity_ref.latitude self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude self.dx_longitude = activity_ref.longitude
if self.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 self.dx_location_source = LocationSourceForSpot.GRID
else: else:
self.dx_location_source = LocationSourceForSpot.ACTIVITY_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.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". # Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as # These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
# being the only source we have for propagation mode. Brace for nightmare regex from hell. # 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 self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode # Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.activity: if self.propagation_mode == "Satellite":
self.activity = ActivityName.SATELLITE self.add_activity(ActivityName.SATELLITE)
if self.propagation_mode == "Earth-Moon-Earth" and not self.activity: if self.propagation_mode == "Earth-Moon-Earth":
self.activity = ActivityName.EME self.add_activity(ActivityName.EME)
# Set activities based on the DX callsign suffix # Set activities based on the DX callsign suffix
if self.dx_call and not self.activity: if self.dx_call:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE): 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): 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 # Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
# running DXpedition which we know from the alerts list. # running DXpedition which we know from the alerts list.
if self.dx_call and not self.activity: if self.dx_call and not self.activities:
now = datetime.now(pytz.UTC).timestamp() now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values(): for alert in DATA_STORE.alerts.values():
if ( if (
alert.activity == ActivityName.DXPEDITION alert.activities
and ActivityName.DXPEDITION in alert.activities
and alert.dx_calls and alert.dx_calls
and alert.start_time and alert.start_time
and alert.end_time and alert.end_time
and alert.start_time < now < alert.end_time and alert.start_time < now < alert.end_time
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c] and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
): ):
self.activity = ActivityName.DXPEDITION self.add_activity(ActivityName.DXPEDITION)
break break
# DX Grid to lat/lon and vice versa in case one is missing # DX Grid to lat/lon and vice versa in case one is missing
@@ -531,14 +529,23 @@ class Spot:
self.de_longitude = de_call_info.longitude self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid self.de_grid = de_call_info.grid
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower # Icon for the spot should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell" self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)): for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon self.icon = activity_icon
break
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self): def to_json(self):
"""JSON serialise""" """JSON serialise"""
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
import pytz import pytz
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -56,8 +56,8 @@ class BOTA(HTTPAlertProvider):
alert = Alert( alert = Alert(
source=self.name, source=self.name,
dx_calls=[dx_call], dx_calls=[dx_call],
activity=ActivityName.BOTA, activities=[ActivityName.BOTA],
activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA)], activity_refs=[ActivityRef(id=ref_name, activity=ActivityName.BOTA, ref_type=ActivityRefType.BEACH)],
start_time=date_time.timestamp(), start_time=date_time.timestamp(),
) )
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -38,12 +38,13 @@ class Hamsat(HTTPAlertProvider):
dx_grid=source_alert["grids"][0], dx_grid=source_alert["grids"][0],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=source_alert["comment"], comment=source_alert["comment"],
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 # Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
activity=ActivityName.SATELLITE, activity=ActivityName.SATELLITE,
id=source_alert["satellite"]["name"], id=source_alert["satellite"]["name"],
ref_type=ActivityRefType.SATELLITE
) )
], ],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ") start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
+1 -1
View File
@@ -89,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}", comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
activity=ActivityName.DXPEDITION, activities=[ActivityName.DXPEDITION],
) )
# Add to our list. # Add to our list.
+1 -1
View File
@@ -46,7 +46,7 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()], dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}", freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"], comment=source_alert["Comments"],
activity=activity, activities=[activity] if activity else [],
activity_refs=activity_refs, activity_refs=activity_refs,
start_time=start_time, start_time=start_time,
) )
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,11 +28,12 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()], dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"], freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"], comment=source_alert["comments"],
activity=ActivityName.POTA, activities=[ActivityName.POTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_alert["reference"], id=source_alert["reference"],
activity=ActivityName.POTA, activity=ActivityName.POTA,
ref_type=ActivityRefType.PARK,
name=source_alert["name"], name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}", url=f"https://pota.app/#/park/{source_alert['reference']}",
) )
+1 -1
View File
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary, comment=summary,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
activity=ActivityName.CONTEST, activities=[ActivityName.CONTEST],
) )
return alert return alert
+3 -2
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,11 +34,12 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()], dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"], freqs_modes=source_alert["frequency"],
comment=source_alert["comments"], comment=source_alert["comments"],
activity=ActivityName.SOTA, activities=[ActivityName.SOTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}", id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
activity=ActivityName.SOTA, activity=ActivityName.SOTA,
ref_type=ActivityRefType.SUMMIT,
name=summit_name, name=summit_name,
activation_score=summit_points, activation_score=summit_points,
) )
+1 -1
View File
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
url=url, url=url,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
activity=ActivityName.CONTEST, activities=[ActivityName.CONTEST],
) )
return alert return alert
+2 -2
View File
@@ -7,7 +7,7 @@ import pytz
from rss_parser import Parser as RSSParser from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -75,7 +75,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call], dx_calls=[dx_call],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=comment, comment=comment,
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(), start_time=time.timestamp(),
) )
+3 -3
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import ActivityName from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,8 +28,8 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()], dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}", freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"], comment=source_alert["remarks"],
activity=ActivityName.WWFF, activities=[ActivityName.WWFF],
activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF)], activity_refs=[ActivityRef(id=source_alert["reference"], activity=ActivityName.WWFF, ref_type=ActivityRefType.PARK)],
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S") start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
+11 -11
View File
@@ -106,49 +106,49 @@ class GMA(HTTPSpotProvider):
if "sota" in ref_info and ref_info["sota"] != "": if "sota" in ref_info and ref_info["sota"] != "":
spot.activity_refs[0].activity = ActivityName.SOTA spot.activity_refs[0].activity = ActivityName.SOTA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.activity = ActivityName.SOTA spot.add_activity(ActivityName.SOTA)
else: else:
spot.activity_refs[0].activity = ActivityName.GMA spot.activity_refs[0].activity = ActivityName.GMA
spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT spot.activity_refs[0].ref_type = ActivityRefType.SUMMIT
spot.activity = ActivityName.GMA spot.add_activity(ActivityName.GMA)
case "POTA": case "POTA":
spot.activity_refs[0].activity = ActivityName.POTA spot.activity_refs[0].activity = ActivityName.POTA
spot.activity_refs[0].ref_type = ActivityRefType.PARK spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.activity = ActivityName.POTA spot.add_activity(ActivityName.POTA)
case "WWFF": case "WWFF":
spot.activity_refs[0].activity = ActivityName.WWFF spot.activity_refs[0].activity = ActivityName.WWFF
spot.activity_refs[0].ref_type = ActivityRefType.PARK spot.activity_refs[0].ref_type = ActivityRefType.PARK
spot.activity = ActivityName.WWFF spot.add_activity(ActivityName.WWFF)
case "IOTA Island": case "IOTA Island":
spot.activity_refs[0].activity = ActivityName.IOTA spot.activity_refs[0].activity = ActivityName.IOTA
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.activity = ActivityName.IOTA spot.add_activity(ActivityName.IOTA)
case "GMA Island": case "GMA Island":
spot.activity_refs[0].activity = ActivityName.GMA_ISLANDS spot.activity_refs[0].activity = ActivityName.GMA_ISLANDS
spot.activity_refs[0].ref_type = ActivityRefType.ISLAND spot.activity_refs[0].ref_type = ActivityRefType.ISLAND
spot.activity = ActivityName.GMA_ISLANDS spot.add_activity(ActivityName.GMA_ISLANDS)
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.activity_refs[0].activity = ActivityName.ILLW spot.activity_refs[0].activity = ActivityName.ILLW
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.activity = ActivityName.ILLW spot.add_activity(ActivityName.ILLW)
case "Lighthouse (ARLHS)": case "Lighthouse (ARLHS)":
spot.activity_refs[0].activity = ActivityName.ARLHS spot.activity_refs[0].activity = ActivityName.ARLHS
spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE spot.activity_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.activity = ActivityName.ARLHS spot.add_activity(ActivityName.ARLHS)
case "Castle": case "Castle":
spot.activity_refs[0].activity = ActivityName.WCA spot.activity_refs[0].activity = ActivityName.WCA
spot.activity_refs[0].ref_type = ActivityRefType.CASTLE spot.activity_refs[0].ref_type = ActivityRefType.CASTLE
spot.activity = ActivityName.WCA spot.add_activity(ActivityName.WCA)
case "Mill": case "Mill":
spot.activity_refs[0].activity = ActivityName.MOTA spot.activity_refs[0].activity = ActivityName.MOTA
spot.activity_refs[0].ref_type = ActivityRefType.MILL spot.activity_refs[0].ref_type = ActivityRefType.MILL
spot.activity = ActivityName.MOTA spot.add_activity(ActivityName.MOTA)
case _: case _:
logger.warning( logger.warning(
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!" f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
) )
spot.activity_refs[0].activity = ref_info["reftype"] 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: elif not ref_response.from_cache:
if not ref_response.ok: if not ref_response.ok:
+1 -1
View File
@@ -62,7 +62,7 @@ class HEMA(HTTPSpotProvider):
freq=float(freq_mode_match.group(1)) * 1000000, freq=float(freq_mode_match.group(1)) * 1000000,
mode=Mode.from_name(freq_mode_match.group(2).upper()), mode=Mode.from_name(freq_mode_match.group(2).upper()),
comment=spotter_comment_match.group(2), comment=spotter_comment_match.group(2),
activity=ActivityName.HEMA, activities=[ActivityName.HEMA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=spot_items[3].upper(), id=spot_items[3].upper(),
+1 -1
View File
@@ -34,7 +34,7 @@ class LLOTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000000, freq=float(source_spot["frequency"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment, comment=comment,
activity=ActivityName.LLOTA, activities=[ActivityName.LLOTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
+2 -2
View File
@@ -70,7 +70,7 @@ class ParksNPeaks(HTTPSpotProvider):
ref_id = source_spot["actSiteID"] ref_id = source_spot["actSiteID"]
if activity: if activity:
spot.activity = activity spot.add_activity(activity)
if ref_id: if ref_id:
activity_refs = [ activity_refs = [
@@ -130,7 +130,7 @@ class ParksNPeaks(HTTPSpotProvider):
) )
ref_id = spot.activity_refs[0].id if spot.activity_refs else "" ref_id = spot.activity_refs[0].id if spot.activity_refs else ""
body = { body = {
"actClass": spot.activity or "", "actClass": next((a for a in spot.activities if self.can_submit_spot(a)), ""),
"actCallsign": spot.dx_call, "actCallsign": spot.dx_call,
"actSite": ref_id, "actSite": ref_id,
"mode": spot.mode or "", "mode": spot.mode or "",
+1 -1
View File
@@ -33,7 +33,7 @@ class POTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None, freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
activity=ActivityName.POTA, activities=[ActivityName.POTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
+1 -1
View File
@@ -57,7 +57,7 @@ class SOTA(HTTPSpotProvider):
# Seen SOTA spots with no frequency! # Seen SOTA spots with no frequency!
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
activity=ActivityName.SOTA, activities=[ActivityName.SOTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["summitCode"], id=source_spot["summitCode"],
+1 -1
View File
@@ -59,7 +59,7 @@ class Tiles(HTTPSpotProvider):
freq=freq, freq=freq,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"], comment=source_spot["notes"],
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. # Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles activity reference. # Just take the grid reference itself as the single Tiles activity reference.
activity_refs=[ activity_refs=[
+1 -1
View File
@@ -34,7 +34,7 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
freq=likely_freq, freq=likely_freq,
comment=source_spot["comment"], comment=source_spot["comment"],
activity=ActivityName.TOWERS, activities=[ActivityName.TOWERS],
activity_refs=[ activity_refs=[
ActivityRef(id=source_spot["ref"], activity=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER) 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, freq=freq_hz,
mode=Mode.from_name(mode), mode=Mode.from_name(mode),
comment=comment, comment=comment,
activity=ActivityName.WOTA, activities=[ActivityName.WOTA],
activity_refs=( activity_refs=(
[ [
ActivityRef( ActivityRef(
+1 -1
View File
@@ -38,7 +38,7 @@ class WWBOTA(SSESpotProvider):
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None, mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
comment=source_spot["comment"], comment=source_spot["comment"],
activity=ActivityName.WWBOTA, activities=[ActivityName.WWBOTA],
activity_refs=refs, activity_refs=refs,
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
+1 -1
View File
@@ -30,7 +30,7 @@ class WWFF(HTTPSpotProvider):
freq=float(source_spot["frequency_khz"]) * 1000, freq=float(source_spot["frequency_khz"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"], comment=source_spot["remarks"],
activity=ActivityName.WWFF, activities=[ActivityName.WWFF],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
+1 -1
View File
@@ -37,7 +37,7 @@ class XOTA(WebsocketSpotProvider):
dx_call=source_spot["stationCallSign"].upper(), dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000, freq=float(source_spot["freq"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
activity=self.ACTIVITY, activities=[self.ACTIVITY] if self.ACTIVITY else [],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=ref_id, id=ref_id,
+1 -1
View File
@@ -35,7 +35,7 @@ class ZLOTA(HTTPSpotProvider):
freq=freq_hz, freq=freq_hz,
mode=Mode.from_name(source_spot["mode"].upper().strip()), mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"], comment=source_spot["comments"],
activity=ActivityName.ZLOTA, activities=[ActivityName.ZLOTA],
activity_refs=[ activity_refs=[
ActivityRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
+33 -21
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. 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 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`, and the response of the activity reference lookup), `sig` has been renamed to `activity`. * **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 `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:** `/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 `/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`. * **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 #### 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 ### 2.2
@@ -582,11 +582,11 @@ components:
in: query in: query
description: > description: >
Limit the spots to only ones from one or more activities provided as an argument. Limit the spots to only ones from one or more activities provided as an argument.
To select more than one activity, supply a comma-separated list. The special `activity` name `NO_ACTIVITY` To select more than one activity, supply a comma-separated list. A spot matches if any of its activities are
matches spots with no activity set. You can use `activity=NO_ACTIVITY` to specifically only return generic in the list. The special `activity` name `NO_ACTIVITY` matches spots with no activity set. You can use
spots with no associated activity. You can also use combinations to request for example POTA + no `activity=NO_ACTIVITY` to specifically only return generic spots with no associated activity. You can also use
activity, but reject other activities. If you want to request 'every activity and not No Activity', see the combinations to request for example POTA + no activity, but reject other activities. If you want to request
`needs_activity` query parameter for a shortcut. 'every activity but not No Activity', see the `needs_activity` query parameter for a shortcut.
schema: schema:
$ref: "#/components/schemas/ActivityNameIncludingNoActivity" $ref: "#/components/schemas/ActivityNameIncludingNoActivity"
SpotNeedsActivity: SpotNeedsActivity:
@@ -746,8 +746,7 @@ components:
in: query in: query
description: > description: >
Limit the alerts to only ones from one or more activities. To select more than one Limit the alerts to only ones from one or more activities. To select more than one
activity, supply a comma-separated list. The special value 'NO_ACTIVITY' can be included to return alerts activity, supply a comma-separated list. An alert matches if any of its activities are in the list.
specifically without an associated activity.
schema: schema:
$ref: "#/components/schemas/ActivityNameIncludingNoActivity" $ref: "#/components/schemas/ActivityNameIncludingNoActivity"
AlertDxContinent: AlertDxContinent:
@@ -1334,9 +1333,16 @@ components:
type: string type: string
description: Comment left by the spotter, if any description: Comment left by the spotter, if any
example: "59 in NY 73" example: "59 in NY 73"
activity: activities:
description: Activity, e.g. outdoor activity programme such as POTA type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName" $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: activity_refs:
type: array type: array
items: items:
@@ -1382,10 +1388,9 @@ components:
submit_upstream: submit_upstream:
type: boolean type: boolean
description: > description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather than only adding it
than only adding it to this Spothole server. Requires `activity`, at least one `activity_refs` to this Spothole server. Requires `upstream_provider` to be set. Check `spot_submit_providers` in the
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the `/options` response to see which activities and providers support this.
/options response to see which activities and providers support this.
default: false default: false
upstream_provider: upstream_provider:
type: string type: string
@@ -1522,9 +1527,16 @@ components:
type: string type: string
description: Comment made by the activator, if any description: Comment made by the activator, if any
example: "2025 DXpedition to null island" example: "2025 DXpedition to null island"
activity: activities:
description: Activity, e.g. outdoor activity programme such as POTA type: array
uniqueItems: true
items:
$ref: "#/components/schemas/ActivityName" $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: activity_refs:
type: array type: array
items: items:
+1 -1
View File
@@ -208,7 +208,7 @@ function addSpot() {
spot["dx_call"] = dx; spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000; spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode; if (mode !== "") spot["mode"] = mode;
if (activity !== "") spot["activity"] = activity; if (activity !== "") spot["activities"] = [activity];
if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}]; if (activityRef !== "") spot["activity_refs"] = [{activity: activity, id: activityRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid; if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment; if (comment !== "") spot["comment"] = comment;
+4 -4
View File
@@ -210,14 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["dx_calls"] != null) { if (a["dx_calls"] != null) {
dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", "); dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", ");
} }
if (dx_calls_html === "" && a["activity"] === "Contest") { if (dx_calls_html === "" && a["activities"] != null && a["activities"].includes("Contest")) {
// Contest = true and no DX callsigns, so display "Contest" // Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest" dx_calls_html = "Contest"
} }
// Format DXpedition country // Format DXpedition country
let dx_country_html = ""; let dx_country_html = "";
if (a["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"]}`; dx_country_html = `<br/>${a["dx_country"]}`;
} }
@@ -252,8 +252,8 @@ function addAlertRowsToTable(tbody, alerts) {
// Activity or fallback to "General DX" // Activity or fallback to "General DX"
let activityText = "General DX"; let activityText = "General DX";
if (a["activity"]) { if (a["activities"] != null && a["activities"].length > 0) {
activityText = a["activity"]; activityText = a["activities"].join(", ");
} }
// Format activity refs // Format activity refs
+2 -2
View File
@@ -276,8 +276,8 @@ function getTooltipText(s) {
// Activity or fallback to source // Activity or fallback to source
let activitySourceText = s["source"]; let activitySourceText = s["source"];
if (s["activity"]) { if (s["activities"] != null && s["activities"].length > 0) {
activitySourceText = s["activity"]; activitySourceText = s["activities"].join(", ");
} }
// Format activity refs // Format activity refs
+2 -2
View File
@@ -329,8 +329,8 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Format activity // Format activity
let activityText = "General DX"; let activityText = "General DX";
if (s["activity"]) { if (s["activities"] != null && s["activities"].length > 0) {
activityText = s["activity"]; activityText = s["activities"].join(", ");
} }
// Format activity refs // Format activity refs
+12 -16
View File
@@ -142,19 +142,14 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Reject if activity ref format incorrect for activity # Reject if any activity ref format is incorrect for its activity
if ( for activity_ref in spot.activity_refs:
spot.activity ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None
and spot.activity_refs if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id):
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.set_status(422)
self.write( self.write(
safe_json_dumps( safe_json_dumps(
f"Error - '{spot.activity_refs[0].id}' does not look like a valid reference for {spot.activity}." 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("Cache-Control", "no-store")
@@ -171,7 +166,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Validate upstream submission requirements # Validate upstream submission requirements
if submit_upstream and upstream_provider_name: if submit_upstream and upstream_provider_name:
if not spot.activity: if not spot.activities:
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream.")) self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -201,7 +197,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Submit upstream if requested # Submit upstream if requested
upstream_warning = None upstream_warning = None
if submit_upstream and upstream_provider_name: if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.activity) provider = self._find_provider(upstream_provider_name, spot.activities)
if provider: if provider:
try: try:
# Submit spot to the upstream provider # Submit spot to the upstream provider
@@ -216,7 +212,7 @@ class APISpotHandler(tornado.web.RequestHandler):
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed." f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
) )
else: else:
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.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 # If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or # duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
@@ -242,11 +238,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, activity) -> SpotProvider | None: def _find_provider(self, provider_name, activities) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given activity.""" """Find an enabled provider by name that can submit spots for at least one of the given activities."""
for p in self._spot_providers: for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(activity): if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
return p return p
return None return None
+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 # the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check. # always passes the check.
if ( if (
alert.activity == ActivityName.DXPEDITION ActivityName.DXPEDITION in alert.activities
and "dxpeditions_skip_max_duration_check" in query and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE" and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
): ):
continue continue
if ( if (
alert.activity == ActivityName.CONTEST ActivityName.CONTEST in alert.activities
and "contests_skip_max_duration_check" in query and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE" and query.get("contests_skip_max_duration_check").upper() == "TRUE"
): ):
@@ -187,13 +187,13 @@ def alert_allowed_by_query(alert, query):
if not alert.source or alert.source not in sources: if not alert.source or alert.source not in sources:
return False return False
case "activity": case "activity":
# If a list of activities is provided, the alert must have an activity and it must match one of them. # If a list of activities is provided, the alert must have at least one activity that matches one of
# The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity. # them. The special activity "NO_ACTIVITY", when supplied in the list, matches alerts with no activity.
activities = query.get(k).split(",") activities = query.get(k).split(",")
include_no_activity = "NO_ACTIVITY" in activities 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 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 return False
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") dxconts = query.get(k).split(",")
@@ -24,7 +24,7 @@ _V2_TO_V3_QUERY_PARAMS = {
# Values of query parameters renamed in v3 # Values of query parameters renamed in v3
_V2_TO_V3_QUERY_VALUES = { _V2_TO_V3_QUERY_VALUES = {
"activity": {"NO_SIG": "NO_ACTIVITY"}, "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 # Keys of JSON objects in API responses renamed in v3
_V3_TO_V2_RESPONSE_KEYS = { _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) 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.""" """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.""" """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.""" """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.""" """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) spot_data = dict(spot_data)
if "sig" in 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: if "sig_refs" in spot_data:
spot_data["activity_refs"] = spot_data.pop("sig_refs") spot_data["activity_refs"] = spot_data.pop("sig_refs")
if isinstance(spot_data.get("activity_refs"), list): if isinstance(spot_data.get("activity_refs"), list):
@@ -130,3 +140,18 @@ class V2APISpotHandler(V2CompatibilityWrapper, RequestCompatibilityWrapper, APIS
refs.append(ref) refs.append(ref)
spot_data["activity_refs"] = refs spot_data["activity_refs"] = refs
return spot_data 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: if not spot.source or spot.source not in sources:
return False return False
case "activity": case "activity":
# If a list of activities is provided, the spot must have an activity and it must match one of them. # If a list of activities is provided, the spot must have at least one activity that matches one of
# The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity. # them. The special activity "NO_ACTIVITY", when supplied in the list, matches spots with no activity.
activities = query.get(k).split(",") activities = query.get(k).split(",")
include_no_activity = "NO_ACTIVITY" in activities 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 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 return False
case "needs_activity": case "needs_activity":
# If true, an activity is required, regardless of what it is, it just can't be missing. Mutually # If true, an activity is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_ACTIVITY" parameter to the "activity" query param. # exclusive with supplying the special "NO_ACTIVITY" parameter to the "activity" query param.
needs_activity = query.get(k).upper() == "TRUE" needs_activity = query.get(k).upper() == "TRUE"
if needs_activity and not spot.activity: if needs_activity and not spot.activities:
return False return False
case "needs_activity_ref": case "needs_activity_ref":
# If true, at least one activity ref is required, regardless of what it is, it just can't be missing. # If true, at least one activity ref is required, regardless of what it is, it just can't be missing.