Refactor datetime and timezone utils

This commit is contained in:
Urtzi Alfaro
2025-10-12 23:16:04 +02:00
parent 7556a00db7
commit 96ad5c6692
11 changed files with 731 additions and 362 deletions

View File

@@ -33,7 +33,7 @@ optuna.logging.set_verbosity(optuna.logging.WARNING)
from app.core.config import settings
from app.core import constants as const
from app.utils.timezone_utils import prepare_prophet_datetime
from app.utils.ml_datetime import prepare_prophet_datetime
from app.utils.file_utils import ChecksummedFile, calculate_file_checksum
from app.utils.distributed_lock import get_training_lock, LockAcquisitionError

View File

@@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
import logging
from app.utils.timezone_utils import ensure_timezone_aware
from app.utils.ml_datetime import ensure_timezone_aware
logger = logging.getLogger(__name__)

View File

@@ -2,7 +2,7 @@
Training Service Utilities
"""
from .timezone_utils import (
from .ml_datetime import (
ensure_timezone_aware,
ensure_timezone_naive,
normalize_datetime_to_utc,

View File

@@ -1,10 +1,14 @@
"""
Timezone Utility Functions
Centralized timezone handling to ensure consistency across the training service
ML-Specific DateTime Utilities
DateTime utilities for machine learning operations, specifically for:
- Prophet forecasting model (requires timezone-naive datetimes)
- Pandas DataFrame datetime operations
- Time series data processing
"""
from datetime import datetime, timezone
from typing import Optional, Union
from typing import Union
import pandas as pd
import logging
@@ -61,15 +65,12 @@ def normalize_datetime_to_utc(dt: Union[datetime, pd.Timestamp]) -> datetime:
if dt is None:
return None
# Handle pandas Timestamp
if isinstance(dt, pd.Timestamp):
dt = dt.to_pydatetime()
# If naive, assume UTC
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
# If aware but not UTC, convert to UTC
return dt.astimezone(timezone.utc)
@@ -93,19 +94,15 @@ def normalize_dataframe_datetime_column(
logger.warning(f"Column {column} not found in dataframe")
return df
# Convert to datetime if not already
df[column] = pd.to_datetime(df[column])
if target_format == 'naive':
# Remove timezone if present
if df[column].dt.tz is not None:
df[column] = df[column].dt.tz_localize(None)
elif target_format == 'aware':
# Add UTC timezone if not present
if df[column].dt.tz is None:
df[column] = df[column].dt.tz_localize(timezone.utc)
else:
# Convert to UTC if different timezone
df[column] = df[column].dt.tz_convert(timezone.utc)
else:
raise ValueError(f"Invalid target_format: {target_format}. Must be 'naive' or 'aware'")
@@ -140,7 +137,6 @@ def safe_datetime_comparison(dt1: datetime, dt2: datetime) -> int:
Returns:
-1 if dt1 < dt2, 0 if equal, 1 if dt1 > dt2
"""
# Normalize both to UTC for comparison
dt1_utc = normalize_datetime_to_utc(dt1)
dt2_utc = normalize_datetime_to_utc(dt2)
@@ -176,9 +172,99 @@ def convert_timestamp_to_datetime(timestamp: Union[int, float, str]) -> datetime
dt = pd.to_datetime(timestamp)
return normalize_datetime_to_utc(dt)
# Check if milliseconds (typical JavaScript timestamp)
if timestamp > 1e10:
timestamp = timestamp / 1000
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
return dt
def align_dataframe_dates(
dfs: list[pd.DataFrame],
date_column: str = 'ds',
method: str = 'inner'
) -> list[pd.DataFrame]:
"""
Align multiple dataframes to have the same date range.
Args:
dfs: List of DataFrames to align
date_column: Name of the date column
method: 'inner' (intersection) or 'outer' (union)
Returns:
List of aligned DataFrames
"""
if not dfs:
return []
if len(dfs) == 1:
return dfs
all_dates = None
for df in dfs:
if date_column not in df.columns:
continue
dates = set(pd.to_datetime(df[date_column]).dt.date)
if all_dates is None:
all_dates = dates
else:
if method == 'inner':
all_dates = all_dates.intersection(dates)
elif method == 'outer':
all_dates = all_dates.union(dates)
aligned_dfs = []
for df in dfs:
if date_column not in df.columns:
aligned_dfs.append(df)
continue
df = df.copy()
df[date_column] = pd.to_datetime(df[date_column])
df['_date_only'] = df[date_column].dt.date
df = df[df['_date_only'].isin(all_dates)]
df = df.drop('_date_only', axis=1)
aligned_dfs.append(df)
return aligned_dfs
def fill_missing_dates(
df: pd.DataFrame,
date_column: str = 'ds',
freq: str = 'D',
fill_value: float = 0.0
) -> pd.DataFrame:
"""
Fill missing dates in a DataFrame with a specified frequency.
Args:
df: DataFrame with date column
date_column: Name of the date column
freq: Pandas frequency string ('D' for daily, 'H' for hourly, etc.)
fill_value: Value to fill for missing dates
Returns:
DataFrame with filled dates
"""
df = df.copy()
df[date_column] = pd.to_datetime(df[date_column])
df = df.set_index(date_column)
full_range = pd.date_range(
start=df.index.min(),
end=df.index.max(),
freq=freq
)
df = df.reindex(full_range, fill_value=fill_value)
df = df.reset_index()
df = df.rename(columns={'index': date_column})
return df

