# #region AppTimezone [C:3] [TYPE Module] [SEMANTICS core,timezone,utilities,datetime] # @BRIEF Application-level timezone utilities. Reads APP_TIMEZONE from env (default Europe/Moscow) # and provides helpers for converting UTC datetimes to the configured timezone. # @RELATION CALLED_BY -> [ConfigManager] # @RELATION DEPENDS_ON -> [EXT:method:GlobalSettings.app_timezone] # @RATIONALE Centralised timezone resolution avoids scattering ZoneInfo() calls across the codebase. # All API responses should emit localised timestamps; internal storage remains UTC. # @REJECTED Storing non-UTC in the database rejected — UTC is the canonical best practice for # persistence; timezone conversion is a presentation-layer concern. from datetime import datetime import os from zoneinfo import ZoneInfo # #region _get_default_tz_name [C:1] [TYPE Function] # @BRIEF Read APP_TIMEZONE from env, fall back to Europe/Moscow. # @PRE Environment is loaded (dotenv or os.environ). # @POST Returns a valid IANA timezone string. def _get_default_tz_name() -> str: return os.getenv("APP_TIMEZONE", "Europe/Moscow") # #endregion _get_default_tz_name # #region get_app_timezone [C:1] [TYPE Function] # @BRIEF Return cached ZoneInfo for the configured application timezone. # @SIDE_EFFECT Reads os.environ on first call; result is cached. # @POST Returns a ZoneInfo instance matching APP_TIMEZONE env var. _APP_TZ_CACHE: ZoneInfo | None = None def get_app_timezone() -> ZoneInfo: global _APP_TZ_CACHE if _APP_TZ_CACHE is None: _APP_TZ_CACHE = ZoneInfo(_get_default_tz_name()) return _APP_TZ_CACHE # #endregion get_app_timezone # #region invalidate_timezone_cache [C:1] [TYPE Function] # @BRIEF Reset the cached ZoneInfo so the next get_app_timezone() call re-reads from env/DB. # @POST _APP_TZ_CACHE is set to None; subsequent get_app_timezone() will re-resolve. # @RATIONALE Called after PATCH /settings/consolidated updates app_timezone. def invalidate_timezone_cache() -> None: global _APP_TZ_CACHE _APP_TZ_CACHE = None # #endregion invalidate_timezone_cache # #region validate_timezone [C:1] [TYPE Function] # @BRIEF Validate that a timezone string is a known IANA timezone. # @PRE tz_name is a string. # @POST Returns True if ZoneInfo accepts the name, False otherwise. def validate_timezone(tz_name: str) -> bool: try: ZoneInfo(tz_name) return True except (KeyError, TypeError): return False # #endregion validate_timezone # #region localize [C:1] [TYPE Function] # @BRIEF Convert a timezone-aware or naive UTC datetime to the configured app timezone. # @PRE If dt is timezone-naive, it is assumed to be UTC. # @POST Returns a datetime with the app timezone attached (astimezone). def localize(dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=ZoneInfo("UTC")) return dt.astimezone(get_app_timezone()) # #endregion localize # #region now [C:1] [TYPE Function] # @BRIEF Get current time in the configured application timezone. # @POST Returns timezone-aware datetime in the app timezone. def now() -> datetime: return datetime.now(get_app_timezone()) # #endregion now # #region format_timezone_offset [C:1] [TYPE Function] # @BRIEF Return the UTC offset string for the configured timezone (e.g. "+03:00"). # @POST Returns string like "+03:00" or "+00:00". def format_timezone_offset() -> str: offset = now().strftime("%z") if offset: return f"{offset[:3]}:{offset[3:]}" return "+00:00" # #endregion format_timezone_offset # #endregion AppTimezone