diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8aeba8da..687bb35d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -5,8 +5,8 @@ file can be found at [config.example.json](config.example.json) Auto-Southwest Check-In supports both global configuration and account/reservation-specific configuration. See [Accounts and Reservations](#accounts-and-reservations) for more information. -**Note**: Many configuration items may also be configured via environment variables (except for account and -reservation specific configurations). +**Note**: Many configuration items may also be configured via environment variables (except for account and +reservation-specific configurations). ## Table of Contents - [Fare Check](#fare-check) @@ -19,6 +19,7 @@ reservation specific configurations). - [Accounts and Reservations](#accounts-and-reservations) * [Accounts](#accounts) * [Reservations](#reservations) +- [Healthchecks URL](#healthchecks-url) ## Fare Check Default: true \ @@ -159,18 +160,18 @@ and/or not provide reservation information as arguments. } ``` - ### Account and Reservation-specific configuration Setting specific configuration values for an account or reservation allows you to fully customize how you want them to be monitored by the script. Here is a list of configuration values that can be applied to an individual account or reservation: - [Fare Check](#fare-check) +- [Healthchecks URL](#healthchecks-url) - [Notification URLS](#notification-urls) - [Notification Level](#notification-level) - [Retrieval Interval](#retrieval-interval) Not all options have to be specified for each account or reservation. If an option is not specified, the top-level value is used -(or the default value if no top-level value is specified either). Any accounts or reservations specified through the command line -will use all of the top-level values. +(or the default value if no top-level value is specified either) with exception to the Healthchecks URL. Any accounts or reservations +specified through the command line will use all of the top-level values. An important note about notification URLs: An account or reservation with specific notification URLs will send notifications to those URLs as well as URLs specified globally. @@ -205,6 +206,26 @@ In this example, the script will send notifications attached to this reservation } ``` +## Healthchecks URL +Default: No URL \ +Type: String + +Monitor successful and failed fare checks using a [Healthchecks.io](https://healthchecks.io/) URL. When a fare check +fails, the `/fail` endpoint of your Healthchecks URL will be pinged to notify you of the failure. + +This configuration option can only be applied within reservation and account configurations (specifying it at the top-level +will have no effect). Due to this, no environment variable is provided as a replacement for this configuration option. +```json +{ + "accounts": [ + { + "username": "user1", + "password": "pass1", + "healthchecks_url": "https://hc-ping.com/uuid" + } + ] +} +``` [0]: https://github.com/caronc/apprise diff --git a/lib/config.py b/lib/config.py index 38454751..41b7d463 100644 --- a/lib/config.py +++ b/lib/config.py @@ -28,6 +28,10 @@ def __init__(self) -> None: self.notification_urls = [] self.retrieval_interval = 24 * 60 * 60 + # Account and reservation-specific config (parsed in _parse_config, but not merged into + # the global configuration). + self.healthchecks_url = None + def create(self, config_json: JSON, global_config: "GlobalConfig") -> None: self._merge_globals(global_config) self._parse_config(config_json) @@ -38,8 +42,8 @@ def _merge_globals(self, global_config: "GlobalConfig") -> None: configuration first. If specific options are set for an account or reservation, those will override the global configuration. """ - self.check_fares = global_config.check_fares self.browser_path = global_config.browser_path + self.check_fares = global_config.check_fares self.notification_level = global_config.notification_level self.notification_urls.extend(global_config.notification_urls) self.retrieval_interval = global_config.retrieval_interval @@ -56,6 +60,14 @@ def _parse_config(self, config: JSON) -> None: if not isinstance(self.check_fares, bool): raise ConfigError("'check_fares' must be a boolean") + if "healthchecks_url" in config: + self.healthchecks_url = config["healthchecks_url"] + + if not isinstance(self.healthchecks_url, str): + raise ConfigError("'healthchecks_url' must be a string") + + logger.debug("A Healthchecks URL has been provided") + if "notification_level" in config: notification_level = config["notification_level"] try: diff --git a/lib/notification_handler.py b/lib/notification_handler.py index 224c2d47..a3ae60a2 100644 --- a/lib/notification_handler.py +++ b/lib/notification_handler.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, List import apprise +import requests from .flight import Flight from .log import get_logger @@ -118,5 +119,13 @@ def lower_fare(self, flight: Flight, price_info: str) -> None: logger.debug("Sending lower fare notification...") self.send_notification(message, NotificationLevel.INFO) + def healthchecks_success(self, data: str) -> None: + if self.reservation_monitor.config.healthchecks_url is not None: + requests.post(self.reservation_monitor.config.healthchecks_url, data=data) + + def healthchecks_fail(self, data: str) -> None: + if self.reservation_monitor.config.healthchecks_url is not None: + requests.post(self.reservation_monitor.config.healthchecks_url + "/fail", data=data) + def _get_account_name(self) -> str: return f"{self.reservation_monitor.first_name} {self.reservation_monitor.last_name}" diff --git a/lib/reservation_monitor.py b/lib/reservation_monitor.py index 1840669d..e8b321cd 100644 --- a/lib/reservation_monitor.py +++ b/lib/reservation_monitor.py @@ -98,12 +98,24 @@ def _check_flight_fares(self) -> None: # and continue try: fare_checker.check_flight_price(flight) + self.notification_handler.healthchecks_success( + f"Successful fare check, confirmation number={flight.confirmation_number}" + ) except RequestError as err: logger.error("Requesting error during fare check. %s. Skipping...", err) + self.notification_handler.healthchecks_fail( + f"Failed fare check, confirmation number={flight.confirmation_number}" + ) except FlightChangeError as err: logger.debug("%s. Skipping fare check", err) + self.notification_handler.healthchecks_success( + f"Successful fare check, confirmation number={flight.confirmation_number}" + ) except Exception as err: logger.exception("Unexpected error during fare check: %s", repr(err)) + self.notification_handler.healthchecks_fail( + f"Failed fare check, confirmation number={flight.confirmation_number}" + ) def _smart_sleep(self, previous_time: datetime) -> None: """ diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b6d54c6f..a797b5ea 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -26,7 +26,7 @@ def test_create_merges_and_parses_config(self, mocker: MockerFixture) -> None: mock_merge_globals.assert_called_once_with(global_config) mock_parse_config.assert_called_once_with({"test": "config"}) - def test_merge_globals_merges_all_config_options(self) -> None: + def test_merge_globals_merges_all_global_config_options(self) -> None: global_config = GlobalConfig() test_config = Config() @@ -34,6 +34,7 @@ def test_merge_globals_merges_all_config_options(self) -> None: { "browser_path": "test/browser_path", "check_fares": True, + "healthchecks_url": "global_healthchecks", "notification_level": 1, "notification_urls": "url1", "retrieval_interval": 20, @@ -44,6 +45,7 @@ def test_merge_globals_merges_all_config_options(self) -> None: { "browser_path": "test/browser_path2", "check_fares": False, + "healthchecks_url": "test_healthchecks", "notification_level": 2, "notification_urls": ["url2"], "retrieval_interval": 10, @@ -57,10 +59,14 @@ def test_merge_globals_merges_all_config_options(self) -> None: assert test_config.notification_urls == ["url2", "url1"] assert test_config.retrieval_interval == global_config.retrieval_interval + # Ensure only global configs are merged, not account/reservation-specific configs + assert test_config.healthchecks_url == "test_healthchecks" + @pytest.mark.parametrize( "config_content", [ {"check_fares": "invalid"}, + {"healthchecks_url": 0}, {"notification_level": "invalid"}, {"notification_level": 3}, {"notification_urls": None}, @@ -78,6 +84,7 @@ def test_parse_config_sets_the_correct_config_values(self) -> None: test_config._parse_config( { "check_fares": False, + "healthchecks_url": "test_healthchecks", "notification_level": 2, "notification_urls": "test_url", "retrieval_interval": 30, @@ -85,6 +92,7 @@ def test_parse_config_sets_the_correct_config_values(self) -> None: ) assert test_config.check_fares is False + assert test_config.healthchecks_url == "test_healthchecks" assert test_config.notification_level == NotificationLevel.ERROR assert test_config.notification_urls == ["test_url"] assert test_config.retrieval_interval == 30 * 60 * 60 @@ -96,6 +104,7 @@ def test_parse_config_does_not_set_values_when_a_config_value_is_empty(self) -> test_config._parse_config({}) assert test_config.check_fares == expected_config.check_fares + assert test_config.healthchecks_url == expected_config.healthchecks_url assert test_config.notification_urls == expected_config.notification_urls assert test_config.notification_level == expected_config.notification_level assert test_config.retrieval_interval == expected_config.retrieval_interval @@ -170,7 +179,7 @@ def test_read_config_returns_empty_config_when_file_not_found( test_config = GlobalConfig() config_content = test_config._read_config() - assert config_content == {} + assert not config_content def test_read_config_raises_exception_when_config_not_dict(self, mocker: MockerFixture) -> None: mocker.patch.object(Path, "read_text") @@ -374,7 +383,7 @@ def test_read_env_vars_missing_reservation(self, mocker: MockerFixture) -> None: test_config = GlobalConfig() config_content = test_config._read_env_vars({}) - assert config_content == {} + assert not config_content @pytest.mark.parametrize( "config_content", diff --git a/tests/unit/test_notification_handler.py b/tests/unit/test_notification_handler.py index 2a9774ae..1392fb4f 100644 --- a/tests/unit/test_notification_handler.py +++ b/tests/unit/test_notification_handler.py @@ -131,6 +131,26 @@ def test_lower_fare_sends_lower_fare_notification(self, mocker: MockerFixture) - self.handler.lower_fare(mock_flight, "") assert mock_send_notification.call_args[0][1] == NotificationLevel.INFO + @pytest.mark.parametrize(["url", "expected_calls"], [("http://healthchecks", 1), (None, 0)]) + def test_healthchecks_success_pings_url_only_if_configured( + self, mocker: MockerFixture, url: str, expected_calls: int + ) -> None: + mock_post = mocker.patch("requests.post") + self.handler.reservation_monitor.config.healthchecks_url = url + + self.handler.healthchecks_success("healthchecks success") + assert mock_post.call_count == expected_calls + + @pytest.mark.parametrize(["url", "expected_calls"], [("http://healthchecks", 1), (None, 0)]) + def test_healthchecks_fail_pings_url_only_if_configured( + self, mocker: MockerFixture, url: str, expected_calls: int + ) -> None: + mock_post = mocker.patch("requests.post") + self.handler.reservation_monitor.config.healthchecks_url = url + + self.handler.healthchecks_fail("healthchecks fail") + assert mock_post.call_count == expected_calls + def test_get_account_name_returns_the_correct_name(self) -> None: self.handler.reservation_monitor.first_name = "John" self.handler.reservation_monitor.last_name = "Doe" diff --git a/tests/unit/test_reservation_monitor.py b/tests/unit/test_reservation_monitor.py index 23eb0383..3db52907 100644 --- a/tests/unit/test_reservation_monitor.py +++ b/tests/unit/test_reservation_monitor.py @@ -133,10 +133,11 @@ def test_check_flight_fares_does_not_check_fares_if_configuration_is_false( mock_fare_checker.assert_not_called() def test_check_flight_fares_checks_fares_on_all_flights(self, mocker: MockerFixture) -> None: + test_flight = mocker.patch("lib.checkin_handler.Flight") mock_check_flight_price = mocker.patch.object(FareChecker, "check_flight_price") self.monitor.config.check_fares = True - self.monitor.checkin_scheduler.flights = ["test_flight1", "test_flight2"] + self.monitor.checkin_scheduler.flights = [test_flight, test_flight] self.monitor._check_flight_fares() assert mock_check_flight_price.call_count == len(self.monitor.checkin_scheduler.flights) @@ -145,12 +146,13 @@ def test_check_flight_fares_checks_fares_on_all_flights(self, mocker: MockerFixt def test_check_flight_fares_catches_error_when_checking_fares( self, mocker: MockerFixture, exception: Exception ) -> None: + test_flight = mocker.patch("lib.checkin_handler.Flight") mock_check_flight_price = mocker.patch.object( - FareChecker, "check_flight_price", side_effect=["", exception] + FareChecker, "check_flight_price", side_effect=[None, exception] ) self.monitor.config.check_fares = True - self.monitor.checkin_scheduler.flights = ["test_flight1", "test_flight2"] + self.monitor.checkin_scheduler.flights = [test_flight, test_flight] self.monitor._check_flight_fares() assert mock_check_flight_price.call_count == len(self.monitor.checkin_scheduler.flights)