112
shared/dt_utils/__init__.py Normal file
View File

@@ -0,0 +1,112 @@
"""
DateTime and Timezone Utilities
Modern, clean datetime and timezone handling for the bakery system.
Uses Python's built-in zoneinfo (Python 3.9+) for timezone operations.
Public API:
Core operations (from core.py):
- utc_now, now_in_timezone
- to_utc, to_timezone
- ensure_aware, ensure_naive
- parse_iso, format_iso, format_custom
- is_aware, validate_timezone
Timezone operations (from timezone.py):
- get_timezone_info, get_utc_offset_hours
- list_supported_timezones
- get_current_date_in_tz, get_current_datetime_in_tz
- combine_date_time_in_tz
- convert_to_utc, convert_from_utc
Business logic (from business.py):
- is_business_hours, next_business_day
- get_business_day_range
- get_tenant_now, is_tenant_business_hours
Constants (from constants.py):
- DEFAULT_TIMEZONE
- SUPPORTED_TIMEZONES
- BUSINESS_HOURS_DEFAULT
"""
from .core import (
utc_now,
now_in_timezone,
to_utc,
to_timezone,
ensure_aware,
ensure_naive,
parse_iso,
format_iso,
format_custom,
is_aware,
validate_timezone,
)
from .timezone import (
get_timezone_info,
get_utc_offset_hours,
list_supported_timezones,
get_current_date_in_tz,
get_current_datetime_in_tz,
combine_date_time_in_tz,
convert_to_utc,
convert_from_utc,
)
from .business import (
is_business_hours,
next_business_day,
get_business_day_range,
get_tenant_now,
is_tenant_business_hours,
)
from .constants import (
DEFAULT_TIMEZONE,
SUPPORTED_TIMEZONES,
BUSINESS_HOURS_DEFAULT,
ISO_FORMAT,
DATE_FORMAT,
TIME_FORMAT,
DATETIME_FORMAT,
)
__all__ = [
# Core operations
"utc_now",
"now_in_timezone",
"to_utc",
"to_timezone",
"ensure_aware",
"ensure_naive",
"parse_iso",
"format_iso",
"format_custom",
"is_aware",
"validate_timezone",
# Timezone operations
"get_timezone_info",
"get_utc_offset_hours",
"list_supported_timezones",
"get_current_date_in_tz",
"get_current_datetime_in_tz",
"combine_date_time_in_tz",
"convert_to_utc",
"convert_from_utc",
# Business logic
"is_business_hours",
"next_business_day",
"get_business_day_range",
"get_tenant_now",
"is_tenant_business_hours",
# Constants
"DEFAULT_TIMEZONE",
"SUPPORTED_TIMEZONES",
"BUSINESS_HOURS_DEFAULT",
"ISO_FORMAT",
"DATE_FORMAT",
"TIME_FORMAT",
"DATETIME_FORMAT",
]

