Files
TimeTracker/app/utils/datetime_utils.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

324 lines
7.9 KiB
Python

"""
Enhanced date and time utilities.
"""
from datetime import date, datetime, timedelta
from typing import Optional, Tuple
from dateutil.relativedelta import relativedelta
from app.utils.timezone import from_app_timezone, now_in_app_timezone, to_app_timezone
def parse_date(date_str: str, format: Optional[str] = None) -> Optional[date]:
"""
Parse a date string to a date object.
Args:
date_str: Date string
format: Optional format string (defaults to ISO format)
Returns:
date object or None if parsing fails
"""
if not date_str:
return None
try:
if format:
return datetime.strptime(date_str, format).date()
else:
# Try ISO format first
try:
return datetime.fromisoformat(date_str).date()
except ValueError:
# Try common formats
for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d"]:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
return None
except Exception:
return None
def parse_datetime(datetime_str: str, format: Optional[str] = None) -> Optional[datetime]:
"""
Parse a datetime string to a datetime object.
Args:
datetime_str: Datetime string
format: Optional format string (defaults to ISO format)
Returns:
datetime object or None if parsing fails
"""
if not datetime_str:
return None
try:
if format:
return datetime.strptime(datetime_str, format)
else:
# Try ISO format first
try:
return datetime.fromisoformat(datetime_str.replace("Z", "+00:00"))
except ValueError:
# Try common formats
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%d/%m/%Y %H:%M:%S", "%m/%d/%Y %H:%M:%S"]:
try:
return datetime.strptime(datetime_str, fmt)
except ValueError:
continue
return None
except Exception:
return None
def format_date(d: date, format: str = "%Y-%m-%d") -> str:
"""
Format a date object to a string.
Args:
d: date object
format: Format string
Returns:
Formatted date string
"""
if not d:
return ""
return d.strftime(format)
def format_datetime(dt: datetime, format: str = "%Y-%m-%d %H:%M:%S") -> str:
"""
Format a datetime object to a string.
Args:
dt: datetime object
format: Format string
Returns:
Formatted datetime string
"""
if not dt:
return ""
return dt.strftime(format)
def get_date_range(
period: str = "month", start_date: Optional[date] = None, end_date: Optional[date] = None
) -> Tuple[date, date]:
"""
Get a date range for common periods.
Args:
period: Period type ('today', 'week', 'month', 'quarter', 'year', 'custom')
start_date: Custom start date (for 'custom' period)
end_date: Custom end date (for 'custom' period)
Returns:
tuple of (start_date, end_date)
"""
today = date.today()
if period == "today":
return today, today
elif period == "week":
# Start of week (Monday)
start = today - timedelta(days=today.weekday())
return start, today
elif period == "month":
start = today.replace(day=1)
return start, today
elif period == "quarter":
quarter = (today.month - 1) // 3
start = date(today.year, quarter * 3 + 1, 1)
return start, today
elif period == "year":
start = date(today.year, 1, 1)
return start, today
elif period == "custom":
if start_date and end_date:
return start_date, end_date
return today, today
else:
return today, today
def get_previous_period(period: str = "month", reference_date: Optional[date] = None) -> Tuple[date, date]:
"""
Get the previous period date range.
Args:
period: Period type ('week', 'month', 'quarter', 'year')
reference_date: Reference date (defaults to today)
Returns:
tuple of (start_date, end_date)
"""
ref = reference_date or date.today()
if period == "week":
start = ref - timedelta(days=ref.weekday() + 7)
end = start + timedelta(days=6)
return start, end
elif period == "month":
first_day = ref.replace(day=1)
start = first_day - relativedelta(months=1)
end = first_day - timedelta(days=1)
return start, end
elif period == "quarter":
quarter = (ref.month - 1) // 3
start = date(ref.year, quarter * 3 + 1, 1)
if quarter == 0:
start = date(ref.year - 1, 10, 1)
end = date(ref.year - 1, 12, 31)
else:
end = date(ref.year, quarter * 3, 1) - timedelta(days=1)
return start, end
elif period == "year":
start = date(ref.year - 1, 1, 1)
end = date(ref.year - 1, 12, 31)
return start, end
else:
return ref, ref
def calculate_duration(start: datetime, end: datetime) -> timedelta:
"""
Calculate duration between two datetimes.
Args:
start: Start datetime
end: End datetime
Returns:
timedelta object
"""
if not start or not end:
return timedelta(0)
return end - start
def format_duration(seconds: float, format: str = "hours") -> str:
"""
Format duration in seconds to a human-readable string.
Args:
seconds: Duration in seconds
format: Format type ('hours', 'detailed', 'short')
Returns:
Formatted duration string
"""
if format == "hours":
hours = seconds / 3600
return f"{hours:.2f}h"
elif format == "detailed":
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
if secs > 0 or not parts:
parts.append(f"{secs}s")
return " ".join(parts)
elif format == "short":
hours = seconds / 3600
if hours < 1:
minutes = seconds / 60
return f"{int(minutes)}m"
return f"{hours:.1f}h"
else:
return f"{seconds}s"
def is_business_day(d: date) -> bool:
"""
Check if a date is a business day (Monday-Friday).
Args:
d: date object
Returns:
True if business day, False otherwise
"""
return d.weekday() < 5 # Monday = 0, Friday = 4
def add_business_days(start_date: date, days: int) -> date:
"""
Add business days to a date.
Args:
start_date: Start date
days: Number of business days to add
Returns:
Result date
"""
current = start_date
added = 0
while added < days:
current += timedelta(days=1)
if is_business_day(current):
added += 1
return current
def get_week_start_end(d: date) -> Tuple[date, date]:
"""
Get the start (Monday) and end (Sunday) of the week for a date.
Args:
d: date object
Returns:
tuple of (week_start, week_end)
"""
week_start = d - timedelta(days=d.weekday())
week_end = week_start + timedelta(days=6)
return week_start, week_end
def get_month_start_end(d: date) -> Tuple[date, date]:
"""
Get the start and end of the month for a date.
Args:
d: date object
Returns:
tuple of (month_start, month_end)
"""
month_start = d.replace(day=1)
if d.month == 12:
month_end = date(d.year + 1, 1, 1) - timedelta(days=1)
else:
month_end = date(d.year, d.month + 1, 1) - timedelta(days=1)
return month_start, month_end