Giant refactor to rebrand "SIG" as "Activity" anywhere that doesn't touch API or config file (which is to be addressed in a future breaking change). #147

This commit is contained in:
Ian Renton
2026-09-18 14:54:11 +01:00
parent 556ea56378
commit 81cd686a00
96 changed files with 1208 additions and 1170 deletions
+41
View File
@@ -0,0 +1,41 @@
from dataclasses import dataclass, field
from core.enums import ActivityRefType, ActivityType
@dataclass
class Activity:
"""Data class that defines an Activity (formerly referred to as a "Special Interest Group" or "SIG", a term
which is still used for the `sig` field name in the API for backwards compatibility). Each contains a name and
a longer form description. They also contain comment_names which attempts to separate out the way people might
refer to it in cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster
spot comments almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA"
as it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# Activity name as used in the UI and API, e.g. "Towers"
name: str
# Description, e.g. "Towers on the Air"
description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
# Note: this field is still named "sig_type" in the API for backwards compatibility.
sig_type: ActivityType
# Identifies that the activity's reference ID structure defined by its regex is unique across all programmes and
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
# spot comments without also needing to see the activity name first. For example, "OHFF-1234" or "B/G-1234" are
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
# conceivably be POTA or ILLW, etc.
refs_globally_unique: bool
# Activity names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Reference type, what gets activated e.g. Park, Summit. May be None if the activity is for multiple types of
# things, in which case the spot data will have to provide this instead.
ref_type: ActivityRefType | None = None
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str | None = None
# Icon to use in the UI when referencing this activity. Chosen from the Font Awesome set.
icon: str | None = None
# Emoji flag for the country or region where this activity is relevant, if any. If None, this implies the
# activity is in worldwide usage.
region_flag: str | None = None
+6 -6
View File
@@ -1,24 +1,24 @@
from dataclasses import dataclass
from core.enums import SIGRefType
from core.enums import ActivityRefType
@dataclass
class SIGRef:
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
class ActivityRef:
"""Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
name and a lookup URL."""
# SIG that this reference is in, e.g. "POTA".
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API.
sig: str
# Reference ID, e.g. "GB-0001".
id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None
# Type of the reference, e.g. "Park", if known.
ref_type: SIGRefType | None = None
ref_type: ActivityRefType | None = None
# URL to look up more information about the reference, if known.
url: str | None = None
# Icon to use for the reference, derived from the SIG. Chosen from the Font Awesome set.
# Icon to use for the reference, derived from the activity. Chosen from the Font Awesome set.
icon: str | None = None
# Latitude of the reference, in degrees, if known.
latitude: float | None = None
+13 -12
View File
@@ -6,9 +6,9 @@ from datetime import datetime, timedelta
import pytz
from core.activity_lookup_helper import populate_missing_activity_ref_info
from core.call_lookup_helper import get_call_info
from core.enums import AlertType, Continent
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import get_flag_for_dxcc
logger = logging.getLogger(__name__)
@@ -59,11 +59,12 @@ class Alert:
# A URL link to more information, if any
url: str | None = None
# Special Interest Group info
# Activity info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# SIG 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. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list)
# Timing info
@@ -125,15 +126,15 @@ class Alert:
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# reference in its initial call, we use this code to populate the rest of the data. This includes working
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
if self.sig_refs:
for sig_ref in self.sig_refs:
populate_missing_sig_ref_info(sig_ref)
for activity_ref in self.sig_refs:
populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
# 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.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig
@@ -152,7 +153,7 @@ 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 spot should be the icon of the first SIG ref if present, otherwise a radio tower
# Icon for the spot should be the icon of the first activity ref if present, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.alert_type == AlertType.DXPEDITION:
self.icon = "fa-globe-africa"
+2 -2
View File
@@ -7,8 +7,8 @@ from core.enums import Continent, LocationSourceForCallsign
class Callsign:
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a callsign
lookup provider using data files or online lookup. This can be used to infer missing data for a spot, though if the
spot has a SIG (e.g. POTA) reference this data for their home location (or even just their country) will be less
accurate and should not be used in preference to that."""
spot has an activity (e.g. POTA) reference this data for their home location (or even just their country) will be
less accurate and should not be used in preference to that."""
# Callsign as spotted
call: str
-39
View File
@@ -1,39 +0,0 @@
from dataclasses import dataclass, field
from core.enums import SIGRefType, SIGType
@dataclass
class SIG:
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
They also contain comment_names which attempts to separate out the way people might refer to it in
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# SIG name as used in the UI and API, e.g. "Towers"
name: str
# Description, e.g. "Towers on the Air"
description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
sig_type: SIGType
# Identifies that the SIG's reference ID structure defined by its regex is unique across all programmes and
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
# spot comments without also needing to see the SIG name first. For example, "OHFF-1234" or "B/G-1234" are
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
# conceivably be POTA or ILLW, etc.
refs_globally_unique: bool
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Reference type, what gets activated e.g. Park, Summit. May be None if the SIG is for multiple types of things, in
# which case the spot data will have to provide this instead.
ref_type: SIGRefType | None = None
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str | None = None
# Icon to use in the UI when referencing this SIG. Chosen from the Font Awesome set.
icon: str | None = None
# Emoji flag for the country or region where this SIG is relevant, if any. If None, this implies the SIG is in
# worldwide usage.
region_flag: str | None = None
+91 -74
View File
@@ -9,18 +9,18 @@ from math import isnan
import pytz
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.activity_lookup_helper import populate_missing_activity_ref_info
from core.activity_utils import (
ANY_ACTIVITY_REGEX,
get_activity_name_from_comment_name,
get_ref_regex_for_activity,
)
from core.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE
from core.constants import PROPAGATION_MODES, SIGS
from core.constants import ACTIVITIES, PROPAGATION_MODES
from core.data_store import DATA_STORE
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import (
ANY_SIG_REGEX,
get_ref_regex_for_sig,
get_sig_name_from_comment_name,
)
from core.utils import (
get_flag_for_dxcc,
infer_band_from_freq,
@@ -28,7 +28,7 @@ from core.utils import (
infer_mode_from_frequency,
infer_mode_type_from_mode,
)
from data.sig_ref import SIGRef
from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__)
@@ -46,8 +46,8 @@ class Spot:
dx_call: str | None = None
# Name of the operator that has been spotted
dx_name: str | None = None
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
# home QTH.
# QTH of the operator that has been spotted. This could be from any activity refs or could be from online lookup of
# their home QTH.
dx_qth: str | None = None
# Country of the DX operator
dx_country: str | None = None
@@ -73,8 +73,8 @@ class Spot:
# DX Location source. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot | None = None
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
# DX callsign doesn't have a suffix like /P.
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
dx_location_good: bool = False
# DE (Spotter) info
@@ -120,11 +120,12 @@ class Spot:
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = False
# Special Interest Group info
# Activity info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# SIG 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. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list)
# Timing info
@@ -158,7 +159,10 @@ class Spot:
objects such as the sig_refs list.."""
if self.sig_refs:
self.sig_refs = [sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref) for sig_ref in self.sig_refs]
self.sig_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
for activity_ref in self.sig_refs
]
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -262,76 +266,89 @@ class Spot:
if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
# Set the top-level activity if it is missing but we have at least one activity ref.
if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
# 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.sig_refs and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig)
activity = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_activity(activity)
if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
self._append_activity_ref_if_missing(ActivityRef(id=ref_match.group(2).upper(), sig=activity))
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
# comments like "also WWFF GFF-0001".
# See if the comment looks like it contains any activities (and optionally activity references) that we
# can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
# POTA comments like "also WWFF GFF-0001".
if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
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
# 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.sig:
self.sig = found_sig
self.sig = found_activity
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
# If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig)
# 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 sig_refs list for this spot.
ref_regex = get_ref_regex_for_activity(found_activity)
if ref_regex:
ref_matches = re.finditer(
r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)",
r"(^|\W)" + found_activity + r"([ -])(" + ref_regex + r")($|\W)",
self.comment,
re.IGNORECASE,
)
for ref_match in ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(3).upper(), sig=found_activity)
)
# See if the comment looks like it contains any SIG references *without* the corresponding SIG name, but
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG.
# See if the comment looks like it contains any activity references *without* the corresponding activity
# name, but where the activity reference is unique-looking enough that we can't confuse it with any other
# activity.
if self.comment:
for sig in SIGS:
if sig.refs_globally_unique and sig.ref_regex:
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
for activity in ACTIVITIES:
if activity.refs_globally_unique and activity.ref_regex:
ref_matches = re.finditer(
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 a SIG 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.
# 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.sig:
self.sig = sig.name
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name))
self.sig = activity.name
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), sig=activity.name)
)
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# reference in its initial call, we use this code to populate the rest of the data. This includes working
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
if self.sig_refs:
for sig_ref in self.sig_refs:
sig_ref = populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
if sig_ref.grid and not self.dx_grid:
self.dx_grid = sig_ref.grid
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude:
self.dx_latitude = sig_ref.latitude
self.dx_longitude = sig_ref.longitude
for activity_ref in self.sig_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid:
self.dx_grid = activity_ref.grid
if (
activity_ref.latitude
and not self.dx_latitude
and activity_ref.longitude
and not self.dx_longitude
):
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
self.dx_location_source = LocationSourceForSpot.GRID
else:
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
# 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.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig
@@ -360,17 +377,17 @@ class Spot:
self.propagation_mode = mode_tag
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
# Set SIGs based on propagation mode
# Set activities based on propagation mode
if self.propagation_mode == "Satellite":
if not self.sig:
self.sig = "AMSAT"
if not any(sig_ref.sig == "AMSAT" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="AMSAT"))
if not any(activity_ref.sig == "AMSAT" for activity_ref in self.sig_refs):
self.sig_refs.append(ActivityRef(sig="AMSAT"))
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig:
self.sig = "EME"
if not any(sig_ref.sig == "EME" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="EME"))
if not any(activity_ref.sig == "EME" for activity_ref in self.sig_refs):
self.sig_refs.append(ActivityRef(sig="EME"))
# Parse "de_grid -> dx_grid" structures from the comment
if self.comment:
@@ -426,8 +443,8 @@ class Spot:
self.dx_grid = dx_call_info.grid
self.dx_location_source = dx_call_info.location_source
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
# 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.sig_refs:
qth = self.sig_refs[0].id
if self.sig_refs[0].name:
@@ -482,7 +499,7 @@ 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 the first SIG ref if present, otherwise a radio tower
# Icon for the spot should be the icon of the first activity ref if present, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
@@ -495,17 +512,17 @@ class Spot:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there."""
def _append_activity_ref_if_missing(self, new_activity_ref):
"""Append an activity ref to the list, so long as it's not already there."""
new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "":
new_activity_ref.id = new_activity_ref.id.strip().upper()
new_activity_ref.sig = new_activity_ref.sig.strip().upper()
if new_activity_ref.id == "":
return
for sig_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
for activity_ref in self.sig_refs:
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
return
self.sig_refs.append(new_sig_ref)
self.sig_refs.append(new_activity_ref)
def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not