Strict adherence to canonical ActivityNames, to avoid the problem where Spothole was giving out activities like ["Towers", "TOWERS"]. All uses of ActivityName should now be canonical not arbitrary strings, and attempts to convert unknown strings to activitynames will be logged for me to check out.

This commit is contained in:
Ian Renton
2026-09-25 09:16:53 +01:00
parent 7417140a3d
commit 760c2412d3
17 changed files with 144 additions and 145 deletions
+30 -18
View File
@@ -5,6 +5,7 @@ import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from math import isnan
from typing import cast
import pytz
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
@@ -129,9 +130,9 @@ class Spot:
# 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)
activities: list[ActivityName] = 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)
activity_refs: list[ActivityRef] = field(default_factory=list)
# Timing info
@@ -161,14 +162,22 @@ 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, and de-duplicating the activities list."""
objects such as the activity_refs list, and de-duplicating the activities list. Activity names provided as
strings are converted to their canonical ActivityName. Any activities, or activity refs, for an activity we
don't know about are dropped."""
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)
for activity_ref in self.activity_refs
]
found_activities = [get_activity_by_name(activity) for activity in self.activities or []]
self.activities = list(dict.fromkeys(found.name for found in found_activities if found))
# When created from JSON, activity refs arrive as dicts rather than ActivityRef objects.
activity_refs = []
for activity_ref in cast("list[ActivityRef | dict]", self.activity_refs or []):
if isinstance(activity_ref, ActivityRef):
activity_refs.append(activity_ref)
elif found_activity := get_activity_by_name(activity_ref.get("activity")):
activity_ref_data = dict(activity_ref)
activity_ref_data["activity"] = found_activity.name
activity_refs.append(ActivityRef(**activity_ref_data))
self.activity_refs = activity_refs
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -274,13 +283,12 @@ class Spot:
# 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())
self.add_activity(activity_ref.activity)
# See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.activity_refs and self.activity_refs[0].activity:
activity = self.activity_refs[0].activity.upper()
if self.comment and self.activity_refs:
activity = self.activity_refs[0].activity
regex = get_ref_regex_for_activity(activity)
if regex:
all_comment_ref_matches = re.finditer(r"(?<!\w)(" + regex + r")(?!\w)", self.comment, re.IGNORECASE)
@@ -476,7 +484,7 @@ class Spot:
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
# string, otherwise see what they have set on an online lookup service.
if self.activity_refs:
qth = self.activity_refs[0].id
qth = self.activity_refs[0].id or ""
if self.activity_refs[0].name:
qth += f" {self.activity_refs[0].name}"
self.dx_qth = qth
@@ -539,11 +547,16 @@ class Spot:
except Exception:
logger.exception("Exception while inferring missing data from spot")
def add_activity(self, activity):
def add_activity(self, activity: ActivityName | None):
"""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."""
insertion order, so the first activity added is treated as the "primary" one. Only canonical ActivityNames are
accepted otherwise we risk sending unknown stuff to API clients."""
if activity and activity not in self.activities:
if not activity:
return
if not isinstance(activity, ActivityName):
raise TypeError(f"add_activity() requires an ActivityName, got {type(activity).__name__} {activity!r}")
if activity not in self.activities:
self.activities.append(activity)
def to_json(self):
@@ -555,7 +568,6 @@ class Spot:
"""Append an activity ref to the list, so long as it's not already there."""
new_activity_ref.id = new_activity_ref.id.strip().upper()
new_activity_ref.activity = new_activity_ref.activity.strip().upper()
if new_activity_ref.id == "":
return
for activity_ref in self.activity_refs: