98 lines
3.1 KiB
Python
98 lines
3.1 KiB
Python
"""Unified DateService — single source of truth for date handling across PM.
|
|
|
|
Design invariants:
|
|
- Internally: Gregorian datetime.date (UTC-safe, no timezone shift).
|
|
- API input/output: ISO 8601 (YYYY-MM-DD) or Jalali (YYYY/MM/DD) when convenient.
|
|
- UI: Jalali (Shamsi) display with English/Persian digit support.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
from typing import Any
|
|
|
|
from app.utils.jalali import (
|
|
days_in_month,
|
|
gregorian_to_jalali,
|
|
is_leap_jalali,
|
|
jalali_to_gregorian,
|
|
parse_flexible_date,
|
|
to_jalali_str,
|
|
)
|
|
|
|
|
|
class DateService:
|
|
@staticmethod
|
|
def parse(value: Any) -> _dt.date | None:
|
|
"""Parse ISO, Jalali, or datetime into a standard Gregorian date."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
|
|
return value
|
|
if isinstance(value, _dt.datetime):
|
|
return value.date()
|
|
return parse_flexible_date(str(value))
|
|
|
|
@staticmethod
|
|
def to_iso(value: _dt.date | None) -> str | None:
|
|
"""Format date as ISO 8601 string (YYYY-MM-DD)."""
|
|
if value is None:
|
|
return None
|
|
return value.isoformat()
|
|
|
|
@staticmethod
|
|
def today() -> _dt.date:
|
|
"""Return today's Gregorian date."""
|
|
return _dt.date.today()
|
|
|
|
@staticmethod
|
|
def to_jalali(value: _dt.date | None, sep: str = "/") -> str | None:
|
|
"""Format date as Jalali string (YYYY/MM/DD)."""
|
|
return to_jalali_str(value, sep=sep)
|
|
|
|
@staticmethod
|
|
def calculate_duration(start_date: _dt.date | None, end_date: _dt.date | None) -> int:
|
|
"""Duration in days = the GAP between the two dates (end date not counted).
|
|
|
|
This is the convention the UI uses, so the number a user types is the
|
|
number displayed back to them:
|
|
|
|
16 → 20 Mordad = 4 days
|
|
start == end = 0 days (same day, no span)
|
|
|
|
Returns 0 when either date is missing or the range is inverted.
|
|
"""
|
|
if not start_date or not end_date:
|
|
return 0
|
|
diff = (end_date - start_date).days
|
|
return max(0, diff)
|
|
|
|
@staticmethod
|
|
def compute_end_date(start_date: _dt.date | None, duration_days: int) -> _dt.date | None:
|
|
"""End date for a start date and a gap-based duration.
|
|
|
|
Exclusive, the exact inverse of calculate_duration:
|
|
|
|
compute_end_date(d, calculate_duration(d, e)) == e
|
|
calculate_duration(d, compute_end_date(d, n)) == n
|
|
|
|
A duration of 0 (or 1) means the task starts and ends the same day.
|
|
"""
|
|
if not start_date:
|
|
return None
|
|
if duration_days <= 0:
|
|
return start_date
|
|
return start_date + _dt.timedelta(days=duration_days)
|
|
|
|
@staticmethod
|
|
def today() -> _dt.date:
|
|
"""Return current Gregorian date."""
|
|
return _dt.date.today()
|
|
|
|
@staticmethod
|
|
def today_jalali(sep: str = "/") -> str:
|
|
"""Return today as Jalali string."""
|
|
return to_jalali_str(_dt.date.today(), sep=sep) or ""
|
|
|
|
|
|
date_service = DateService()
|