From edc5bdec7957e53f4aeeffef086298a1813728d5 Mon Sep 17 00:00:00 2001 From: ehl0wr0ld <96768694+ehlowr0ld@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:35:15 +0200 Subject: [PATCH] fix: timezone switching spam bug (#664) * fix: timezone switching spam bug * fix: rate limiting timezone update --------- Co-authored-by: Rafael Uzarowski --- python/helpers/localization.py | 122 +++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 30 deletions(-) diff --git a/python/helpers/localization.py b/python/helpers/localization.py index 3356150d0..565f3f7f2 100644 --- a/python/helpers/localization.py +++ b/python/helpers/localization.py @@ -1,13 +1,16 @@ -from datetime import datetime +from datetime import datetime, timezone as dt_timezone, timedelta import pytz # type: ignore from python.helpers.print_style import PrintStyle from python.helpers.dotenv import get_dotenv_value, save_dotenv_value + class Localization: """ Localization class for handling timezone conversions between UTC and local time. + Now stores a fixed UTC offset (in minutes) derived from the provided timezone name + to avoid noisy updates when equivalent timezones share the same offset. """ # singleton @@ -20,34 +23,90 @@ class Localization: return cls._instance def __init__(self, timezone: str | None = None): + self.timezone: str = "UTC" + self._offset_minutes: int = 0 + self._last_timezone_change: datetime | None = None + # Load persisted values if available + persisted_tz = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")) + persisted_offset = get_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", None) if timezone is not None: - self.set_timezone(timezone) # Use the setter to validate - else: - timezone = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")) + # Explicit override self.set_timezone(timezone) + else: + # Initialize from persisted values + self.timezone = persisted_tz + if persisted_offset is not None: + try: + self._offset_minutes = int(str(persisted_offset)) + except Exception: + self._offset_minutes = self._compute_offset_minutes(self.timezone) + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes)) + else: + # Compute from timezone and persist + self._offset_minutes = self._compute_offset_minutes(self.timezone) + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes)) def get_timezone(self) -> str: return self.timezone + def _compute_offset_minutes(self, timezone_name: str) -> int: + tzinfo = pytz.timezone(timezone_name) + now_in_tz = datetime.now(tzinfo) + offset = now_in_tz.utcoffset() + return int(offset.total_seconds() // 60) if offset else 0 + + def get_offset_minutes(self) -> int: + return self._offset_minutes + + def _can_change_timezone(self) -> bool: + """Check if timezone can be changed (rate limited to once per hour).""" + if self._last_timezone_change is None: + return True + + time_diff = datetime.now() - self._last_timezone_change + return time_diff >= timedelta(hours=1) + def set_timezone(self, timezone: str) -> None: - """Set the timezone, with validation.""" - # Validate timezone + """Set the timezone name, but internally store and compare by UTC offset minutes.""" try: - pytz.timezone(timezone) - if timezone != getattr(self, 'timezone', None): - PrintStyle.debug(f"Changing timezone from {getattr(self, 'timezone', 'None')} to {timezone}") + # Validate timezone and compute its current offset + _ = pytz.timezone(timezone) + new_offset = self._compute_offset_minutes(timezone) + + # If offset changes, check rate limit and update + if new_offset != getattr(self, "_offset_minutes", None): + if not self._can_change_timezone(): + return + + prev_tz = getattr(self, "timezone", "None") + prev_off = getattr(self, "_offset_minutes", None) + PrintStyle.debug( + f"Changing timezone from {prev_tz} (offset {prev_off}) to {timezone} (offset {new_offset})" + ) + self._offset_minutes = new_offset self.timezone = timezone + # Persist both the human-readable tz and the numeric offset save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone) + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes)) + + # Update rate limit timestamp only when actual change occurs + self._last_timezone_change = datetime.now() + else: + # Offset unchanged: update stored timezone without logging or persisting to avoid churn + self.timezone = timezone except pytz.exceptions.UnknownTimeZoneError: PrintStyle.error(f"Unknown timezone: {timezone}, defaulting to UTC") self.timezone = "UTC" - # save the default timezone to the environment variable to avoid future errors on startup + self._offset_minutes = 0 + # save defaults to avoid future errors on startup save_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC") + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", "0") def localtime_str_to_utc_dt(self, localtime_str: str | None) -> datetime | None: """ Convert a local time ISO string to a UTC datetime object. Returns None if input is None or invalid. + When input lacks tzinfo, assume the configured fixed UTC offset. """ if not localtime_str: return None @@ -58,22 +117,27 @@ class Localization: # Try parsing with timezone info first local_datetime_obj = datetime.fromisoformat(localtime_str) if local_datetime_obj.tzinfo is None: - # If no timezone info, assume it's in the configured timezone - local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj) + # If no timezone info, assume fixed offset + local_datetime_obj = local_datetime_obj.replace( + tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes)) + ) except ValueError: # If timezone parsing fails, try without timezone - local_datetime_obj = datetime.fromisoformat(localtime_str.split('Z')[0].split('+')[0]) - local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj) + base = localtime_str.split('Z')[0].split('+')[0] + local_datetime_obj = datetime.fromisoformat(base) + local_datetime_obj = local_datetime_obj.replace( + tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes)) + ) # Convert to UTC - return local_datetime_obj.astimezone(pytz.utc) + return local_datetime_obj.astimezone(dt_timezone.utc) except Exception as e: PrintStyle.error(f"Error converting localtime string to UTC: {e}") return None def utc_dt_to_localtime_str(self, utc_dt: datetime | None, sep: str = "T", timespec: str = "auto") -> str | None: """ - Convert a UTC datetime object to a local time ISO string. + Convert a UTC datetime object to a local time ISO string using the fixed UTC offset. Returns None if input is None. """ if utc_dt is None: @@ -83,15 +147,15 @@ class Localization: assert utc_dt is not None try: - # Ensure datetime is timezone aware + # Ensure datetime is timezone aware in UTC if utc_dt.tzinfo is None: - utc_dt = pytz.utc.localize(utc_dt) - elif utc_dt.tzinfo != pytz.utc: - utc_dt = utc_dt.astimezone(pytz.utc) + utc_dt = utc_dt.replace(tzinfo=dt_timezone.utc) + else: + utc_dt = utc_dt.astimezone(dt_timezone.utc) - # Convert to local time - local_datetime_obj = utc_dt.astimezone(pytz.timezone(self.timezone)) - # Return the local time string + # Convert to local time using fixed offset + local_tz = dt_timezone(timedelta(minutes=self._offset_minutes)) + local_datetime_obj = utc_dt.astimezone(local_tz) return local_datetime_obj.isoformat(sep=sep, timespec=timespec) except Exception as e: PrintStyle.error(f"Error converting UTC datetime to localtime string: {e}") @@ -99,8 +163,8 @@ class Localization: def serialize_datetime(self, dt: datetime | None) -> str | None: """ - Serialize a datetime object to ISO format string in the user's timezone. - This ensures the frontend receives dates in the correct timezone for display. + Serialize a datetime object to ISO format string using the user's fixed UTC offset. + This ensures the frontend receives dates with the correct current offset for display. """ if dt is None: return None @@ -111,12 +175,10 @@ class Localization: try: # Ensure datetime is timezone aware (if not, assume UTC) if dt.tzinfo is None: - dt = pytz.utc.localize(dt) - - # Convert to the user's timezone - local_timezone = pytz.timezone(self.timezone) - local_dt = dt.astimezone(local_timezone) + dt = dt.replace(tzinfo=dt_timezone.utc) + local_tz = dt_timezone(timedelta(minutes=self._offset_minutes)) + local_dt = dt.astimezone(local_tz) return local_dt.isoformat() except Exception as e: PrintStyle.error(f"Error serializing datetime: {e}")