Spaces:
Paused
Paused
File size: 7,906 Bytes
8d1819a |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
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
_instance = None
@classmethod
def get(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = cls(*args, **kwargs)
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:
# 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 name, but internally store and compare by UTC offset minutes."""
try:
# 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"
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
try:
# Handle both with and without timezone info
try:
# 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 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
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(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 using the fixed UTC offset.
Returns None if input is None.
"""
if utc_dt is None:
return None
# At this point, utc_dt is definitely not None
assert utc_dt is not None
try:
# Ensure datetime is timezone aware in UTC
if utc_dt.tzinfo is None:
utc_dt = utc_dt.replace(tzinfo=dt_timezone.utc)
else:
utc_dt = utc_dt.astimezone(dt_timezone.utc)
# 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}")
return None
def serialize_datetime(self, dt: datetime | None) -> str | None:
"""
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
# At this point, dt is definitely not None
assert dt is not None
try:
# Ensure datetime is timezone aware (if not, assume UTC)
if dt.tzinfo is None:
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}")
return None
|