demo seed change

This commit is contained in:
Urtzi Alfaro
2025-12-13 23:57:54 +01:00
parent f3688dfb04
commit ff830a3415
299 changed files with 20328 additions and 19485 deletions

465
shared/utils/demo_dates.py Normal file → Executable file
View 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