From 4477942becc22b9861c4c62050741fb0aa3c35ac Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 24 Sep 2026 22:57:46 +0100 Subject: [PATCH] Allow spots and alerts to have multiple activities #143 --- data/alert.py | 35 ++++++--- data/spot.py | 73 ++++++++++--------- providers/alert/bota.py | 6 +- providers/alert/hamsat.py | 5 +- providers/alert/ng3k.py | 2 +- providers/alert/parksnpeaks.py | 2 +- providers/alert/pota.py | 5 +- providers/alert/rsgb_ical_alert_provider.py | 2 +- providers/alert/sota.py | 5 +- providers/alert/wa7bnm.py | 2 +- providers/alert/wota.py | 4 +- providers/alert/wwff.py | 6 +- providers/spot/gma.py | 22 +++--- providers/spot/hema.py | 2 +- providers/spot/llota.py | 2 +- providers/spot/parksnpeaks.py | 4 +- providers/spot/pota.py | 2 +- providers/spot/sota.py | 2 +- providers/spot/tiles.py | 2 +- providers/spot/towers.py | 2 +- providers/spot/wota.py | 2 +- providers/spot/wwbota.py | 2 +- providers/spot/wwff.py | 2 +- providers/spot/xota.py | 2 +- providers/spot/zlota.py | 2 +- static/apidocs/openapi.yml | 60 +++++++++------ static/js/add-spot.js | 2 +- static/js/alerts.js | 8 +- static/js/map.js | 4 +- static/js/spots.js | 4 +- webserver/handlers/api/addspot.py | 42 +++++------ webserver/handlers/api/alerts.py | 12 +-- .../api/compatibility/v2_compatibility.py | 37 ++++++++-- webserver/handlers/api/spots.py | 10 +-- 34 files changed, 217 insertions(+), 157 deletions(-) diff --git a/data/alert.py b/data/alert.py index 1acf2c3..fc10843 100644 --- a/data/alert.py +++ b/data/alert.py @@ -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""" diff --git a/data/spot.py b/data/spot.py index 64a9f47..4c1d3a1 100644 --- a/data/spot.py +++ b/data/spot.py @@ -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_griddx_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""" diff --git a/providers/alert/bota.py b/providers/alert/bota.py index ec85499..340bfb1 100644 --- a/providers/alert/bota.py +++ b/providers/alert/bota.py @@ -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(), ) diff --git a/providers/alert/hamsat.py b/providers/alert/hamsat.py index 2686313..a367080 100644 --- a/providers/alert/hamsat.py +++ b/providers/alert/hamsat.py @@ -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") diff --git a/providers/alert/ng3k.py b/providers/alert/ng3k.py index a950b92..6e15090 100644 --- a/providers/alert/ng3k.py +++ b/providers/alert/ng3k.py @@ -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. diff --git a/providers/alert/parksnpeaks.py b/providers/alert/parksnpeaks.py index cce2279..6285325 100644 --- a/providers/alert/parksnpeaks.py +++ b/providers/alert/parksnpeaks.py @@ -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, ) diff --git a/providers/alert/pota.py b/providers/alert/pota.py index 514c23f..325aeb5 100644 --- a/providers/alert/pota.py +++ b/providers/alert/pota.py @@ -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']}", ) diff --git a/providers/alert/rsgb_ical_alert_provider.py b/providers/alert/rsgb_ical_alert_provider.py index 1402700..4f7b9f3 100644 --- a/providers/alert/rsgb_ical_alert_provider.py +++ b/providers/alert/rsgb_ical_alert_provider.py @@ -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 diff --git a/providers/alert/sota.py b/providers/alert/sota.py index cc6169e..0c848c4 100644 --- a/providers/alert/sota.py +++ b/providers/alert/sota.py @@ -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, ) diff --git a/providers/alert/wa7bnm.py b/providers/alert/wa7bnm.py index 09cdde7..15573cc 100644 --- a/providers/alert/wa7bnm.py +++ b/providers/alert/wa7bnm.py @@ -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 diff --git a/providers/alert/wota.py b/providers/alert/wota.py index b6ed9c3..3b78c43 100644 --- a/providers/alert/wota.py +++ b/providers/alert/wota.py @@ -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(), ) diff --git a/providers/alert/wwff.py b/providers/alert/wwff.py index 570f0f3..d748b03 100644 --- a/providers/alert/wwff.py +++ b/providers/alert/wwff.py @@ -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(), diff --git a/providers/spot/gma.py b/providers/spot/gma.py index 1057e64..fce4107 100644 --- a/providers/spot/gma.py +++ b/providers/spot/gma.py @@ -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: diff --git a/providers/spot/hema.py b/providers/spot/hema.py index 4971405..a79fd53 100644 --- a/providers/spot/hema.py +++ b/providers/spot/hema.py @@ -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(), diff --git a/providers/spot/llota.py b/providers/spot/llota.py index 798654c..eab1d4e 100644 --- a/providers/spot/llota.py +++ b/providers/spot/llota.py @@ -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"], diff --git a/providers/spot/parksnpeaks.py b/providers/spot/parksnpeaks.py index e29bffb..7c8d58f 100644 --- a/providers/spot/parksnpeaks.py +++ b/providers/spot/parksnpeaks.py @@ -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 "", diff --git a/providers/spot/pota.py b/providers/spot/pota.py index 8ed2be9..31094e4 100644 --- a/providers/spot/pota.py +++ b/providers/spot/pota.py @@ -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"], diff --git a/providers/spot/sota.py b/providers/spot/sota.py index d2f3e94..2ec1e92 100644 --- a/providers/spot/sota.py +++ b/providers/spot/sota.py @@ -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"], diff --git a/providers/spot/tiles.py b/providers/spot/tiles.py index 69c71c1..e30f43d 100644 --- a/providers/spot/tiles.py +++ b/providers/spot/tiles.py @@ -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=[ diff --git a/providers/spot/towers.py b/providers/spot/towers.py index d81c04d..28bedb5 100644 --- a/providers/spot/towers.py +++ b/providers/spot/towers.py @@ -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) ], diff --git a/providers/spot/wota.py b/providers/spot/wota.py index 8ac2bc6..50c47fa 100644 --- a/providers/spot/wota.py +++ b/providers/spot/wota.py @@ -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( diff --git a/providers/spot/wwbota.py b/providers/spot/wwbota.py index 01b9c52..5026bb6 100644 --- a/providers/spot/wwbota.py +++ b/providers/spot/wwbota.py @@ -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 diff --git a/providers/spot/wwff.py b/providers/spot/wwff.py index c7d1f78..cc439f9 100644 --- a/providers/spot/wwff.py +++ b/providers/spot/wwff.py @@ -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"], diff --git a/providers/spot/xota.py b/providers/spot/xota.py index b394d89..57fb8b1 100644 --- a/providers/spot/xota.py +++ b/providers/spot/xota.py @@ -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, diff --git a/providers/spot/zlota.py b/providers/spot/zlota.py index 407a016..eaf360b 100644 --- a/providers/spot/zlota.py +++ b/providers/spot/zlota.py @@ -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"], diff --git a/static/apidocs/openapi.yml b/static/apidocs/openapi.yml index b0be74e..1e6df06 100644 --- a/static/apidocs/openapi.yml +++ b/static/apidocs/openapi.yml @@ -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. - - 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=...`. + In v3.0 of Spothole, the `v2` (and `v1`) API endpoints will be maintained for backwards compatibility, so if you have written a client against the `v2` API, it will continue to receive `sig`, `sig_refs` etc. as before. Where a spot or alert has more than one activity, the `v2` and `v1` APIs will return only the first one as `sig`. + + You are encouraged to move to the `v3` API endpoints as soon as possible. To upgrade, replace `v2` with `v3` in the URLs your code calls, then rename any use of the fields, query parameters and values listed above, and handle `activities` being a list rather than a single `sig` value. If you use the activity reference lookup, call `/lookup/activityref?activity=...&id=...` instead of `/lookup/sigref?sig=...&id=...`. ### 2.2 @@ -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: diff --git a/static/js/add-spot.js b/static/js/add-spot.js index ebec4b4..ad8d21c 100644 --- a/static/js/add-spot.js +++ b/static/js/add-spot.js @@ -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; diff --git a/static/js/alerts.js b/static/js/alerts.js index 3a65c8f..bc7ef4d 100644 --- a/static/js/alerts.js +++ b/static/js/alerts.js @@ -210,14 +210,14 @@ function addAlertRowsToTable(tbody, alerts) { if (a["dx_calls"] != null) { dx_calls_html = a["dx_calls"].map(call => `${call}`).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 = `
${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 diff --git a/static/js/map.js b/static/js/map.js index 36c1f6f..fb3857e 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -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 diff --git a/static/js/spots.js b/static/js/spots.js index dae744a..1f4c1ca 100644 --- a/static/js/spots.js +++ b/static/js/spots.js @@ -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 diff --git a/webserver/handlers/api/addspot.py b/webserver/handlers/api/addspot.py index 9d91ae6..1feb458 100644 --- a/webserver/handlers/api/addspot.py +++ b/webserver/handlers/api/addspot.py @@ -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 diff --git a/webserver/handlers/api/alerts.py b/webserver/handlers/api/alerts.py index 9ee79e5..500c218 100644 --- a/webserver/handlers/api/alerts.py +++ b/webserver/handlers/api/alerts.py @@ -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(",") diff --git a/webserver/handlers/api/compatibility/v2_compatibility.py b/webserver/handlers/api/compatibility/v2_compatibility.py index 9c61fbf..c4800f0 100644 --- a/webserver/handlers/api/compatibility/v2_compatibility.py +++ b/webserver/handlers/api/compatibility/v2_compatibility.py @@ -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 \ No newline at end of file diff --git a/webserver/handlers/api/spots.py b/webserver/handlers/api/spots.py index b215c94..689ce60 100644 --- a/webserver/handlers/api/spots.py +++ b/webserver/handlers/api/spots.py @@ -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.