demo seed change
This commit is contained in:
465
shared/utils/demo_dates.py
Normal file → Executable file
465
shared/utils/demo_dates.py
Normal file → Executable file
@@ -1,18 +1,37 @@
|
||||
"""
|
||||
Demo Date Offset Utilities
|
||||
Provides functions for adjusting dates during demo session cloning
|
||||
to ensure all temporal data is relative to the demo session creation time
|
||||
Demo Date Utilities for Temporal Determinism
|
||||
Adjusts dates from seed data to be relative to demo session creation time
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import pytz
|
||||
|
||||
# Fixed base reference date for all demo data
|
||||
# This is the "day 0" that all seed data is defined relative to
|
||||
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# Base reference date for all demo seed data
|
||||
# All seed scripts should use this as the "logical seed date"
|
||||
# IMPORTANT: This should be set to approximately the current date to ensure demo data appears current
|
||||
# Updated to December 1, 2025 to align with current date
|
||||
BASE_REFERENCE_DATE = datetime(2025, 12, 1, 6, 0, 0, tzinfo=timezone.utc)
|
||||
def get_base_reference_date(session_created_at: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
Get the base reference date for demo data.
|
||||
|
||||
If session_created_at is provided, calculate relative to it.
|
||||
Otherwise, use current time (for backwards compatibility with seed scripts).
|
||||
|
||||
Returns:
|
||||
Base reference date at 6 AM UTC
|
||||
"""
|
||||
if session_created_at:
|
||||
if session_created_at.tzinfo is None:
|
||||
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
|
||||
# Reference is session creation time at 6 AM that day
|
||||
return session_created_at.replace(
|
||||
hour=6, minute=0, second=0, microsecond=0
|
||||
)
|
||||
# Fallback for seed scripts: use today at 6 AM
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.replace(hour=6, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def adjust_date_for_demo(
|
||||
@@ -21,31 +40,14 @@ def adjust_date_for_demo(
|
||||
base_reference_date: datetime = BASE_REFERENCE_DATE
|
||||
) -> Optional[datetime]:
|
||||
"""
|
||||
Adjust a date from seed data to be relative to demo session creation time
|
||||
|
||||
This ensures that demo data appears fresh and relevant regardless of when
|
||||
the demo session is created. For example, expiration dates that were "15 days
|
||||
from seed date" will become "15 days from session creation date".
|
||||
|
||||
Args:
|
||||
original_date: The original date from the seed data (or None)
|
||||
session_created_at: When the demo session was created
|
||||
base_reference_date: The logical date when seed data was created (default: 2025-01-15)
|
||||
|
||||
Returns:
|
||||
Adjusted date relative to session creation, or None if original_date was None
|
||||
|
||||
Adjust a date from seed data to be relative to demo session creation time.
|
||||
|
||||
Example:
|
||||
# Seed data created on 2025-01-15
|
||||
# Stock expiration: 2025-01-30 (15 days from seed date)
|
||||
# Demo session created: 2025-10-16
|
||||
# Result: 2025-10-31 (15 days from session date)
|
||||
|
||||
>>> original = datetime(2025, 1, 30, 12, 0, tzinfo=timezone.utc)
|
||||
>>> session = datetime(2025, 10, 16, 10, 0, tzinfo=timezone.utc)
|
||||
>>> adjusted = adjust_date_for_demo(original, session)
|
||||
>>> print(adjusted)
|
||||
2025-10-31 10:00:00+00:00
|
||||
# Seed data created on 2025-12-13 06:00
|
||||
# Stock expiration: 2025-12-28 06:00 (15 days from seed date)
|
||||
# Demo session created: 2025-12-16 10:00
|
||||
# Base reference: 2025-12-16 06:00
|
||||
# Result: 2025-12-31 10:00 (15 days from session date)
|
||||
"""
|
||||
if original_date is None:
|
||||
return None
|
||||
@@ -65,148 +67,281 @@ def adjust_date_for_demo(
|
||||
return session_created_at + offset
|
||||
|
||||
|
||||
def adjust_date_relative_to_now(
|
||||
days_offset: int,
|
||||
hours_offset: int = 0,
|
||||
reference_time: Optional[datetime] = None
|
||||
def calculate_edge_case_times(session_created_at: datetime) -> dict:
|
||||
"""
|
||||
Calculate deterministic edge case times for demo sessions.
|
||||
|
||||
These times are designed to always create specific demo scenarios:
|
||||
- One late delivery (should have arrived hours ago)
|
||||
- One overdue production batch (should have started hours ago)
|
||||
- One in-progress batch (started recently)
|
||||
- One upcoming batch (starts soon)
|
||||
- One arriving-soon delivery (arrives in a few hours)
|
||||
|
||||
Returns:
|
||||
{
|
||||
'late_delivery_expected': session - 4h,
|
||||
'overdue_batch_planned_start': session - 2h,
|
||||
'in_progress_batch_actual_start': session - 1h45m,
|
||||
'upcoming_batch_planned_start': session + 1h30m,
|
||||
'arriving_soon_delivery_expected': session + 2h30m,
|
||||
'evening_batch_planned_start': today 17:00,
|
||||
'tomorrow_morning_planned_start': tomorrow 05:00
|
||||
}
|
||||
"""
|
||||
if session_created_at.tzinfo is None:
|
||||
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Calculate today at 6 AM (base reference)
|
||||
base_reference = get_base_reference_date(session_created_at)
|
||||
|
||||
# Calculate tomorrow at 6 AM
|
||||
tomorrow_base = base_reference + timedelta(days=1)
|
||||
|
||||
return {
|
||||
'late_delivery_expected': session_created_at - timedelta(hours=4),
|
||||
'overdue_batch_planned_start': session_created_at - timedelta(hours=2),
|
||||
'in_progress_batch_actual_start': session_created_at - timedelta(hours=1, minutes=45),
|
||||
'upcoming_batch_planned_start': session_created_at + timedelta(hours=1, minutes=30),
|
||||
'arriving_soon_delivery_expected': session_created_at + timedelta(hours=2, minutes=30),
|
||||
'evening_batch_planned_start': base_reference.replace(hour=17, minute=0, second=0, microsecond=0),
|
||||
'tomorrow_morning_planned_start': tomorrow_base.replace(hour=5, minute=0, second=0, microsecond=0)
|
||||
}
|
||||
|
||||
|
||||
def ensure_future_time(
|
||||
target_time: datetime,
|
||||
reference_time: datetime,
|
||||
min_hours_ahead: float = 1.0
|
||||
) -> datetime:
|
||||
"""
|
||||
Create a date relative to now (or a reference time) with specified offset
|
||||
|
||||
Useful for creating dates during cloning without needing to store seed dates.
|
||||
|
||||
Args:
|
||||
days_offset: Number of days to add (negative for past dates)
|
||||
hours_offset: Number of hours to add (negative for past times)
|
||||
reference_time: Reference datetime (defaults to now)
|
||||
|
||||
Returns:
|
||||
Calculated datetime
|
||||
|
||||
Example:
|
||||
>>> # Create a date 7 days in the future
|
||||
>>> future = adjust_date_relative_to_now(days_offset=7)
|
||||
>>> # Create a date 3 days in the past
|
||||
>>> past = adjust_date_relative_to_now(days_offset=-3)
|
||||
Ensure a target time is in the future relative to reference time.
|
||||
|
||||
If target_time is in the past or too close to reference_time,
|
||||
shift it forward by at least min_hours_ahead.
|
||||
"""
|
||||
if reference_time is None:
|
||||
reference_time = datetime.now(timezone.utc)
|
||||
elif reference_time.tzinfo is None:
|
||||
if target_time.tzinfo is None:
|
||||
target_time = target_time.replace(tzinfo=timezone.utc)
|
||||
if reference_time.tzinfo is None:
|
||||
reference_time = reference_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
return reference_time + timedelta(days=days_offset, hours=hours_offset)
|
||||
|
||||
time_diff = (target_time - reference_time).total_seconds() / 3600
|
||||
|
||||
if time_diff < min_hours_ahead:
|
||||
# Shift forward to ensure minimum hours ahead
|
||||
return reference_time + timedelta(hours=min_hours_ahead)
|
||||
|
||||
return target_time
|
||||
|
||||
|
||||
def calculate_expiration_date(
|
||||
received_date: datetime,
|
||||
shelf_life_days: int
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate expiration date based on received date and shelf life
|
||||
|
||||
Args:
|
||||
received_date: When the product was received
|
||||
shelf_life_days: Number of days until expiration
|
||||
|
||||
Returns:
|
||||
Calculated expiration datetime
|
||||
"""
|
||||
if received_date.tzinfo is None:
|
||||
received_date = received_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
return received_date + timedelta(days=shelf_life_days)
|
||||
|
||||
|
||||
def get_days_until_expiration(
|
||||
expiration_date: datetime,
|
||||
reference_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
"""
|
||||
Calculate number of days until expiration
|
||||
|
||||
Args:
|
||||
expiration_date: The expiration datetime
|
||||
reference_date: Reference datetime (defaults to now)
|
||||
|
||||
Returns:
|
||||
Number of days until expiration (negative if already expired)
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = datetime.now(timezone.utc)
|
||||
elif reference_date.tzinfo is None:
|
||||
reference_date = reference_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expiration_date.tzinfo is None:
|
||||
expiration_date = expiration_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
delta = expiration_date - reference_date
|
||||
return delta.days
|
||||
|
||||
|
||||
def is_expiring_soon(
|
||||
expiration_date: datetime,
|
||||
threshold_days: int = 3,
|
||||
reference_date: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a product is expiring soon
|
||||
|
||||
Args:
|
||||
expiration_date: The expiration datetime
|
||||
threshold_days: Number of days to consider as "soon" (default: 3)
|
||||
reference_date: Reference datetime (defaults to now)
|
||||
|
||||
Returns:
|
||||
True if expiring within threshold_days, False otherwise
|
||||
"""
|
||||
days_until = get_days_until_expiration(expiration_date, reference_date)
|
||||
return 0 <= days_until <= threshold_days
|
||||
|
||||
|
||||
def is_expired(
|
||||
expiration_date: datetime,
|
||||
reference_date: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a product is expired
|
||||
|
||||
Args:
|
||||
expiration_date: The expiration datetime
|
||||
reference_date: Reference datetime (defaults to now)
|
||||
|
||||
Returns:
|
||||
True if expired, False otherwise
|
||||
"""
|
||||
days_until = get_days_until_expiration(expiration_date, reference_date)
|
||||
return days_until < 0
|
||||
|
||||
|
||||
def adjust_multiple_dates(
|
||||
dates_dict: dict,
|
||||
def resolve_time_marker(
|
||||
time_marker: str,
|
||||
session_created_at: datetime,
|
||||
base_reference_date: datetime = BASE_REFERENCE_DATE
|
||||
) -> dict:
|
||||
) -> datetime:
|
||||
"""
|
||||
Adjust multiple dates in a dictionary
|
||||
|
||||
Resolve time markers like "BASE_TS + 1h30m" to actual datetimes.
|
||||
|
||||
Supports markers in the format:
|
||||
- "BASE_TS + XhYm" (e.g., "BASE_TS + 1h30m")
|
||||
- "BASE_TS - XhYm" (e.g., "BASE_TS - 2h")
|
||||
- "BASE_TS + Xd" (e.g., "BASE_TS + 2d")
|
||||
- "BASE_TS - Xd" (e.g., "BASE_TS - 1d")
|
||||
|
||||
Args:
|
||||
dates_dict: Dictionary with datetime values to adjust
|
||||
session_created_at: When the demo session was created
|
||||
base_reference_date: The logical date when seed data was created
|
||||
|
||||
time_marker: Time marker string to resolve
|
||||
session_created_at: Demo session creation time
|
||||
base_reference_date: Base reference date for calculation
|
||||
|
||||
Returns:
|
||||
Dictionary with adjusted dates (preserves None values)
|
||||
|
||||
Example:
|
||||
>>> dates = {
|
||||
... 'expiration_date': datetime(2025, 1, 30, tzinfo=timezone.utc),
|
||||
... 'received_date': datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
... 'optional_date': None
|
||||
... }
|
||||
>>> session = datetime(2025, 10, 16, tzinfo=timezone.utc)
|
||||
>>> adjusted = adjust_multiple_dates(dates, session)
|
||||
Resolved datetime adjusted for demo session
|
||||
|
||||
Raises:
|
||||
ValueError: If time_marker format is invalid
|
||||
|
||||
Examples:
|
||||
>>> resolve_time_marker("BASE_TS + 1h30m", session_time)
|
||||
>>> # Returns session_created_at + 1h30m
|
||||
>>> resolve_time_marker("BASE_TS - 2h", session_time)
|
||||
>>> # Returns session_created_at - 2h
|
||||
"""
|
||||
return {
|
||||
key: adjust_date_for_demo(value, session_created_at, base_reference_date)
|
||||
for key, value in dates_dict.items()
|
||||
}
|
||||
if not time_marker or not time_marker.startswith("BASE_TS"):
|
||||
raise ValueError(f"Invalid time marker format: {time_marker}")
|
||||
|
||||
# Extract the offset part
|
||||
offset_part = time_marker[7:].strip() # Remove "BASE_TS "
|
||||
|
||||
if not offset_part:
|
||||
# Just "BASE_TS" - return session_created_at
|
||||
return session_created_at
|
||||
|
||||
# Parse operator and value
|
||||
operator = offset_part[0]
|
||||
value_part = offset_part[1:].strip()
|
||||
|
||||
if operator not in ['+', '-']:
|
||||
raise ValueError(f"Invalid operator in time marker: {time_marker}")
|
||||
|
||||
# Parse time components
|
||||
days = 0
|
||||
hours = 0
|
||||
minutes = 0
|
||||
|
||||
if 'd' in value_part:
|
||||
# Handle days
|
||||
day_part, rest = value_part.split('d', 1)
|
||||
days = int(day_part)
|
||||
value_part = rest
|
||||
|
||||
if 'h' in value_part:
|
||||
# Handle hours
|
||||
hour_part, rest = value_part.split('h', 1)
|
||||
hours = int(hour_part)
|
||||
value_part = rest
|
||||
|
||||
if 'm' in value_part:
|
||||
# Handle minutes
|
||||
minute_part = value_part.split('m', 1)[0]
|
||||
minutes = int(minute_part)
|
||||
|
||||
# Calculate offset
|
||||
offset = timedelta(days=days, hours=hours, minutes=minutes)
|
||||
|
||||
if operator == '+':
|
||||
return session_created_at + offset
|
||||
else:
|
||||
return session_created_at - offset
|
||||
|
||||
|
||||
def shift_to_session_time(
|
||||
original_offset_days: int,
|
||||
original_hour: int,
|
||||
original_minute: int,
|
||||
session_created_at: datetime,
|
||||
base_reference: Optional[datetime] = None
|
||||
) -> datetime:
|
||||
"""
|
||||
Shift a time from seed data to demo session time with same-day preservation.
|
||||
|
||||
Ensures that:
|
||||
1. Items scheduled for "today" (offset_days=0) remain on the same day as session creation
|
||||
2. Future items stay in the future, past items stay in the past
|
||||
3. Times don't shift to invalid moments (e.g., past times for pending items)
|
||||
|
||||
Examples:
|
||||
# Session created at noon, item originally scheduled for morning
|
||||
>>> session = datetime(2025, 12, 12, 12, 0, tzinfo=timezone.utc)
|
||||
>>> result = shift_to_session_time(0, 6, 0, session) # Today at 06:00
|
||||
>>> # Returns today at 13:00 (shifted forward to stay in future)
|
||||
|
||||
# Session created at noon, item originally scheduled for evening
|
||||
>>> result = shift_to_session_time(0, 18, 0, session) # Today at 18:00
|
||||
>>> # Returns today at 18:00 (already in future)
|
||||
"""
|
||||
if session_created_at.tzinfo is None:
|
||||
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if base_reference is None:
|
||||
base_reference = get_base_reference_date(session_created_at)
|
||||
|
||||
# Calculate original time
|
||||
original_time = base_reference.replace(
|
||||
hour=original_hour,
|
||||
minute=original_minute,
|
||||
second=0,
|
||||
microsecond=0
|
||||
) + timedelta(days=original_offset_days)
|
||||
|
||||
# Calculate offset from base reference
|
||||
offset = original_time - base_reference
|
||||
|
||||
# Apply offset to session creation date
|
||||
new_time = session_created_at + offset
|
||||
|
||||
# Ensure the time is in the future for pending items
|
||||
if original_offset_days >= 0: # Future or today
|
||||
new_time = ensure_future_time(new_time, session_created_at, min_hours_ahead=0.5)
|
||||
|
||||
return new_time
|
||||
|
||||
|
||||
def get_working_hours_time(
|
||||
target_date: datetime,
|
||||
hours_from_start: float = 2.0
|
||||
) -> datetime:
|
||||
"""
|
||||
Get a time within working hours (8 AM - 6 PM) for a given date.
|
||||
|
||||
Args:
|
||||
target_date: The date to calculate time for
|
||||
hours_from_start: Hours from working day start (8 AM)
|
||||
|
||||
Returns:
|
||||
Datetime within working hours
|
||||
"""
|
||||
if target_date.tzinfo is None:
|
||||
target_date = target_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Working hours: 8 AM - 6 PM (10 hours)
|
||||
working_start = target_date.replace(hour=8, minute=0, second=0, microsecond=0)
|
||||
working_end = target_date.replace(hour=18, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Calculate time within working hours
|
||||
result_time = working_start + timedelta(hours=hours_from_start)
|
||||
|
||||
# Ensure it's within working hours
|
||||
if result_time > working_end:
|
||||
result_time = working_end
|
||||
|
||||
return result_time
|
||||
|
||||
|
||||
def get_next_workday(date: datetime) -> datetime:
|
||||
"""
|
||||
Get the next workday (Monday-Friday), skipping weekends.
|
||||
|
||||
If date is Friday, returns Monday.
|
||||
If date is Saturday, returns Monday.
|
||||
Otherwise returns next day.
|
||||
"""
|
||||
if date.tzinfo is None:
|
||||
date = date.replace(tzinfo=timezone.utc)
|
||||
|
||||
next_day = date + timedelta(days=1)
|
||||
|
||||
# Skip weekends
|
||||
while next_day.weekday() >= 5: # 5=Saturday, 6=Sunday
|
||||
next_day += timedelta(days=1)
|
||||
|
||||
return next_day
|
||||
|
||||
|
||||
def get_previous_workday(date: datetime) -> datetime:
|
||||
"""
|
||||
Get the previous workday (Monday-Friday), skipping weekends.
|
||||
|
||||
If date is Monday, returns Friday.
|
||||
If date is Sunday, returns Friday.
|
||||
Otherwise returns previous day.
|
||||
"""
|
||||
if date.tzinfo is None:
|
||||
date = date.replace(tzinfo=timezone.utc)
|
||||
|
||||
prev_day = date - timedelta(days=1)
|
||||
|
||||
# Skip weekends
|
||||
while prev_day.weekday() >= 5: # 5=Saturday, 6=Sunday
|
||||
prev_day -= timedelta(days=1)
|
||||
|
||||
return prev_day
|
||||
|
||||
|
||||
def format_iso_with_timezone(dt: datetime) -> str:
|
||||
"""
|
||||
Format datetime as ISO 8601 with timezone, replacing Z with +00:00 for compatibility.
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
iso_string = dt.isoformat()
|
||||
return iso_string.replace('+00:00', 'Z') if iso_string.endswith('+00:00') else iso_string
|
||||
Reference in New Issue
Block a user