Repository: jamesoff/simplemonitor

Issue:
Timezones in Alerters
Since #489 loggers now have a option `tz` for specifying a timezone (thanks for that!). Alerters don't have one though. So, for example, I can't change (or haven't found out how to) the timezone of the `{failed_at}` variable in the `execute` alerter. Maybe adding a `tz` option for alerters just like the one for loggers would be nice.

Discussion:

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 05a5247..b08fa7b 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -38,3 +38,4 @@ jobs:
         run: make network-test
       - name: Upload to codecov
         run: pipenv run codecov --token="${{ secrets.CODECOV_TOKEN }}"
+      - uses: codecov/codecov-action@v1
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 6345ace..980bf96 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -32,5 +32,4 @@ jobs:
         run: make integration-tests
       - name: Unit tests
         run: make unit-test
-      - name: Upload to codecov
-        run: pipenv run codecov
+      - uses: codecov/codecov-action@v1
diff --git a/docs/alerting.md b/docs/alerting.md
index 5f8005f..5448953 100644
--- a/docs/alerting.md
+++ b/docs/alerting.md
@@ -37,6 +37,7 @@ The section name should be the name of your alerter. This is the name you should
 | ooh_success | makes an alerter trigger its success action even if out-of-hours (0 or 1) | no | 0 |
 | groups | comma-separated list of group names this alerter will fire for. See the `group` setting for monitors | no | `default` |
 | only_failures | set to 1 to only fire this alerters for failure notifications (or catchups), not recoveries | no | 0 |
+| tz | timezone to use in alert messages | no | UTC |
 
 The *limit* uses the virtual fail count of a monitor, which means if a monitor has a tolerance of 3 and the alerter has a limit of 2, the monitor must fail 5 times before an alert is sent.
 
diff --git a/simplemonitor/Alerters/alerter.py b/simplemonitor/Alerters/alerter.py
index 34373af..4d0a3b7 100644
--- a/simplemonitor/Alerters/alerter.py
+++ b/simplemonitor/Alerters/alerter.py
@@ -158,6 +158,7 @@ class Alerter:
         self._only_failures = self.get_config_option(
             "only_failures", required_type=bool, default=False
         )
+        self._tz = cast(str, self.get_config_option("tz", default="UTC"))
 
         if self._ooh_failures is None:
             self._ooh_failures = []
@@ -311,9 +312,8 @@ class Alerter:
         else:
             return "unknowned"
 
-    @staticmethod
     def build_message(
-        length: AlertLength, alert_type: AlertType, monitor: Monitor
+        self, length: AlertLength, alert_type: AlertType, monitor: Monitor
     ) -> str:
         """Create a message for an Alerter to send."""
         if monitor.state() == MonitorState.FAILED:
@@ -336,7 +336,7 @@ class Alerter:
                 alert_type=alert_type.value,
                 alert_verb=Alerter._get_verb(alert_type),
                 downtime=downtime,
-                failure_time=format_datetime(monitor.first_failure_time()),
+                failure_time=format_datetime(monitor.first_failure_time(), self._tz),
                 monitor=monitor,
                 result=monitor.get_result(),
             )
@@ -384,13 +384,13 @@ class Alerter:
                 alert_type=alert_type,
                 monitor=monitor,
                 alert_verb=Alerter._get_verb(alert_type),
-                failure_time=format_datetime(monitor.first_failure_time()),
+                failure_time=format_datetime(monitor.first_failure_time(), self._tz),
                 downtime=downtime,
                 result=monitor.get_result(),
                 host=host,
                 desc=monitor.describe(),
                 vfc=monitor.virtual_fail_count(),
-                recovered_time=format_datetime(monitor.last_update),
+                recovered_time=format_datetime(monitor.last_update, self._tz),
             )
             message = textwrap.dedent(message)
         else:


Test patch:
diff --git a/tests/test_alerter.py b/tests/test_alerter.py
index 3da5bda..f568b9d 100644
--- a/tests/test_alerter.py
+++ b/tests/test_alerter.py
@@ -297,12 +297,17 @@ class TestAlerter(unittest.TestCase):
 
 
 class TestMessageBuilding(unittest.TestCase):
+    def setUp(self):
+        self.test_alerter = alerter.Alerter()
+        self.freeze_time_value = "2020-03-10 09:00"
+        self.expected_time_string = "2020-03-10 09:00:00+00:00"
+
     def test_notification_format_failure(self):
         m = monitor.MonitorFail("test", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.NOTIFICATION, alerter.AlertType.FAILURE, m
                 ),
                 "Monitor test failed",
@@ -310,11 +315,11 @@ class TestMessageBuilding(unittest.TestCase):
 
     def test_notification_format_success(self):
         m = monitor.MonitorNull("winning", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             for _ in range(0, 6):
                 m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.NOTIFICATION, alerter.AlertType.SUCCESS, m
                 ),
                 "Monitor winning succeeded",
