Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add healthchecks.io integration #203

Merged
merged 10 commits into from
Feb 16, 2024
31 changes: 26 additions & 5 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 \
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions lib/notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
12 changes: 12 additions & 0 deletions lib/reservation_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,24 @@ def _check_flight_fares(self) -> None:
# and continue
try:
fare_checker.check_flight_price(flight)
StevenMassaro marked this conversation as resolved.
Show resolved Hide resolved
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)
jdholtz marked this conversation as resolved.
Show resolved Hide resolved
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:
"""
Expand Down
15 changes: 12 additions & 3 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ 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()

global_config._parse_config(
{
"browser_path": "test/browser_path",
"check_fares": True,
"healthchecks_url": "global_healthchecks",
"notification_level": 1,
"notification_urls": "url1",
"retrieval_interval": 20,
Expand All @@ -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,
Expand All @@ -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},
Expand All @@ -78,13 +84,15 @@ 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,
}
)

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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions tests/unit/test_reservation_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down