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 @@
-
- {% module Template("widgets/refresh_timer.html", web_ui_options=web_ui_options) %} -
{% module Template("widgets/filters_display_data_buttons.html", web_ui_options=web_ui_options) %} @@ -84,7 +81,7 @@
- + diff --git a/templates/bands.html b/templates/bands.html index 7fb2988..851a27a 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,8 +76,8 @@
- - + + diff --git a/templates/base.html b/templates/base.html index a8df017..92ffbbf 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -16,10 +16,10 @@ window.fetchEventSource = fetchEventSource; - - - - + + + + {% end %} {% block body %}
diff --git a/templates/cards/duration_limit_alerts.html b/templates/cards/duration_limit_alerts.html index 44ce237..4c96bfc 100644 --- a/templates/cards/duration_limit_alerts.html +++ b/templates/cards/duration_limit_alerts.html @@ -21,5 +21,11 @@ for="dxpeditions_skip_max_duration_check">Allow DXpeditions that are longer

+

+ +

\ No newline at end of file diff --git a/templates/conditions.html b/templates/conditions.html index f8f81e2..6cedb3b 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -284,7 +284,7 @@
- + diff --git a/templates/map.html b/templates/map.html index 10eac3e..16bf7ca 100644 --- a/templates/map.html +++ b/templates/map.html @@ -113,8 +113,8 @@ const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; - - + + diff --git a/templates/spots.html b/templates/spots.html index 6df51f0..9b9088c 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -113,8 +113,8 @@ - - + + diff --git a/templates/status.html b/templates/status.html index 5a00e98..726edb1 100644 --- a/templates/status.html +++ b/templates/status.html @@ -86,7 +86,7 @@ - +