| """Date parser and period helpers. | |
| Multi-format support: YYYY-MM-DD, YYYY.MM.DD, YYYY/MM/DD, DD.MM.YYYY, DD/MM/YYYY. | |
| """ | |
| from __future__ import annotations | |
| from datetime import datetime | |
| from utils.numbers import is_null_alias | |
| _FORMATS = ( | |
| "%Y-%m-%d", | |
| "%Y.%m.%d", | |
| "%Y.%m.%d.", | |
| "%Y/%m/%d", | |
| "%d.%m.%Y", | |
| "%d.%m.%Y.", | |
| "%d/%m/%Y", | |
| ) | |
| def parse_date_safe(value) -> datetime | None: | |
| """Parse a date string in multiple formats. Returns None on failure.""" | |
| if value is None: | |
| return None | |
| if isinstance(value, datetime): | |
| return value | |
| if not isinstance(value, str): | |
| return None | |
| s = value.strip() | |
| if not s or is_null_alias(s): | |
| return None | |
| # Take the first 10 chars (YYYY-MM-DD-like prefix, possibly trailing .) | |
| s = s[:10] if len(s) >= 10 else s | |
| for fmt in _FORMATS: | |
| try: | |
| return datetime.strptime(s, fmt) | |
| except ValueError: | |
| continue | |
| return None | |
| def is_expiring_soon(end_date_str: str | None, months: int = 12) -> bool: | |
| """Check whether the given date expires within the next ``months`` months.""" | |
| end = parse_date_safe(end_date_str) | |
| if not end: | |
| return False | |
| now = datetime.now() | |
| months_remaining = (end.year - now.year) * 12 + (end.month - now.month) | |
| return 0 <= months_remaining <= months | |