Refactor datetime and timezone utils
This commit is contained in:
@@ -33,7 +33,7 @@ optuna.logging.set_verbosity(optuna.logging.WARNING)
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core import constants as const
|
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.file_utils import ChecksummedFile, calculate_file_checksum
|
||||||
from app.utils.distributed_lock import get_training_lock, LockAcquisitionError
|
from app.utils.distributed_lock import get_training_lock, LockAcquisitionError
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
from app.utils.timezone_utils import ensure_timezone_aware
|
from app.utils.ml_datetime import ensure_timezone_aware
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Training Service Utilities
|
Training Service Utilities
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .timezone_utils import (
|
from .ml_datetime import (
|
||||||
ensure_timezone_aware,
|
ensure_timezone_aware,
|
||||||
ensure_timezone_naive,
|
ensure_timezone_naive,
|
||||||
normalize_datetime_to_utc,
|
normalize_datetime_to_utc,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Timezone Utility Functions
|
ML-Specific DateTime Utilities
|
||||||
Centralized timezone handling to ensure consistency across the training service
|
|
||||||
|
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 datetime import datetime, timezone
|
||||||
from typing import Optional, Union
|
from typing import Union
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -61,15 +65,12 @@ def normalize_datetime_to_utc(dt: Union[datetime, pd.Timestamp]) -> datetime:
|
|||||||
if dt is None:
|
if dt is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Handle pandas Timestamp
|
|
||||||
if isinstance(dt, pd.Timestamp):
|
if isinstance(dt, pd.Timestamp):
|
||||||
dt = dt.to_pydatetime()
|
dt = dt.to_pydatetime()
|
||||||
|
|
||||||
# If naive, assume UTC
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
# If aware but not UTC, convert to UTC
|
|
||||||
return dt.astimezone(timezone.utc)
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,19 +94,15 @@ def normalize_dataframe_datetime_column(
|
|||||||
logger.warning(f"Column {column} not found in dataframe")
|
logger.warning(f"Column {column} not found in dataframe")
|
||||||
return df
|
return df
|
||||||
|
|
||||||
# Convert to datetime if not already
|
|
||||||
df[column] = pd.to_datetime(df[column])
|
df[column] = pd.to_datetime(df[column])
|
||||||
|
|
||||||
if target_format == 'naive':
|
if target_format == 'naive':
|
||||||
# Remove timezone if present
|
|
||||||
if df[column].dt.tz is not None:
|
if df[column].dt.tz is not None:
|
||||||
df[column] = df[column].dt.tz_localize(None)
|
df[column] = df[column].dt.tz_localize(None)
|
||||||
elif target_format == 'aware':
|
elif target_format == 'aware':
|
||||||
# Add UTC timezone if not present
|
|
||||||
if df[column].dt.tz is None:
|
if df[column].dt.tz is None:
|
||||||
df[column] = df[column].dt.tz_localize(timezone.utc)
|
df[column] = df[column].dt.tz_localize(timezone.utc)
|
||||||
else:
|
else:
|
||||||
# Convert to UTC if different timezone
|
|
||||||
df[column] = df[column].dt.tz_convert(timezone.utc)
|
df[column] = df[column].dt.tz_convert(timezone.utc)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid target_format: {target_format}. Must be 'naive' or 'aware'")
|
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:
|
Returns:
|
||||||
-1 if dt1 < dt2, 0 if equal, 1 if dt1 > dt2
|
-1 if dt1 < dt2, 0 if equal, 1 if dt1 > dt2
|
||||||
"""
|
"""
|
||||||
# Normalize both to UTC for comparison
|
|
||||||
dt1_utc = normalize_datetime_to_utc(dt1)
|
dt1_utc = normalize_datetime_to_utc(dt1)
|
||||||
dt2_utc = normalize_datetime_to_utc(dt2)
|
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)
|
dt = pd.to_datetime(timestamp)
|
||||||
return normalize_datetime_to_utc(dt)
|
return normalize_datetime_to_utc(dt)
|
||||||
|
|
||||||
# Check if milliseconds (typical JavaScript timestamp)
|
|
||||||
if timestamp > 1e10:
|
if timestamp > 1e10:
|
||||||
timestamp = timestamp / 1000
|
timestamp = timestamp / 1000
|
||||||
|
|
||||||
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
return dt
|
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
112
shared/dt_utils/__init__.py
Normal 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
157
shared/dt_utils/business.py
Normal 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"])
|
||||||
|
)
|
||||||
33
shared/dt_utils/constants.py
Normal file
33
shared/dt_utils/constants.py
Normal 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
168
shared/dt_utils/core.py
Normal 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
160
shared/dt_utils/timezone.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user