157
shared/dt_utils/business.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Business Domain DateTime Logic
Business-specific datetime operations including business hours,
business days, and tenant-specific datetime handling.
"""
from datetime import datetime, time, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
import structlog
from .constants import DEFAULT_TIMEZONE, BUSINESS_HOURS_DEFAULT
from .timezone import get_current_datetime_in_tz, combine_date_time_in_tz, convert_from_utc
logger = structlog.get_logger()
def is_business_hours(
dt: Optional[datetime] = None,
tz: str = DEFAULT_TIMEZONE,
start_hour: int = BUSINESS_HOURS_DEFAULT["start_hour"],
end_hour: int = BUSINESS_HOURS_DEFAULT["end_hour"],
include_weekends: bool = BUSINESS_HOURS_DEFAULT["include_weekends"]
) -> bool:
"""
Check if datetime is within business hours.
Args:
dt: Datetime to check (defaults to now in timezone)
tz: IANA timezone string
start_hour: Business hours start (24h format)
end_hour: Business hours end (24h format)
include_weekends: Whether to include weekends as business days
Returns:
True if within business hours, False otherwise
"""
if dt is None:
dt = get_current_datetime_in_tz(tz)
elif dt.tzinfo is None:
zone = ZoneInfo(tz)
dt = dt.replace(tzinfo=zone)
else:
dt = convert_from_utc(dt, tz)
if not include_weekends and dt.weekday() >= 5:
return False
return start_hour <= dt.hour < end_hour
def next_business_day(
from_dt: Optional[datetime] = None,
target_time: time = time(9, 0),
tz: str = DEFAULT_TIMEZONE
) -> datetime:
"""
Get next business day at specific time in timezone.
Args:
from_dt: Starting datetime (defaults to now in timezone)
target_time: Time to schedule (e.g., time(9, 0) for 9 AM)
tz: IANA timezone string
Returns:
Next business day at target_time in specified timezone
"""
if from_dt is None:
current = get_current_datetime_in_tz(tz)
else:
current = convert_from_utc(from_dt, tz)
next_day = current.date()
next_datetime = combine_date_time_in_tz(next_day, target_time, tz)
if current.time() < target_time:
next_datetime = combine_date_time_in_tz(current.date(), target_time, tz)
while next_datetime.weekday() >= 5:
next_day = next_datetime.date() + timedelta(days=1)
next_datetime = combine_date_time_in_tz(next_day, target_time, tz)
return next_datetime
def get_business_day_range(
start: datetime,
end: datetime,
tz: str = DEFAULT_TIMEZONE
) -> list[datetime]:
"""
Get list of business days (weekdays) between start and end dates.
Args:
start: Start datetime
end: End datetime
tz: IANA timezone string
Returns:
List of datetime objects for each business day
"""
if start.tzinfo is None:
start = start.replace(tzinfo=ZoneInfo(tz))
if end.tzinfo is None:
end = end.replace(tzinfo=ZoneInfo(tz))
business_days = []
current = start
while current <= end:
if current.weekday() < 5:
business_days.append(current)
current += timedelta(days=1)
return business_days
def get_tenant_now(tenant_tz: str = DEFAULT_TIMEZONE) -> datetime:
"""
Get current datetime for tenant's timezone.
Args:
tenant_tz: Tenant's IANA timezone string
Returns:
Current datetime in tenant's timezone
"""
return get_current_datetime_in_tz(tenant_tz)
def is_tenant_business_hours(
tenant_tz: str = DEFAULT_TIMEZONE,
dt: Optional[datetime] = None,
config: Optional[dict] = None
) -> bool:
"""
Check if it's currently business hours for tenant.
Args:
tenant_tz: Tenant's IANA timezone string
dt: Optional datetime to check (defaults to now)
config: Optional business hours configuration override
Returns:
True if within tenant's business hours, False otherwise
"""
if config is None:
config = BUSINESS_HOURS_DEFAULT
return is_business_hours(
dt=dt,
tz=tenant_tz,
start_hour=config.get("start_hour", BUSINESS_HOURS_DEFAULT["start_hour"]),
end_hour=config.get("end_hour", BUSINESS_HOURS_DEFAULT["end_hour"]),
include_weekends=config.get("include_weekends", BUSINESS_HOURS_DEFAULT["include_weekends"])
)

View File

@@ -0,0 +1,33 @@
"""
DateTime and Timezone Constants
Configuration constants for datetime and timezone operations across the bakery system.
"""
DEFAULT_TIMEZONE = "Europe/Madrid"
SUPPORTED_TIMEZONES = {
"Europe/Madrid",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"America/New_York",
"America/Chicago",
"America/Los_Angeles",
"Asia/Tokyo",
"Asia/Shanghai",
"Australia/Sydney",
"UTC"
}
BUSINESS_HOURS_DEFAULT = {
"start_hour": 8,
"end_hour": 20,
"include_weekends": False
}
ISO_FORMAT = "%Y-%m-%dT%H:%M:%S"
ISO_FORMAT_WITH_TZ = "%Y-%m-%dT%H:%M:%S%z"
DATE_FORMAT = "%Y-%m-%d"
TIME_FORMAT = "%H:%M:%S"
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"

168
shared/dt_utils/core.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Core DateTime Utilities
Low-level datetime operations for the bakery system.
All functions use Python's built-in zoneinfo for timezone handling.
"""
from datetime import datetime, timezone
from typing import Optional
from zoneinfo import ZoneInfo
from .constants import DEFAULT_TIMEZONE, ISO_FORMAT
def utc_now() -> datetime:
"""
Get current datetime in UTC with timezone awareness.
Returns:
Current UTC datetime
"""
return datetime.now(timezone.utc)
def now_in_timezone(tz: str = DEFAULT_TIMEZONE) -> datetime:
"""
Get current datetime in specified timezone.
Args:
tz: IANA timezone string (e.g., "Europe/Madrid")
Returns:
Current datetime in the specified timezone
"""
return datetime.now(ZoneInfo(tz))
def to_utc(dt: datetime) -> datetime:
"""
Convert datetime to UTC.
Args:
dt: Datetime to convert (if naive, assumes UTC)
Returns:
Datetime in UTC timezone
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_timezone(dt: datetime, tz: str) -> datetime:
"""
Convert datetime to specified timezone.
Args:
dt: Datetime to convert (if naive, assumes UTC)
tz: Target IANA timezone string
Returns:
Datetime in target timezone
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo(tz))
def ensure_aware(dt: datetime, default_tz: str = "UTC") -> datetime:
"""
Ensure a datetime is timezone-aware.
Args:
dt: Datetime to check
default_tz: Timezone to apply if datetime is naive (default: UTC)
Returns:
Timezone-aware datetime
"""
if dt.tzinfo is None:
tz = ZoneInfo(default_tz)
return dt.replace(tzinfo=tz)
return dt
def ensure_naive(dt: datetime) -> datetime:
"""
Remove timezone information from a datetime.
Args:
dt: Datetime to process
Returns:
Timezone-naive datetime
"""
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
def parse_iso(dt_str: str) -> datetime:
"""
Parse ISO format datetime string.
Args:
dt_str: ISO format datetime string
Returns:
Parsed datetime (timezone-aware if timezone info present)
"""
return datetime.fromisoformat(dt_str)
def format_iso(dt: datetime) -> str:
"""
Format datetime as ISO string.
Args:
dt: Datetime to format
Returns:
ISO format string
"""
return dt.isoformat()
def format_custom(dt: datetime, format_str: str = ISO_FORMAT) -> str:
"""
Format datetime with custom format string.
Args:
dt: Datetime to format
format_str: strftime format string
Returns:
Formatted datetime string
"""
return dt.strftime(format_str)
def is_aware(dt: datetime) -> bool:
"""
Check if datetime is timezone-aware.
Args:
dt: Datetime to check
Returns:
True if timezone-aware, False otherwise
"""
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
def validate_timezone(tz: str) -> bool:
"""
Validate if timezone string is valid.
Args:
tz: IANA timezone string to validate
Returns:
True if valid, False otherwise
"""
try:
ZoneInfo(tz)
return True
except Exception:
return False

