Allow spots and alerts to have multiple activities #143

This commit is contained in:
Ian Renton
2026-09-24 22:57:46 +01:00
parent 81166dccab
commit 4477942bec
34 changed files with 217 additions and 157 deletions
+26 -9
View File
@@ -66,8 +66,11 @@ class Alert:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA).
activity: str | None = None
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
@@ -92,6 +95,11 @@ class Alert:
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def __post_init__(self):
"""Normalise the activities list, removing any duplicates while keeping the order."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -145,10 +153,10 @@ class Alert:
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.activity_refs and self.activity_refs[0] and not self.activity:
self.activity = self.activity_refs[0].activity
# Add the activities of any activity refs we have to the alert's list of activities.
for activity_ref in self.activity_refs:
if activity_ref and activity_ref.activity:
self.add_activity(activity_ref.activity)
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -179,14 +187,23 @@ class Alert:
if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
# Icon for the alert should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""
+40 -33
View File
@@ -125,8 +125,11 @@ class Spot:
# Activity info
# Activity (e.g. outdoor activity programme such as POTA).
activity: str | None = None
# Activities (e.g. outdoor activity programmes such as POTA). An alert can be for several activities at once,
# e.g. a POTA and WWFF dual activation. This is a list so we can maintain the order items were added, but needs to
# be set-like to avoid dupes, and there's no Python class that handles that properly. So we use a list, but handle
# the uniqueness logic manually, so you must use add_activity() to add to it instead of adding directly.
activities: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO.
activity_refs: list = field(default_factory=list)
@@ -158,8 +161,9 @@ class Spot:
def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the activity_refs list.."""
objects such as the activity_refs list, and de-duplicating the activities list."""
self.activities = list(dict.fromkeys(self.activities)) if self.activities else []
if self.activity_refs:
self.activity_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
@@ -268,9 +272,10 @@ class Spot:
if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level activity if it is missing but we have at least one activity ref.
if not self.activity and self.activity_refs:
self.activity = self.activity_refs[0].activity.upper()
# Add the activities of any activity refs we have to the top-level activities list.
for activity_ref in self.activity_refs:
if activity_ref.activity:
self.add_activity(activity_ref.activity.upper())
# See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
@@ -290,11 +295,10 @@ class Spot:
if self.comment:
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# First of all, add the activity to this spot's list of activities. This covers
# things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.activity:
self.activity = found_activity
self.add_activity(found_activity)
# Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the activity_refs list for this spot.
@@ -320,11 +324,9 @@ class Spot:
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
)
for ref_match in ref_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF.
if not self.activity:
self.activity = activity.name
# First of all, add the activity to this spot's list of activities. This covers things
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
self.add_activity(activity.name)
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), activity=activity.name)
)
@@ -346,16 +348,11 @@ class Spot:
):
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
if self.activity in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
if activity_ref.activity in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
self.dx_location_source = LocationSourceForSpot.GRID
else:
self.dx_location_source = LocationSourceForSpot.ACTIVITY_REF_LOOKUP
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.activity_refs and not self.activity:
self.activity = self.activity_refs[0].activity
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
@@ -407,32 +404,33 @@ class Spot:
self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.activity:
self.activity = ActivityName.SATELLITE
if self.propagation_mode == "Earth-Moon-Earth" and not self.activity:
self.activity = ActivityName.EME
if self.propagation_mode == "Satellite":
self.add_activity(ActivityName.SATELLITE)
if self.propagation_mode == "Earth-Moon-Earth":
self.add_activity(ActivityName.EME)
# Set activities based on the DX callsign suffix
if self.dx_call and not self.activity:
if self.dx_call:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
self.activity = ActivityName.AERONAUTICAL_MOBILE
self.add_activity(ActivityName.AERONAUTICAL_MOBILE)
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
self.activity = ActivityName.MARITIME_MOBILE
self.add_activity(ActivityName.MARITIME_MOBILE)
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
# running DXpedition which we know from the alerts list.
if self.dx_call and not self.activity:
if self.dx_call and not self.activities:
now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values():
if (
alert.activity == ActivityName.DXPEDITION
alert.activities
and ActivityName.DXPEDITION in alert.activities
and alert.dx_calls
and alert.start_time
and alert.end_time
and alert.start_time < now < alert.end_time
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
):
self.activity = ActivityName.DXPEDITION
self.add_activity(ActivityName.DXPEDITION)
break
# DX Grid to lat/lon and vice versa in case one is missing
@@ -531,14 +529,23 @@ class Spot:
self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
# Icon for the spot should be the icon of its first activity that has one, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.activity and (activity_icon := get_icon_for_activity(self.activity)):
self.icon = activity_icon
for activity in self.activities:
if activity_icon := get_icon_for_activity(activity):
self.icon = activity_icon
break
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
"""Add an activity to the activities list, so long as it's not blank and not already there. The list is kept in
insertion order, so the first activity added is treated as the "primary" one."""
if activity and activity not in self.activities:
self.activities.append(activity)
def to_json(self):
"""JSON serialise"""