From c12da77439fa4252e9c9fa882f7d3ac8e85c82e2 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 24 Feb 2026 14:14:53 +0100 Subject: [PATCH] Fixing `change_datetime` notification token (and adding test) (#3922) --- changedetectionio/notification_service.py | 63 ++++++++++++------- .../templates/_common_fields.html | 8 +++ changedetectionio/tests/test_notification.py | 18 +++++- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index eac9902e8..98c8ea6cc 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -54,16 +54,51 @@ def _check_cascading_vars(datastore, var_name, watch): return None +class FormattableTimestamp(str): + """ + A str subclass representing a formatted datetime. As a plain string it renders + with the default format, but can also be called with a custom format argument + in Jinja2 templates: + + {{ change_datetime }} → '2024-01-15 10:30:00 UTC' + {{ change_datetime(format='%Y') }} → '2024' + {{ change_datetime(format='%Y-%m-%d') }} → '2024-01-15' + + Being a str subclass means it is natively JSON serializable. + """ + _DEFAULT_FORMAT = '%Y-%m-%d %H:%M:%S %Z' + + def __new__(cls, timestamp): + dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + local_tz = datetime.datetime.now().astimezone().tzinfo + dt_local = dt.astimezone(local_tz) + try: + formatted = dt_local.strftime(cls._DEFAULT_FORMAT) + except Exception: + formatted = dt_local.isoformat() + instance = super().__new__(cls, formatted) + instance._dt = dt_local + return instance + + def __call__(self, format=_DEFAULT_FORMAT): + try: + return self._dt.strftime(format) + except Exception: + return self._dt.isoformat() + + # What is passed around as notification context, also used as the complete list of valid {{ tokens }} class NotificationContextData(dict): def __init__(self, initial_data=None, **kwargs): + # ValidateJinja2Template() validates against the keynames of this dict to check for valid tokens in the body (user submission) super().__init__({ 'base_url': None, + 'change_datetime': FormattableTimestamp(time.time()), 'current_snapshot': None, 'diff': None, - 'diff_clean': None, 'diff_added': None, 'diff_added_clean': None, + 'diff_clean': None, 'diff_full': None, 'diff_full_clean': None, 'diff_patch': None, @@ -72,16 +107,18 @@ class NotificationContextData(dict): 'diff_url': None, 'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen 'notification_timestamp': time.time(), + 'prev_snapshot': None, 'preview_url': None, 'screenshot': None, - 'triggered_text': None, 'timestamp_from': None, 'timestamp_to': None, + 'triggered_text': None, 'uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters 'watch_mime_type': None, 'watch_tag': None, 'watch_title': None, 'watch_url': 'https://WATCH-PLACE-HOLDER/', + 'watch_uuid': 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', # Converted to 'watch_uuid' in create_notification_parameters }) # Apply any initial data passed in @@ -103,7 +140,7 @@ class NotificationContextData(dict): So we can test the output in the notification body """ for key in self.keys(): - if key in ['uuid', 'time', 'watch_uuid']: + if key in ['uuid', 'time', 'watch_uuid', 'change_datetime']: continue rand_str = 'RANDOM-PLACEHOLDER-'+''.join(random.choices(string.ascii_letters + string.digits, k=12)) self[key] = rand_str @@ -115,24 +152,6 @@ class NotificationContextData(dict): super().__setitem__(key, value) -def timestamp_to_localtime(timestamp): - # Format the date using locale-aware formatting with timezone - dt = datetime.datetime.fromtimestamp(int(timestamp)) - dt = dt.replace(tzinfo=pytz.UTC) - - # Get local timezone-aware datetime - local_tz = datetime.datetime.now().astimezone().tzinfo - local_dt = dt.astimezone(local_tz) - - # Format date with timezone - using strftime for locale awareness - try: - formatted_date = local_dt.strftime('%Y-%m-%d %H:%M:%S %Z') - except: - # Fallback if locale issues - formatted_date = local_dt.isoformat() - - return formatted_date - def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool): """ Efficiently renders only the diff placeholders that are actually used in the notification text. @@ -198,7 +217,7 @@ def set_basic_notification_vars(current_snapshot, prev_snapshot, watch, triggere 'current_snapshot': current_snapshot, 'prev_snapshot': prev_snapshot, 'screenshot': watch.get_screenshot() if watch and watch.get('notification_screenshot') else None, - 'change_datetime': timestamp_to_localtime(timestamp_changed) if timestamp_changed else None, + 'change_datetime': FormattableTimestamp(timestamp_changed) if timestamp_changed else None, 'triggered_text': triggered_text, 'uuid': watch.get('uuid') if watch else None, 'watch_url': watch.get('url') if watch else None, diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html index b3cb8a7f1..85f72a158 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -44,6 +44,14 @@ {{ '{{preview_url}}' }} {{ _('The URL of the preview page generated by changedetection.io.') }} + + {{ '{{change_datetime}}' }} + {{ _('Date/time of the change, accepts format=, change_datetime(format=\'%A\')\', default is \'%Y-%m-%d %H:%M:%S %Z\'') }} + + + {{ '{{diff_url}}' }} + {{ _('The URL of the diff output for the watch.') }} + {{ '{{diff_url}}' }} {{ _('The URL of the diff output for the watch.') }} diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index 568727e8f..3a1ef2d63 100644 --- a/changedetectionio/tests/test_notification.py +++ b/changedetectionio/tests/test_notification.py @@ -17,6 +17,7 @@ from changedetectionio.notification import ( ) from ..diff import HTML_CHANGED_STYLE from ..model import USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH +from ..notification_service import FormattableTimestamp # Hard to just add more live server URLs when one test is already running (I think) @@ -108,6 +109,9 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore "Diff Removed: {{diff_removed}}\n" "Diff Full: {{diff_full}}\n" "Diff as Patch: {{diff_patch}}\n" + "Change datetime: {{change_datetime}}\n" + "Change datetime format: Weekday {{change_datetime(format='%A')}}\n" + "Change datetime format: {{change_datetime(format='%Y-%m-%dT%H:%M:%S%z')}}\n" ":-)", "notification_screenshot": True, "notification_format": 'text'} @@ -135,8 +139,6 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore assert bytes(notification_url.encode('utf-8')) in res.data assert bytes("New ChangeDetection.io Notification".encode('utf-8')) in res.data - - ## Now recheck, and it should have sent the notification wait_for_all_checks(client) set_modified_response(datastore_path=datastore_path) @@ -172,11 +174,23 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore assert ":-)" in notification_submission assert "New ChangeDetection.io Notification - {}".format(test_url) in notification_submission assert test_url in notification_submission + assert ':-)' in notification_submission # Check the attachment was added, and that it is a JPEG from the original PNG notification_submission_object = json.loads(notification_submission) assert notification_submission_object + import time + # Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any + times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)] + assert any(t in notification_submission for t in times_possible) + + txt = f"Weekday {FormattableTimestamp(int(time.time()))(format='%A')}" + assert txt in notification_submission + + + + # We keep PNG screenshots for now # IF THIS FAILS YOU SHOULD BE TESTING WITH ENV VAR REMOVE_REQUESTS_OLD_SCREENSHOTS=False assert notification_submission_object['attachments'][0]['filename'] == 'last-screenshot.png'