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

@@ -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)