From 96ad5c6692e0442e4c06d1cbd25cae5e0979d427 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sun, 12 Oct 2025 23:16:04 +0200 Subject: [PATCH] Refactor datetime and timezone utils --- services/training/app/ml/prophet_manager.py | 2 +- .../app/services/date_alignment_service.py | 2 +- services/training/app/utils/__init__.py | 2 +- .../{timezone_utils.py => ml_datetime.py} | 110 ++++++- shared/dt_utils/__init__.py | 112 +++++++ shared/dt_utils/business.py | 157 ++++++++++ shared/dt_utils/constants.py | 33 +++ shared/dt_utils/core.py | 168 +++++++++++ shared/dt_utils/timezone.py | 160 ++++++++++ shared/utils/datetime_utils.py | 71 ----- shared/utils/timezone_helper.py | 276 ------------------ 11 files changed, 731 insertions(+), 362 deletions(-) rename services/training/app/utils/{timezone_utils.py => ml_datetime.py} (62%) create mode 100644 shared/dt_utils/__init__.py create mode 100644 shared/dt_utils/business.py create mode 100644 shared/dt_utils/constants.py create mode 100644 shared/dt_utils/core.py create mode 100644 shared/dt_utils/timezone.py delete mode 100644 shared/utils/datetime_utils.py delete mode 100644 shared/utils/timezone_helper.py diff --git a/services/training/app/ml/prophet_manager.py b/services/training/app/ml/prophet_manager.py index 95121484..e3baf7f5 100644 --- a/services/training/app/ml/prophet_manager.py +++ b/services/training/app/ml/prophet_manager.py @@ -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 diff --git a/services/training/app/services/date_alignment_service.py b/services/training/app/services/date_alignment_service.py index 2f9e9ec2..6e31448c 100644 --- a/services/training/app/services/date_alignment_service.py +++ b/services/training/app/services/date_alignment_service.py @@ -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__) diff --git a/services/training/app/utils/__init__.py b/services/training/app/utils/__init__.py index 07b969d5..fa610cba 100644 --- a/services/training/app/utils/__init__.py +++ b/services/training/app/utils/__init__.py @@ -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, diff --git a/services/training/app/utils/timezone_utils.py b/services/training/app/utils/ml_datetime.py similarity index 62% rename from services/training/app/utils/timezone_utils.py rename to services/training/app/utils/ml_datetime.py index 77bf4e2d..5cd7122b 100644 --- a/services/training/app/utils/timezone_utils.py +++ b/services/training/app/utils/ml_datetime.py @@ -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 diff --git a/shared/dt_utils/__init__.py b/shared/dt_utils/__init__.py new file mode 100644 index 00000000..a4fcce2e --- /dev/null +++ b/shared/dt_utils/__init__.py @@ -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", +] diff --git a/shared/dt_utils/business.py b/shared/dt_utils/business.py new file mode 100644 index 00000000..5607f174 --- /dev/null +++ b/shared/dt_utils/business.py @@ -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"]) + ) diff --git a/shared/dt_utils/constants.py b/shared/dt_utils/constants.py new file mode 100644 index 00000000..326bb4ea --- /dev/null +++ b/shared/dt_utils/constants.py @@ -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" diff --git a/shared/dt_utils/core.py b/shared/dt_utils/core.py new file mode 100644 index 00000000..bf829838 --- /dev/null +++ b/shared/dt_utils/core.py @@ -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 diff --git a/shared/dt_utils/timezone.py b/shared/dt_utils/timezone.py new file mode 100644 index 00000000..c9ab4023 --- /dev/null +++ b/shared/dt_utils/timezone.py @@ -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) diff --git a/shared/utils/datetime_utils.py b/shared/utils/datetime_utils.py deleted file mode 100644 index 3035001a..00000000 --- a/shared/utils/datetime_utils.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/shared/utils/timezone_helper.py b/shared/utils/timezone_helper.py deleted file mode 100644 index b3d12886..00000000 --- a/shared/utils/timezone_helper.py +++ /dev/null @@ -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)