diff --git a/alertproviders/alert_provider.py b/alertproviders/alert_provider.py index 59e6e4d..1c6f62c 100644 --- a/alertproviders/alert_provider.py +++ b/alertproviders/alert_provider.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import pytz @@ -35,8 +35,12 @@ class AlertProvider: for alert in alerts: # Fill in any blanks alert.infer_missing() - # Add to the list - self.alerts.add(alert.id, alert, expire=MAX_ALERT_AGE) + # Add to the list, provided it meets the semsible date test. If alerts have an end time, it must be in the + # future, or if not, then the start date must be at least in the last 24 hours. + if (alert.end_time and alert.end_time > datetime.now(pytz.UTC).timestamp()) or ( + not alert.end_time and alert.start_time > (datetime.now( + pytz.UTC) - timedelta(days=1)).timestamp()): + self.alerts.add(alert.id, alert, expire=MAX_ALERT_AGE) # Stop any threads and prepare for application shutdown def stop(self): diff --git a/core/cleanup.py b/core/cleanup.py index 3c7eba2..c97a63d 100644 --- a/core/cleanup.py +++ b/core/cleanup.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timedelta from threading import Timer from time import sleep @@ -32,6 +32,17 @@ class CleanupTimer: # Perform cleanup self.spots.expire() self.alerts.expire() + + # Alerts can persist in the system for a while, so we want to explicitly clean up any alerts that have + # definitively ended, or if they have no definite end time, then if the start time was more than 24 hours + # ago. + for id in list(self.alerts.iterkeys()): + alert = self.alerts[id] + if (alert.end_time and alert.end_time < datetime.now(pytz.UTC).timestamp()) or ( + not alert.end_time and alert.start_time < (datetime.now( + pytz.UTC) - timedelta(days=1)).timestamp()): + self.alerts.evict(id) + self.status = "OK" self.last_cleanup_time = datetime.now(pytz.UTC) @@ -41,4 +52,4 @@ class CleanupTimer: sleep(1) self.cleanup_timer = Timer(self.cleanup_interval, self.cleanup) - self.cleanup_timer.start() \ No newline at end of file + self.cleanup_timer.start() diff --git a/data/alert.py b/data/alert.py index d628450..687f892 100644 --- a/data/alert.py +++ b/data/alert.py @@ -1,7 +1,9 @@ +import hashlib import json from dataclasses import dataclass from datetime import datetime +import copy import pytz from core.constants import DXCC_FLAGS @@ -14,7 +16,7 @@ from core.utils import infer_continent_from_callsign, \ @dataclass class Alert: # Unique identifier for the alert - id: int = None + id: str = None # Callsign of the operator that has been alertted dx_call: str = None # Name of the operator that has been alertted @@ -103,8 +105,15 @@ class Alert: if self.dx_call and not self.dx_name: self.dx_name = infer_name_from_callsign(self.dx_call) - # Always create an ID based on a hashcode - self.id = hash(str(self)) + # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index + # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical + # apart from that they were retrieved from the API at different times. Note that the simple Python hash() + # function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we + # use diskcache to store our data between runs, so we use SHA256 which does not include this random element. + self_copy = copy.deepcopy(self) + self_copy.received_time = 0 + self_copy.received_time_iso = "" + self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest() # JSON serialise def to_json(self): diff --git a/data/spot.py b/data/spot.py index 21aa147..9e897d0 100644 --- a/data/spot.py +++ b/data/spot.py @@ -1,3 +1,5 @@ +import copy +import hashlib import json from dataclasses import dataclass from datetime import datetime @@ -16,7 +18,7 @@ from core.utils import infer_mode_type_from_mode, infer_band_from_freq, infer_co @dataclass class Spot: # Unique identifier for the spot - id: int = None + id: str = None # Callsign of the operator that has been spotted dx_call: str = None # Callsign of the operator that has spotted them @@ -207,8 +209,15 @@ class Spot: # is likely at home. self.location_good = self.location_source == "SPOT" or (self.location_source == "QRZ" and not "/" in self.dx_call) - # Always create an ID based on a hashcode - self.id = hash(str(self)) + # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index + # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical + # apart from that they were retrieved from the API at different times. Note that the simple Python hash() + # function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we + # use diskcache to store our data between runs, so we use SHA256 which does not include this random element. + self_copy = copy.deepcopy(self) + self_copy.received_time = 0 + self_copy.received_time_iso = "" + self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest() # JSON serialise def to_json(self): diff --git a/server/webserver.py b/server/webserver.py index cfbfbdc..c5e9dc6 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -70,7 +70,8 @@ class WebServer: if not ALLOW_SPOTTING: response.content_type = 'application/json' response.status = 401 - return json.dumps("Error - this server does not allow new spots to be added via the API.", default=serialize_everything) + return json.dumps("Error - this server does not allow new spots to be added via the API.", + default=serialize_everything) # Reject if no spot if not bottle.request.query.spot: @@ -86,7 +87,8 @@ class WebServer: if not spot.time or not spot.dx_call: response.content_type = 'application/json' response.status = 422 - return json.dumps("Error - 'time' and 'dx_call' must be provided as a minimum.", default=serialize_everything) + return json.dumps("Error - 'time' and 'dx_call' must be provided as a minimum.", + default=serialize_everything) # infer missing data, and add it to our database. spot.source = "API" @@ -169,7 +171,6 @@ class WebServer: spots = spots[:int(query.get("limit"))] return spots - # Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in # the main "alerts" GET call. def get_alert_list_with_filters(self): @@ -185,26 +186,26 @@ class WebServer: alert_ids = list(self.alerts.iterkeys()) alerts = [] for k in alert_ids: - # While we persist old spots in the system for a while to produce a useful list, any alert that has already - # passed its end time can be explicitly removed from the list to return. - # TODO deal with there being no end time - if self.alerts.get(k).end_time > datetime.now(pytz.UTC).timestamp(): - alerts.append(self.alerts.get(k)) + alerts.append(self.alerts.get(k)) alerts = sorted(alerts, key=lambda alert: alert.start_time) for k in query.keys(): match k: case "received_since": since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC) - alerts = [s for s in alerts if s.received_time > since] + alerts = [a for a in alerts if a.received_time > since] + case "max_duration": + max_duration = int(query.get(k)) + alerts = [a for a in alerts if (a.end_time and a.end_time - a.start_time <= max_duration) or ( + not a.end_time and datetime.now(pytz.UTC).timestamp() - a.start_time <= max_duration)] case "source": sources = query.get(k).split(",") - alerts = [s for s in alerts if s.source in sources] + alerts = [a for a in alerts if a.source in sources] case "sig": sigs = query.get(k).split(",") - alerts = [s for s in alerts if s.sig in sigs] + alerts = [a for a in alerts if a.sig in sigs] case "dx_continent": dxconts = query.get(k).split(",") - alerts = [s for s in alerts if s.dx_continent in dxconts] + alerts = [a for a in alerts if a.dx_continent in dxconts] # If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys. if "limit" in query.keys(): alerts = alerts[:int(query.get("limit"))] @@ -219,7 +220,9 @@ class WebServer: "mode_types": MODE_TYPES, "sigs": SIGS, # Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available. - "spot_sources": list(map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))), - "alert_sources": list(map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))), + "spot_sources": list( + map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))), + "alert_sources": list( + map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))), "continents": CONTINENTS, "max_spot_age": MAX_SPOT_AGE} diff --git a/views/webpage_about.tpl b/views/webpage_about.tpl index 81fadd4..959435d 100644 --- a/views/webpage_about.tpl +++ b/views/webpage_about.tpl @@ -9,4 +9,6 @@
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.
The software was written by Ian Renton, MØTRT and other contributors. Full details are available in the README.
- \ No newline at end of file + + + \ No newline at end of file diff --git a/views/webpage_alerts.tpl b/views/webpage_alerts.tpl index 4808e0a..a00380f 100644 --- a/views/webpage_alerts.tpl +++ b/views/webpage_alerts.tpl @@ -25,7 +25,7 @@