@@ -322,20 +327,21 @@ class TestMessageBuilding(unittest.TestCase):
 
     def test_oneline_format_failure(self):
         m = monitor.MonitorFail("test", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.ONELINE, alerter.AlertType.FAILURE, m
                 ),
-                "failure: test failed on {hostname} at 2020-03-10 09:00:00+00:00 (0+00:00:00): This monitor always fails.".format(
-                    hostname=util.short_hostname()
+                "failure: test failed on {hostname} at {expected_time} (0+00:00:00): This monitor always fails.".format(
+                    hostname=util.short_hostname(),
+                    expected_time=self.expected_time_string,
                 ),
             )
 
     def test_oneline_format_success(self):
         m = monitor.MonitorNull("winning", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             for _ in range(0, 6):
                 m.run_test()
             m.last_result = "a " * 80
@@ -345,32 +351,33 @@ class TestMessageBuilding(unittest.TestCase):
                 )
                 + "a " * 80
             )
-            output = alerter.Alerter.build_message(
+            output = self.test_alerter.build_message(
                 alerter.AlertLength.ONELINE, alerter.AlertType.SUCCESS, m
             )
             self.assertEqual(desired, output)
 
     def test_sms_format_failure(self):
         m = monitor.MonitorFail("test", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.SMS, alerter.AlertType.FAILURE, m
                 ),
-                "failure: test failed on {hostname} at 2020-03-10 09:00:00+00:00 (0+00:00:00): This monitor always fails.".format(
-                    hostname=util.short_hostname()
+                "failure: test failed on {hostname} at {expected_time} (0+00:00:00): This monitor always fails.".format(
+                    hostname=util.short_hostname(),
+                    expected_time=self.expected_time_string,
                 ),
             )
 
     def test_sms_format_success(self):
         m = monitor.MonitorNull("winning", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             for _ in range(0, 6):
                 m.run_test()
             m.last_result = "a " * 80
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.SMS, alerter.AlertType.SUCCESS, m
                 ),
                 "success: winning succeeded on {hostname} at (0+00:00:00): a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a...".format(
@@ -380,69 +387,79 @@ class TestMessageBuilding(unittest.TestCase):
 
     def test_full_format_failure(self):
         m = monitor.MonitorFail("test", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.FULL, alerter.AlertType.FAILURE, m
                 ),
                 textwrap.dedent(
                     """
                     Monitor test on {hostname} failed!
-                    Failed at: 2020-03-10 09:00:00+00:00 (down 0+00:00:00)
+                    Failed at: {expected_time} (down 0+00:00:00)
                     Virtual failure count: 1
                     Additional info: This monitor always fails.
                     Description: A monitor which always fails.
                     """.format(
-                        hostname=util.short_hostname()
+                        hostname=util.short_hostname(),
+                        expected_time=self.expected_time_string,
                     )
                 ),
             )
 
     def test_full_format_failure_docs(self):
         m = monitor.MonitorFail("test", {"failure_doc": "whoops"})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.FULL, alerter.AlertType.FAILURE, m
                 ),
                 textwrap.dedent(
                     """
                     Monitor test on {hostname} failed!
-                    Failed at: 2020-03-10 09:00:00+00:00 (down 0+00:00:00)
+                    Failed at: {expected_time} (down 0+00:00:00)
                     Virtual failure count: 1
                     Additional info: This monitor always fails.
                     Description: A monitor which always fails.
                     Documentation: whoops
                     """.format(
-                        hostname=util.short_hostname()
+                        hostname=util.short_hostname(),
+                        expected_time=self.expected_time_string,
                     )
                 ),
             )
 
     def test_full_format_success(self):
         m = monitor.MonitorNull("winning", {})
-        with freeze_time("2020-03-10 09:00"):
+        with freeze_time(self.freeze_time_value):
             for _ in range(0, 6):
                 m.run_test()
             self.assertEqual(
-                alerter.Alerter.build_message(
+                self.test_alerter.build_message(
                     alerter.AlertLength.FULL, alerter.AlertType.SUCCESS, m
                 ),
                 textwrap.dedent(
                     """
                     Monitor winning on {hostname} succeeded!
-                    Recovered at: 2020-03-10 09:00:00+00:00
+                    Recovered at: {expected_time}
                     Additional info: 
                     Description: (Monitor did not write an auto-biography.)
                     """.format(  # noqa: W291
-                        hostname=util.short_hostname()
+                        hostname=util.short_hostname(),
+                        expected_time=self.expected_time_string,
                     )
                 ),
             )
 
 
+class TestMessageBuildingTZ(TestMessageBuilding):
+    def setUp(self):
+        super().setUp()
+        self.test_alerter._tz = "Europe/Warsaw"
+        self.expected_time_string = "2020-03-10 10:00:00+01:00"
+
+
 class TestSNSAlerter(unittest.TestCase):
     def test_config(self):
         with self.assertRaises(util.AlerterConfigurationError):