mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 14:27:42 +00:00
Support RSGB contests
This commit is contained in:
@@ -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.
|
||||
|
||||
+11
-6
@@ -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):
|
||||
|
||||
+6
-6
@@ -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):
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+6
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,3 +25,4 @@ ruff~=0.16.3
|
||||
pdfplumber~=0.11.10
|
||||
xlrd~=2.0.2
|
||||
openpyxl~=3.1.5
|
||||
icalendar~=7.3.0
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+11
-25
@@ -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 => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).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 = `<br/>${a["dx_country"]}`;
|
||||
}
|
||||
|
||||
@@ -226,6 +232,9 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
if (a["comment"] != null) {
|
||||
commentText = escapeHtml(a["comment"]);
|
||||
}
|
||||
if (a["url"] != null) {
|
||||
commentText = `<a href="${escapeHtml(a['url'])}" target="_new">${commentText}</a>`;
|
||||
}
|
||||
|
||||
// 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 = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
|
||||
} else {
|
||||
number = Math.round(count / 60.0).toFixed(0);
|
||||
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
|
||||
}
|
||||
}
|
||||
$("#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
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/add-spot.js?v=1788546948"></script>
|
||||
<script src="/static/js/add-spot.js?v=1788595232"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-add-spot").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<div id="settingsButtonRow" class="row mb-3">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
{% module Template("widgets/refresh_timer.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="d-inline-flex gap-1">
|
||||
{% module Template("widgets/filters_display_data_buttons.html", web_ui_options=web_ui_options) %}
|
||||
@@ -84,7 +81,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/alerts.js?v=1788546948"></script>
|
||||
<script src="/static/js/alerts.js?v=1788595233"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-alerts").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
|
||||
<script src="/static/js/bands.js?v=1788546948"></script>
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788595232"></script>
|
||||
<script src="/static/js/bands.js?v=1788595232"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-bands").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{% extends "skeleton.html" %}
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=1788546948" type="text/css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=1788595232" type="text/css">
|
||||
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||
@@ -16,10 +16,10 @@
|
||||
window.fetchEventSource = fetchEventSource;
|
||||
</script>
|
||||
|
||||
<script src="/static/js/utils.js?v=1788546948"></script>
|
||||
<script src="/static/js/ui-ham.js?v=1788546948"></script>
|
||||
<script src="/static/js/geo.js?v=1788546948"></script>
|
||||
<script src="/static/js/common.js?v=1788546948"></script>
|
||||
<script src="/static/js/utils.js?v=1788595232"></script>
|
||||
<script src="/static/js/ui-ham.js?v=1788595232"></script>
|
||||
<script src="/static/js/geo.js?v=1788595232"></script>
|
||||
<script src="/static/js/common.js?v=1788595232"></script>
|
||||
{% end %}
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
|
||||
@@ -21,5 +21,11 @@
|
||||
for="dxpeditions_skip_max_duration_check">Allow
|
||||
DXpeditions that are longer</label>
|
||||
</p>
|
||||
<p class='card-text spothole-card-text' style='line-height: 1.5em !important;'>
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();"
|
||||
id="contests_skip_max_duration_check" checked><label class="form-check-label ms-2"
|
||||
for="contests_skip_max_duration_check">Allow
|
||||
DXpeditions that are longer</label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||
<script src="/static/js/conditions.js?v=1788546948"></script>
|
||||
<script src="/static/js/conditions.js?v=1788595232"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
+2
-2
@@ -113,8 +113,8 @@
|
||||
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
||||
</script>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
|
||||
<script src="/static/js/map.js?v=1788546948"></script>
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788595233"></script>
|
||||
<script src="/static/js/map.js?v=1788595233"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-map").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
|
||||
<script src="/static/js/spots.js?v=1788546948"></script>
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1788595232"></script>
|
||||
<script src="/static/js/spots.js?v=1788595232"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-spots").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/status.js?v=1788546948"></script>
|
||||
<script src="/static/js/status.js?v=1788595232"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$("#nav-link-status").addClass("active");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<div id="timing-container">Loading...</div>
|
||||
Reference in New Issue
Block a user