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