diff --git a/config-example.yml b/config-example.yml
index 3e36a65..53938ee 100644
--- a/config-example.yml
+++ b/config-example.yml
@@ -165,6 +165,12 @@ alert_providers:
- class: "NG3K"
enabled: true
+ - class: "RSGBHFContests"
+ enabled: true
+
+ - class: "RSGBVHFContests"
+ enabled: true
+
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
diff --git a/core/enums.py b/core/enums.py
index 75594cb..48e139b 100644
--- a/core/enums.py
+++ b/core/enums.py
@@ -30,7 +30,6 @@ class Mode(str, Enum):
OLIVIA = "OLIVIA"
PKT = "PKT"
MSK144 = "MSK144"
- UNKNOWN = "UNKNOWN"
@property
def is_cw(self) -> bool:
@@ -42,14 +41,14 @@ class Mode(str, Enum):
@property
def is_data(self) -> bool:
- return not (self.is_cw or self.is_phone or self == Mode.UNKNOWN)
+ return not (self.is_cw or self.is_phone)
@staticmethod
def from_name(name):
"""Convert a string to an enum mode using the alias table."""
if not name:
- return Mode.UNKNOWN
+ return None
try:
return Mode(name.upper())
@@ -57,14 +56,13 @@ class Mode(str, Enum):
try:
return Mode(MODE_ALIASES[name.upper()])
except (KeyError, ValueError):
- return Mode.UNKNOWN
+ return None
class ModeType(str, Enum):
PHONE = "PHONE"
CW = "CW"
DATA = "DATA"
- UNKNOWN = "UNKNOWN"
class ModeSource(str, Enum):
@@ -115,7 +113,14 @@ class SIGRefType(str, Enum):
REGION = "REGION"
GRID = "GRID"
TOILET = "TOILET"
- UNKNOWN = "UNKNOWN"
+
+
+class AlertType(str, Enum):
+ """Type of an alert."""
+
+ XOTA = "XOTA"
+ DXPEDITION = "DXPEDITION"
+ CONTEST = "CONTEST"
class SIGType(str, Enum):
diff --git a/core/utils.py b/core/utils.py
index c76930f..73037e3 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -20,11 +20,11 @@ def safe_json_dumps(obj):
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
-def infer_mode_from_comment(comment: str) -> Mode:
+def infer_mode_from_comment(comment: str) -> Mode | None:
"""Infer a mode from the comment"""
if not comment:
- return Mode.UNKNOWN
+ return None
for mode in Mode:
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
@@ -33,14 +33,14 @@ def infer_mode_from_comment(comment: str) -> Mode:
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
return Mode(MODE_ALIASES[alias])
- return Mode.UNKNOWN
+ return None
-def infer_mode_type_from_mode(mode: str) -> ModeType:
+def infer_mode_type_from_mode(mode: str) -> ModeType | None:
"""Infer a "mode family" from a mode ."""
if not mode:
- return ModeType.UNKNOWN
+ return None
if mode in MODE_ALIASES:
mode = MODE_ALIASES[mode]
@@ -56,7 +56,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType:
except ValueError:
if mode.upper() != "OTHER" and mode != "?":
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
- return ModeType.UNKNOWN
+ return None
def infer_band_from_freq(freq):
diff --git a/data/alert.py b/data/alert.py
index a4e923e..d2bbaea 100644
--- a/data/alert.py
+++ b/data/alert.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import pytz
from core.call_lookup_helper import get_call_info
-from core.enums import Continent
+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
@@ -58,8 +58,10 @@ class Alert:
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None
- # Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
- is_dxpedition: bool = False
+ # The type of alert this is: xOTA, DXpedition, or Contest.
+ alert_type: AlertType | None = None
+ # A URL link to more information, if any
+ url: str | None = None
# Where we got the alert from, e.g. "POTA", "SOTA"...
source: str | None = None
# The ID the source gave it, if any.
diff --git a/data/sig_ref.py b/data/sig_ref.py
index 8896b6c..f14e65d 100644
--- a/data/sig_ref.py
+++ b/data/sig_ref.py
@@ -15,7 +15,7 @@ class SIGRef:
# 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 = SIGRefType.UNKNOWN
+ ref_type: SIGRefType | None = None
# URL to look up more information about the reference, if known.
url: str | None = None
# Latitude of the reference, in degrees, if known.
diff --git a/data/spot.py b/data/spot.py
index f52c26a..acc3d53 100644
--- a/data/spot.py
+++ b/data/spot.py
@@ -103,9 +103,9 @@ class Spot:
# General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8...
- mode: Mode = Mode.UNKNOWN
+ mode: Mode | None = None
# Inferred mode "family".
- mode_type: ModeType = ModeType.UNKNOWN
+ mode_type: ModeType | None = None
# Source of the mode information.
mode_source: ModeSource = ModeSource.NONE
# Frequency, in Hz
@@ -239,21 +239,17 @@ class Spot:
self.band = band.name
# Mode from comments or bandplan
- if not self.mode:
- self.mode = Mode.UNKNOWN
- if self.mode != Mode.UNKNOWN:
+ if self.mode:
self.mode_source = ModeSource.SPOT
- if self.comment and self.mode == Mode.UNKNOWN:
+ if self.comment and not self.mode:
self.mode = infer_mode_from_comment(self.comment)
self.mode_source = ModeSource.COMMENT
- if self.freq and self.mode == Mode.UNKNOWN:
+ if self.freq and not self.mode:
self.mode = infer_mode_from_frequency(self.freq)
self.mode_source = ModeSource.BANDPLAN
# Mode type from mode
- if not self.mode_type:
- self.mode_type = ModeType.UNKNOWN
- if self.mode != Mode.UNKNOWN and self.mode_type == ModeType.UNKNOWN:
+ if self.mode and not self.mode_type:
self.mode_type = infer_mode_type_from_mode(self.mode)
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
diff --git a/providers/alert/bota.py b/providers/alert/bota.py
index 5f1d0f4..798770a 100644
--- a/providers/alert/bota.py
+++ b/providers/alert/bota.py
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
+from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -57,7 +58,7 @@ class BOTA(HTTPAlertProvider):
dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
start_time=date_time.timestamp(),
- is_dxpedition=False,
+ alert_type=AlertType.XOTA,
)
new_alerts.append(alert)
diff --git a/providers/alert/ng3k.py b/providers/alert/ng3k.py
index 161cd6a..03170f5 100644
--- a/providers/alert/ng3k.py
+++ b/providers/alert/ng3k.py
@@ -6,19 +6,20 @@ import pytz
from rss_parser import Parser
from rss_parser.models.rss import RSS
+from core.enums import AlertType
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
class NG3K(HTTPAlertProvider):
- """Alert provider NG3K DXpedition list"""
+ """Alert provider for NG3K DXpedition list"""
- POLL_INTERVAL_SEC = 1800
+ POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config):
- super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
+ super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
def _http_response_to_alerts(self, http_response):
new_alerts = []
@@ -88,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp,
end_time=end_timestamp,
- is_dxpedition=True,
+ alert_type=AlertType.DXPEDITION,
)
# Add to our list.
diff --git a/providers/alert/parksnpeaks.py b/providers/alert/parksnpeaks.py
index 1323470..614837c 100644
--- a/providers/alert/parksnpeaks.py
+++ b/providers/alert/parksnpeaks.py
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz
+from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -51,7 +52,7 @@ class ParksNPeaks(HTTPAlertProvider):
comment=source_alert["Comments"],
sig_refs=sigrefs,
start_time=start_time,
- is_dxpedition=False,
+ alert_type=AlertType.XOTA,
)
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
diff --git a/providers/alert/pota.py b/providers/alert/pota.py
index 6765118..aceee0e 100644
--- a/providers/alert/pota.py
+++ b/providers/alert/pota.py
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz
+from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class POTA(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.UTC)
.timestamp(),
- is_dxpedition=False,
+ alert_type=AlertType.XOTA,
)
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
diff --git a/providers/alert/rsgb_ical_alert_provider.py b/providers/alert/rsgb_ical_alert_provider.py
new file mode 100644
index 0000000..80b2af1
--- /dev/null
+++ b/providers/alert/rsgb_ical_alert_provider.py
@@ -0,0 +1,101 @@
+import re
+from datetime import datetime, time
+from typing import cast
+
+import pytz
+from icalendar import Calendar, Event
+
+from core.enums import AlertType, Continent
+from data.alert import Alert
+from providers.alert.http_alert_provider import HTTPAlertProvider
+
+
+class RSGBICALAlertProvider(HTTPAlertProvider):
+ """Generic alert provider for RSGB contest ICAL calendars."""
+
+ def __init__(self, name, provider_config, url, poll_interval):
+ super().__init__(name, provider_config, url, poll_interval)
+
+ FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
+
+ def _http_response_to_alerts(self, http_response):
+ new_alerts = []
+ cal = Calendar.from_ical(http_response.content)
+
+ # Iterate through events
+ for component in cal.walk():
+ if component.name != "VEVENT":
+ continue
+
+ event = cast(Event, component)
+ summary = str(event.get("summary", "")).strip()
+
+ # Ensure summaries start with "RSGB" to avoid any confusion
+ if not summary.startswith("RSGB "):
+ summary = "RSGB " + summary
+
+ # Extract freq from summary
+ freqs_modes = ""
+
+ # HF contests don't give frequencies, all VHF ones do
+ match = self.FREQ_PATTERN.search(summary)
+ if match:
+ freqs_modes = match.group()
+ else:
+ freqs_modes = "HF bands"
+ freqs_modes = freqs_modes + ", "
+
+ # Extract mode from summary
+ if "FMAC" in summary:
+ freqs_modes = freqs_modes + "FM"
+ elif "CW" in summary:
+ freqs_modes = freqs_modes + "CW"
+ elif "SSB" in summary:
+ freqs_modes = freqs_modes + "SSB"
+ elif "FT8" in summary:
+ freqs_modes = freqs_modes + "FT8"
+ elif "FT4" in summary:
+ freqs_modes = freqs_modes + "FT4"
+ elif "DATA" in summary:
+ freqs_modes = freqs_modes + "Data modes"
+ else:
+ freqs_modes = freqs_modes + "All modes"
+
+ dtstart = event.get("dtstart")
+ dtend = event.get("dtend")
+ start_timestamp = self._to_utc_timestamp(dtstart.dt)
+ end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
+
+ # Convert to our alert format
+ alert = Alert(
+ source=self.name,
+ dx_calls=[],
+ dx_country="United Kingdom",
+ dx_dxcc_id=235,
+ dx_continent=Continent.EU,
+ dx_cq_zone=14,
+ dx_itu_zone=27,
+ dx_flag="🇬🇧",
+ freqs_modes=freqs_modes,
+ comment=summary,
+ start_time=start_timestamp,
+ end_time=end_timestamp,
+ alert_type=AlertType.CONTEST,
+ )
+
+ # Add to our list.
+ new_alerts.append(alert)
+ return new_alerts
+
+ @staticmethod
+ def _to_utc_timestamp(value):
+ """Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
+
+ # Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ value = pytz.UTC.localize(value)
+ return value.astimezone(pytz.UTC).timestamp()
+
+ # Date object so this is an all day event
+ return pytz.UTC.localize(datetime.combine(value, time.min)).timestamp()
diff --git a/providers/alert/rsgbhfcontests.py b/providers/alert/rsgbhfcontests.py
new file mode 100644
index 0000000..6bc4740
--- /dev/null
+++ b/providers/alert/rsgbhfcontests.py
@@ -0,0 +1,11 @@
+from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
+
+
+class RSGBHFContests(RSGBICALAlertProvider):
+ """Alert provider for RSGB HF Contest calendar"""
+
+ POLL_INTERVAL_DAYS = 30
+ ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
+
+ def __init__(self, provider_config):
+ super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
diff --git a/providers/alert/rsgbvhfcontests.py b/providers/alert/rsgbvhfcontests.py
new file mode 100644
index 0000000..a71055d
--- /dev/null
+++ b/providers/alert/rsgbvhfcontests.py
@@ -0,0 +1,11 @@
+from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
+
+
+class RSGBVHFContests(RSGBICALAlertProvider):
+ """Alert provider for RSGB VHF Contest calendar"""
+
+ POLL_INTERVAL_DAYS = 30
+ ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
+
+ def __init__(self, provider_config):
+ super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
diff --git a/providers/alert/sota.py b/providers/alert/sota.py
index a254faf..b803b61 100644
--- a/providers/alert/sota.py
+++ b/providers/alert/sota.py
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz
+from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class SOTA(HTTPAlertProvider):
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
- is_dxpedition=False,
+ alert_type=AlertType.XOTA,
)
# Add to our list
diff --git a/providers/alert/wwff.py b/providers/alert/wwff.py
index 53bb63c..5d4f359 100644
--- a/providers/alert/wwff.py
+++ b/providers/alert/wwff.py
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz
+from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,7 +35,7 @@ class WWFF(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC)
.timestamp(),
- is_dxpedition=False,
+ alert_type=AlertType.XOTA,
)
# Add to our list
diff --git a/providers/sigrefdata/zlota.py b/providers/sigrefdata/zlota.py
index 8cc1958..ee47964 100644
--- a/providers/sigrefdata/zlota.py
+++ b/providers/sigrefdata/zlota.py
@@ -30,7 +30,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
try:
ref_type = SIGRefType(ref["asset_type"].title().upper())
except ValueError:
- ref_type = SIGRefType.UNKNOWN
+ ref_type = None
new_ref = SIGRef(
sig=self.SIG,
diff --git a/providers/spot/gma.py b/providers/spot/gma.py
index 44983bf..688a2e9 100644
--- a/providers/spot/gma.py
+++ b/providers/spot/gma.py
@@ -66,9 +66,7 @@ class GMA(HTTPSpotProvider):
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
else None,
# Filter out some weird mode strings
- mode=Mode.from_name(source_spot["MODE"].upper())
- if "<>" not in source_spot["MODE"]
- else Mode.UNKNOWN,
+ mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"],
sig_refs=[
SIGRef(
diff --git a/providers/spot/wwbota.py b/providers/spot/wwbota.py
index dcb6ad2..3525312 100644
--- a/providers/spot/wwbota.py
+++ b/providers/spot/wwbota.py
@@ -27,7 +27,7 @@ class WWBOTA(SSESpotProvider):
name=ref["name"],
latitude=ref["lat"],
longitude=ref["long"],
- ref_type=SIGRefType.BUNKER
+ ref_type=SIGRefType.BUNKER,
)
refs.append(sigref)
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000000,
- mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else Mode.UNKNOWN,
+ mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else None,
comment=source_spot["comment"],
sig="WWBOTA",
sig_refs=refs,
diff --git a/requirements.txt b/requirements.txt
index 501cff6..ecc7b22 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,4 +24,5 @@ fastkml~=1.4.0
ruff~=0.16.3
pdfplumber~=0.11.10
xlrd~=2.0.2
-openpyxl~=3.1.5
\ No newline at end of file
+openpyxl~=3.1.5
+icalendar~=7.3.0
\ No newline at end of file
diff --git a/server/handlers/api/alerts.py b/server/handlers/api/alerts.py
index 6c1de40..868d7ae 100644
--- a/server/handlers/api/alerts.py
+++ b/server/handlers/api/alerts.py
@@ -9,6 +9,7 @@ import tornado_eventsource.handler
from tornado import httputil
from tornado.web import Application
+from core.enums import AlertType
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
@@ -169,11 +170,18 @@ def alert_allowed_by_query(alert, query):
max_duration = int(query.get(k))
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
- # the alert is a dxpedition, it also always passes the check.
- if alert.is_dxpedition and (
- query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
- if "dxpeditions_skip_max_duration_check" in query
- else False
+ # 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.alert_type == AlertType.DXPEDITION
+ and "dxpeditions_skip_max_duration_check" in query
+ and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
+ ):
+ continue
+ if (
+ alert.alert_type == AlertType.CONTEST
+ and "contests_skip_max_duration_check" in query
+ and query.get("contests_skip_max_duration_check").upper() == "TRUE"
):
continue
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
diff --git a/static/apidocs/openapi.yml b/static/apidocs/openapi.yml
index a0313ea..e4040dd 100644
--- a/static/apidocs/openapi.yml
+++ b/static/apidocs/openapi.yml
@@ -19,8 +19,9 @@ info:
* Added DTMBA, FEA, BIWOTA, COTA & PGA SIGs
* Removed the distinction between LSB & USB (both will now show as SSB) and between the various digital voice modes, which will now show as DV.
- * Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG information
- * Unknown modes and mode types now return "UNKNOWN" not null
+ * Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG data
+ * Added `alert_type` and `url` to alert data
+ * Added `contests_skip_max_duration_check` to alert query parameters
* SIG reference types (e.g. "Park") are now capitalised to match other enums
### 2.0
@@ -209,6 +210,7 @@ paths:
- $ref: '#/components/parameters/AlertReceivedSince'
- $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
+ - $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent'
@@ -243,6 +245,7 @@ paths:
parameters:
- $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
+ - $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent'
@@ -661,8 +664,8 @@ components:
time minus start time, if end time is set, otherwise the activation is assumed to be short and
therefore to always pass this check. This is useful to filter out people who alert POTA
activations lasting months or even years, but note it will also include multi-day or multi-week
- DXpeditions that you might otherwise be interested in. See the
- dxpeditions_skip_max_duration_check parameter for the workaround.
+ DXpeditions or contests that you might otherwise be interested in. See the
+ dxpeditions_skip_max_duration_check and contests_skip_max_duration_check parameters for the workaround.
schema:
type: integer
AlertDxpeditionsSkipMaxDurationCheck:
@@ -675,6 +678,16 @@ components:
on the air most of the time.
schema:
type: boolean
+ AlertContestsSkipMaxDurationCheck:
+ name: contests_skip_max_duration_check
+ in: query
+ description: >
+ Return contest alerts even if they last longer than max_duration. This allows the user to
+ filter out multi-day/multi-week POTA alerts where the operator likely won't be on the air most
+ of the time, but keep multi-day/multi-week contests where contesters likely *will* be
+ on the air most of the time.
+ schema:
+ type: boolean
AlertSource:
name: source
in: query
@@ -903,9 +916,16 @@ components:
- REGION
- GRID
- TOILET
- - UNKNOWN
example: PARK
+ AlertType:
+ type: string
+ enum:
+ - XOTA
+ - DXPEDITION
+ - CONTEST
+ example: XOTA
+
Continent:
type: string
enum:
@@ -970,7 +990,6 @@ components:
- FSK
- PKT
- MSK144
- - UNKNOWN
example: SSB
ModeType:
@@ -979,7 +998,6 @@ components:
- CW
- PHONE
- DATA
- - UNKNOWN
example: CW
ModeSource:
@@ -1433,6 +1451,13 @@ components:
items:
$ref: '#/components/schemas/SIGRef'
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
+ alert_type:
+ description: "The type of alert this is: xOTA, DXpedition, or Contest."
+ $ref: "#/components/schemas/AlertType"
+ url:
+ type: string
+ description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
+ example: "https://www.rsgbcc.org/cgi-bin/contest_rules.pl?year=2026&contest=144backpack1"
source:
type: string
description: Where we got the alert from.
diff --git a/static/js/alerts.js b/static/js/alerts.js
index 9c4461d..07ff9dd 100644
--- a/static/js/alerts.js
+++ b/static/js/alerts.js
@@ -13,7 +13,6 @@ function loadAlerts() {
url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
- updateRefreshDisplay();
// Store data
alerts = jsonData;
// Update table
@@ -38,6 +37,9 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true";
}
+ if ($("#contests_skip_max_duration_check")[0].checked) {
+ str = str + "&contests_skip_max_duration_check=true";
+ }
return str;
}
@@ -208,10 +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["alert_type"] === "CONTEST") {
+ // Contest = true and no DX callsigns, so display "Contest"
+ dx_calls_html = "Contest"
+ }
// Format DXpedition country
let dx_country_html = "";
- if (a["is_dxpedition"] === true && a["dx_country"] != null && a["dx_country"] !== "") {
+ if (a["alert_type"] === "DXPEDITION" && a["dx_country"] != null && a["dx_country"] !== "") {
dx_country_html = `
${a["dx_country"]}`;
}
@@ -226,6 +232,9 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["comment"] != null) {
commentText = escapeHtml(a["comment"]);
}
+ if (a["url"] != null) {
+ commentText = `${commentText}`;
+ }
// Sig or fallback to source
let sigSourceText = a["source"];
@@ -331,33 +340,10 @@ function filtersUpdated() {
saveSettings();
}
-// Update the refresh timing display
-function updateRefreshDisplay() {
- if (lastUpdateTime != null) {
- let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
- let count = REFRESH_INTERVAL_SEC;
- let updatingString = "Updating..."
- if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
- count = REFRESH_INTERVAL_SEC - secSinceUpdate;
- let number;
- if (count <= 60) {
- number = count.toFixed(0);
- updatingString = "Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".";
- } else {
- number = Math.round(count / 60.0).toFixed(0);
- updatingString = "Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".";
- }
- }
- $("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
- }
-}
-
// Startup
$(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
loadOptions();
- // Update the refresh timing display every second
- setInterval(updateRefreshDisplay, 1000);
});
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
diff --git a/templates/add_spot.html b/templates/add_spot.html
index 9467eb0..8ce828d 100644
--- a/templates/add_spot.html
+++ b/templates/add_spot.html
@@ -77,7 +77,7 @@
-
+
diff --git a/templates/alerts.html b/templates/alerts.html
index 58037c3..521f0e7 100644
--- a/templates/alerts.html
+++ b/templates/alerts.html
@@ -3,9 +3,6 @@
+ +