diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index 356b39e05..394927b03 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -54,14 +54,45 @@ def _check_cascading_vars(datastore, var_name, watch): return None +class FormattableTimestamp: + """ + A datetime wrapper that renders with a default strftime format when used as a string, + 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' + """ + _DEFAULT_FORMAT = '%Y-%m-%d %H:%M:%S %Z' + + def __init__(self, timestamp): + dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + local_tz = datetime.datetime.now().astimezone().tzinfo + self._dt = dt.astimezone(local_tz) + + def __call__(self, format=_DEFAULT_FORMAT): + try: + return self._dt.strftime(format) + except Exception: + return self._dt.isoformat() + + def __str__(self): + try: + return self._dt.strftime(self._DEFAULT_FORMAT) + except Exception: + return self._dt.isoformat() + + def __repr__(self): + return self.__str__() + + # 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': None, - 'change_datetime_z': None, + 'change_datetime': FormattableTimestamp(time.time()), 'current_snapshot': None, 'diff': None, 'diff_added': None, @@ -108,7 +139,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 @@ -120,35 +151,6 @@ class NotificationContextData(dict): super().__setitem__(key, value) -def timestamp_to_localtime_iso8601(timestamp): - # Interpret timestamp as UTC - dt_utc = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) - - # Convert to local timezone - local_dt = dt_utc.astimezone() - - # Return ISO 8601 format - return local_dt.isoformat() - -def timestamp_to_localtime(timestamp): - # Format the date using locale-aware formatting with timezone - # Pass tz=UTC so fromtimestamp() interprets the epoch correctly as UTC, - # rather than converting to local time first and then mislabelling it as UTC. - dt = datetime.datetime.fromtimestamp(int(timestamp), tz=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. @@ -214,8 +216,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_z': timestamp_to_localtime_iso8601(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 d89955a26..85f72a158 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -45,12 +45,8 @@ {{ _('The URL of the preview page generated by changedetection.io.') }} - {{ '{{change_datime}}' }} - {{ _('Date/time of the change in \'%Y-%m-%d %H:%M:%S %Z\' format') }} - - - {{ '{{change_datime_z}}' }} - {{ _('Date/time of the change in ISO-8601 format') }} + {{ '{{change_datetime}}' }} + {{ _('Date/time of the change, accepts format=, change_datetime(format=\'%A\')\', default is \'%Y-%m-%d %H:%M:%S %Z\'') }} {{ '{{diff_url}}' }} diff --git a/changedetectionio/tests/test_notification.py b/changedetectionio/tests/test_notification.py index f5715b472..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) @@ -109,7 +110,8 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore "Diff Full: {{diff_full}}\n" "Diff as Patch: {{diff_patch}}\n" "Change datetime: {{change_datetime}}\n" - "Change datetime Z: {{change_datetime_z}}\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'} @@ -178,15 +180,15 @@ def test_check_notification(client, live_server, measure_memory_usage, datastore notification_submission_object = json.loads(notification_submission) assert notification_submission_object - # timestamp worked import time - from changedetectionio.notification_service import timestamp_to_localtime, timestamp_to_localtime_iso8601 # Could be from a few seconds ago (when the notification was fired vs in this test checking), so check for any - times_possible = [timestamp_to_localtime(int(time.time()) - i) for i in range(15)] + times_possible = [str(FormattableTimestamp(int(time.time()) - i)) for i in range(15)] assert any(t in notification_submission for t in times_possible) - times_possible = [timestamp_to_localtime_iso8601(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