Refactor datetime and timezone utils
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user