160
shared/dt_utils/timezone.py Normal file
View File

@@ -0,0 +1,160 @@
"""
Timezone Operations
Timezone metadata and conversion utilities.
"""
from datetime import datetime, date, time
from zoneinfo import ZoneInfo
from typing import Optional
import structlog
from .constants import DEFAULT_TIMEZONE, SUPPORTED_TIMEZONES
logger = structlog.get_logger()
def get_timezone_info(tz: str) -> ZoneInfo:
"""
Get ZoneInfo object for timezone.
Args:
tz: IANA timezone string
Returns:
ZoneInfo object
Raises:
ZoneInfoNotFoundError: If timezone is invalid
"""
return ZoneInfo(tz)
def get_utc_offset_hours(tz: str) -> float:
"""
Get current UTC offset for timezone in hours.
Args:
tz: IANA timezone string
Returns:
UTC offset in hours (e.g., +2.0 for CEST)
"""
try:
zone = ZoneInfo(tz)
now = datetime.now(zone)
offset_seconds = now.utcoffset().total_seconds()
return offset_seconds / 3600
except Exception as e:
logger.warning(f"Could not get offset for {tz}", error=str(e))
return 0.0
def list_supported_timezones() -> list[str]:
"""
Get list of supported timezones.
Returns:
List of IANA timezone strings
"""
return sorted(SUPPORTED_TIMEZONES)
def get_current_date_in_tz(tz: str = DEFAULT_TIMEZONE) -> date:
"""
Get current date in specified timezone.
Args:
tz: IANA timezone string
Returns:
Current date in the specified timezone
"""
try:
zone = ZoneInfo(tz)
return datetime.now(zone).date()
except Exception as e:
logger.warning(f"Invalid timezone {tz}, using default", error=str(e))
return datetime.now(ZoneInfo(DEFAULT_TIMEZONE)).date()
def get_current_datetime_in_tz(tz: str = DEFAULT_TIMEZONE) -> datetime:
"""
Get current datetime in specified timezone.
Args:
tz: IANA timezone string
Returns:
Current datetime in the specified timezone
"""
try:
zone = ZoneInfo(tz)
return datetime.now(zone)
except Exception as e:
logger.warning(f"Invalid timezone {tz}, using default", error=str(e))
return datetime.now(ZoneInfo(DEFAULT_TIMEZONE))
def combine_date_time_in_tz(
target_date: date,
target_time: time,
tz: str = DEFAULT_TIMEZONE
) -> datetime:
"""
Combine date and time in specified timezone.
Args:
target_date: Date component
target_time: Time component
tz: IANA timezone string
Returns:
Datetime combining date and time in specified timezone
"""
try:
zone = ZoneInfo(tz)
return datetime.combine(target_date, target_time, tzinfo=zone)
except Exception as e:
logger.warning(f"Invalid timezone {tz}, using default", error=str(e))
zone = ZoneInfo(DEFAULT_TIMEZONE)
return datetime.combine(target_date, target_time, tzinfo=zone)
def convert_to_utc(dt: datetime) -> datetime:
"""
Convert datetime to UTC.
Args:
dt: Datetime to convert (must be timezone-aware)
Returns:
Datetime in UTC timezone
"""
if dt.tzinfo is None:
logger.warning("Converting naive datetime to UTC, assuming UTC")
return dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo("UTC"))
def convert_from_utc(dt: datetime, target_timezone: str) -> datetime:
"""
Convert UTC datetime to target timezone.
Args:
dt: UTC datetime
target_timezone: Target IANA timezone string
Returns:
Datetime in target timezone
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
try:
zone = ZoneInfo(target_timezone)
return dt.astimezone(zone)
except Exception as e:
logger.warning(f"Invalid timezone {target_timezone}, using default", error=str(e))
zone = ZoneInfo(DEFAULT_TIMEZONE)
return dt.astimezone(zone)

View File

@@ -1,71 +0,0 @@
"""
DateTime utilities for microservices
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import pytz
def utc_now() -> datetime:
"""Get current UTC datetime"""
return datetime.now(timezone.utc)
def madrid_now() -> datetime:
"""Get current Madrid datetime"""
madrid_tz = pytz.timezone('Europe/Madrid')
return datetime.now(madrid_tz)
def to_utc(dt: datetime) -> datetime:
"""Convert datetime to UTC"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def to_madrid(dt: datetime) -> datetime:
"""Convert datetime to Madrid timezone"""
madrid_tz = pytz.timezone('Europe/Madrid')
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(madrid_tz)
def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Format datetime as string"""
return dt.strftime(format_str)
def parse_datetime(dt_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime:
"""Parse datetime from string"""
return datetime.strptime(dt_str, format_str)
def is_business_hours(dt: Optional[datetime] = None) -> bool:
"""Check if datetime is during business hours (9 AM - 6 PM Madrid time)"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Check if it's a weekday (Monday=0, Sunday=6)
if madrid_dt.weekday() >= 5: # Weekend
return False
# Check if it's business hours
return 9 <= madrid_dt.hour < 18
def next_business_day(dt: Optional[datetime] = None) -> datetime:
"""Get next business day"""
if dt is None:
dt = madrid_now()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
madrid_dt = to_madrid(dt)
# Add days until we reach a weekday
while madrid_dt.weekday() >= 5: # Weekend
madrid_dt += timedelta(days=1)
# Set to 9 AM
return madrid_dt.replace(hour=9, minute=0, second=0, microsecond=0)

View File

@@ -1,276 +0,0 @@
# shared/utils/timezone_helper.py
"""
Timezone Utility Helper for Bakery Management System
Provides timezone-aware date/time utilities for accurate scheduling across
different geographic locations. All schedulers should use these utilities
to ensure consistent behavior.
"""
from datetime import datetime, date, time
from typing import Optional
from zoneinfo import ZoneInfo
import structlog
logger = structlog.get_logger()
class TimezoneHelper:
"""Helper class for timezone-aware operations"""
DEFAULT_TIMEZONE = "Europe/Madrid"
VALID_TIMEZONES = {
"Europe/Madrid", "Europe/London", "Europe/Paris", "Europe/Berlin",
"America/New_York", "America/Chicago", "America/Los_Angeles",
"Asia/Tokyo", "Asia/Shanghai", "Australia/Sydney",
"UTC"
}
@classmethod
def get_current_date_in_timezone(cls, timezone_str: str) -> date:
"""
Get current date in specified timezone
Args:
timezone_str: IANA timezone string (e.g., "Europe/Madrid")
Returns:
Current date in the specified timezone
"""
try:
tz = ZoneInfo(timezone_str)
return datetime.now(tz).date()
except Exception as e:
logger.warning(f"Invalid timezone {timezone_str}, using default",
error=str(e))
return datetime.now(ZoneInfo(cls.DEFAULT_TIMEZONE)).date()
@classmethod
def get_current_datetime_in_timezone(cls, timezone_str: str) -> datetime:
"""
Get current datetime in specified timezone
Args:
timezone_str: IANA timezone string
Returns:
Current datetime in the specified timezone
"""
try:
tz = ZoneInfo(timezone_str)
return datetime.now(tz)
except Exception as e:
logger.warning(f"Invalid timezone {timezone_str}, using default",
error=str(e))
return datetime.now(ZoneInfo(cls.DEFAULT_TIMEZONE))
@classmethod
def combine_date_time_in_timezone(
cls,
target_date: date,
target_time: time,
timezone_str: str
) -> datetime:
"""
Combine date and time in specified timezone
Args:
target_date: Date component
target_time: Time component
timezone_str: IANA timezone string
Returns:
Datetime combining date and time in specified timezone
"""
try:
tz = ZoneInfo(timezone_str)
return datetime.combine(target_date, target_time, tzinfo=tz)
except Exception as e:
logger.warning(f"Invalid timezone {timezone_str}, using default",
error=str(e))
tz = ZoneInfo(cls.DEFAULT_TIMEZONE)
return datetime.combine(target_date, target_time, tzinfo=tz)
@classmethod
def convert_to_utc(cls, dt: datetime) -> datetime:
"""
Convert datetime to UTC
Args:
dt: Datetime to convert (must be timezone-aware)
Returns:
Datetime in UTC timezone
"""
if dt.tzinfo is None:
logger.warning("Converting naive datetime to UTC, assuming UTC")
return dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo("UTC"))
@classmethod
def convert_from_utc(cls, dt: datetime, target_timezone: str) -> datetime:
"""
Convert UTC datetime to target timezone
Args:
dt: UTC datetime
target_timezone: Target IANA timezone string
Returns:
Datetime in target timezone
"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
try:
tz = ZoneInfo(target_timezone)
return dt.astimezone(tz)
except Exception as e:
logger.warning(f"Invalid timezone {target_timezone}, using default",
error=str(e))
tz = ZoneInfo(cls.DEFAULT_TIMEZONE)
return dt.astimezone(tz)
@classmethod
def validate_timezone(cls, timezone_str: str) -> bool:
"""
Validate if timezone string is valid
Args:
timezone_str: IANA timezone string to validate
Returns:
True if valid, False otherwise
"""
try:
ZoneInfo(timezone_str)
return True
except Exception:
return False
@classmethod
def get_timezone_offset_hours(cls, timezone_str: str) -> float:
"""
Get current UTC offset for timezone in hours
Args:
timezone_str: IANA timezone string
Returns:
UTC offset in hours (e.g., +2.0 for CEST)
"""
try:
tz = ZoneInfo(timezone_str)
now = datetime.now(tz)
offset_seconds = now.utcoffset().total_seconds()
return offset_seconds / 3600
except Exception as e:
logger.warning(f"Could not get offset for {timezone_str}",
error=str(e))
return 0.0
@classmethod
def is_business_hours(
cls,
dt: Optional[datetime] = None,
timezone_str: str = DEFAULT_TIMEZONE,
start_hour: int = 8,
end_hour: int = 20
) -> bool:
"""
Check if datetime is within business hours
Args:
dt: Datetime to check (defaults to now)
timezone_str: IANA timezone string
start_hour: Business hours start (24h format)
end_hour: Business hours end (24h format)
Returns:
True if within business hours, False otherwise
"""
if dt is None:
dt = cls.get_current_datetime_in_timezone(timezone_str)
elif dt.tzinfo is None:
# Assume it's in the target timezone
tz = ZoneInfo(timezone_str)
dt = dt.replace(tzinfo=tz)
else:
# Convert to target timezone
dt = cls.convert_from_utc(dt, timezone_str)
# Check if weekday (Monday=0, Sunday=6)
if dt.weekday() >= 5: # Saturday or Sunday
return False
# Check if within business hours
return start_hour <= dt.hour < end_hour
@classmethod
def get_next_business_day_at_time(
cls,
target_time: time,
timezone_str: str = DEFAULT_TIMEZONE,
from_datetime: Optional[datetime] = None
) -> datetime:
"""
Get next business day at specific time in timezone
Args:
target_time: Time to schedule (e.g., time(6, 0) for 6 AM)
timezone_str: IANA timezone string
from_datetime: Starting datetime (defaults to now)
Returns:
Next business day at target_time in specified timezone
"""
if from_datetime is None:
current = cls.get_current_datetime_in_timezone(timezone_str)
else:
current = cls.convert_from_utc(from_datetime, timezone_str)
# Start with next day
next_day = current.date()
next_datetime = cls.combine_date_time_in_timezone(
next_day, target_time, timezone_str
)
# If we haven't passed target_time today, use today
if current.time() < target_time:
next_datetime = cls.combine_date_time_in_timezone(
current.date(), target_time, timezone_str
)
# Skip weekends
while next_datetime.weekday() >= 5: # Saturday or Sunday
next_day = next_datetime.date()
from datetime import timedelta
next_day = next_day + timedelta(days=1)
next_datetime = cls.combine_date_time_in_timezone(
next_day, target_time, timezone_str
)
return next_datetime
# Convenience functions for common operations
def get_tenant_current_date(tenant_timezone: str = "Europe/Madrid") -> date:
"""Get current date for tenant's timezone"""
return TimezoneHelper.get_current_date_in_timezone(tenant_timezone)
def get_tenant_current_datetime(tenant_timezone: str = "Europe/Madrid") -> datetime:
"""Get current datetime for tenant's timezone"""
return TimezoneHelper.get_current_datetime_in_timezone(tenant_timezone)
def is_tenant_business_hours(tenant_timezone: str = "Europe/Madrid") -> bool:
"""Check if it's currently business hours for tenant"""
return TimezoneHelper.is_business_hours(timezone_str=tenant_timezone)
def validate_timezone(timezone_str: str) -> bool:
"""Validate timezone string"""
return TimezoneHelper.validate_timezone(timezone_str)