675 lines
26 KiB
Python
675 lines
26 KiB
Python
"""
|
|||
|
|
Comprehensive date validation and parsing utilities
|
||
|
|
Provides robust date format validation and constraint checking
|
||
|
|
"""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
from datetime import datetime, date, timedelta
|
||
|
|
from typing import Optional, List, Tuple, Union, Dict, Any
|
||
|
|
|
||
|
|
try:
|
||
|
|
from .error_handling import ValidationError
|
||
|
|
except ImportError:
|
||
|
|
from error_handling import ValidationError
|
||
|
|
|
||
|
|
|
||
|
|
class DateValidator:
|
||
|
|
"""Comprehensive date validation with multiple format support"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
# Supported date formats with their regex patterns and strptime formats
|
||
|
|
self.date_formats = {
|
||
|
|
'iso_date': {
|
||
|
|
'pattern': r'^\d{4}-\d{2}-\d{2}$',
|
||
|
|
'strptime': '%Y-%m-%d',
|
||
|
|
'description': 'ISO date format (YYYY-MM-DD)'
|
||
|
|
},
|
||
|
|
'iso_datetime': {
|
||
|
|
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$',
|
||
|
|
'strptime': '%Y-%m-%dT%H:%M:%S',
|
||
|
|
'description': 'ISO datetime format (YYYY-MM-DDTHH:MM:SS)'
|
||
|
|
},
|
||
|
|
'iso_datetime_ms': {
|
||
|
|
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$',
|
||
|
|
'strptime': '%Y-%m-%dT%H:%M:%S.%f',
|
||
|
|
'description': 'ISO datetime with milliseconds'
|
||
|
|
},
|
||
|
|
'iso_datetime_tz': {
|
||
|
|
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$',
|
||
|
|
'strptime': '%Y-%m-%dT%H:%M:%S%z',
|
||
|
|
'description': 'ISO datetime with timezone'
|
||
|
|
},
|
||
|
|
'us_date': {
|
||
|
|
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
|
||
|
|
'strptime': '%m/%d/%Y',
|
||
|
|
'description': 'US date format (MM/DD/YYYY)'
|
||
|
|
},
|
||
|
|
'eu_date': {
|
||
|
|
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
|
||
|
|
'strptime': '%d/%m/%Y',
|
||
|
|
'description': 'European date format (DD/MM/YYYY)'
|
||
|
|
},
|
||
|
|
'dot_date': {
|
||
|
|
'pattern': r'^\d{1,2}\.\d{1,2}\.\d{4}$',
|
||
|
|
'strptime': '%d.%m.%Y',
|
||
|
|
'description': 'Dot-separated date (DD.MM.YYYY)'
|
||
|
|
},
|
||
|
|
'compact_date': {
|
||
|
|
'pattern': r'^\d{8}$',
|
||
|
|
'strptime': '%Y%m%d',
|
||
|
|
'description': 'Compact date format (YYYYMMDD)'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Default constraints
|
||
|
|
self.default_constraints = {
|
||
|
|
'min_year': 1900,
|
||
|
|
'max_year': 2100,
|
||
|
|
'allow_future': True,
|
||
|
|
'max_future_days': 365 * 10, # 10 years
|
||
|
|
'allow_past': True,
|
||
|
|
'max_past_days': 365 * 50, # 50 years
|
||
|
|
}
|
||
|
|
|
||
|
|
def validate_date_string(
|
||
|
|
self,
|
||
|
|
date_string: str,
|
||
|
|
field_name: str = "date",
|
||
|
|
allowed_formats: Optional[List[str]] = None,
|
||
|
|
constraints: Optional[Dict[str, Any]] = None,
|
||
|
|
auto_detect_format: bool = True
|
||
|
|
) -> Tuple[datetime, str]:
|
||
|
|
"""
|
||
|
|
Validate a date string with comprehensive format and constraint checking
|
||
|
|
|
||
|
|
Args:
|
||
|
|
date_string: Date string to validate
|
||
|
|
field_name: Name of the field for error messages
|
||
|
|
allowed_formats: List of allowed format names (None = all formats)
|
||
|
|
constraints: Date constraints (None = use defaults)
|
||
|
|
auto_detect_format: Whether to auto-detect format
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (parsed_datetime, detected_format)
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
if not date_string or not isinstance(date_string, str):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} must be a non-empty string",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="non_empty_string"
|
||
|
|
)
|
||
|
|
|
||
|
|
date_string = date_string.strip()
|
||
|
|
if not date_string:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} cannot be empty",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="non_empty"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Determine which formats to try
|
||
|
|
formats_to_try = allowed_formats or list(self.date_formats.keys())
|
||
|
|
|
||
|
|
parsed_date = None
|
||
|
|
detected_format = None
|
||
|
|
parsing_errors = []
|
||
|
|
|
||
|
|
# Try each format
|
||
|
|
for format_name in formats_to_try:
|
||
|
|
if format_name not in self.date_formats:
|
||
|
|
self.logger.warning(f"Unknown date format: {format_name}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
format_info = self.date_formats[format_name]
|
||
|
|
|
||
|
|
# Check regex pattern first (faster than strptime)
|
||
|
|
if not re.match(format_info['pattern'], date_string):
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Try to parse with strptime
|
||
|
|
try:
|
||
|
|
parsed_date = datetime.strptime(date_string, format_info['strptime'])
|
||
|
|
detected_format = format_name
|
||
|
|
break
|
||
|
|
except ValueError as e:
|
||
|
|
parsing_errors.append(f"{format_name}: {str(e)}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
# If no format worked, provide helpful error
|
||
|
|
if parsed_date is None:
|
||
|
|
if auto_detect_format:
|
||
|
|
error_msg = f"{field_name} format not recognized. Tried formats: {', '.join(formats_to_try)}"
|
||
|
|
if parsing_errors:
|
||
|
|
error_msg += f". Errors: {'; '.join(parsing_errors[:3])}" # Limit error details
|
||
|
|
else:
|
||
|
|
format_descriptions = [f'{fmt} ({self.date_formats[fmt]["description"]})' for fmt in formats_to_try if fmt in self.date_formats]
|
||
|
|
error_msg = f"{field_name} must match one of these formats: {', '.join(format_descriptions)}"
|
||
|
|
|
||
|
|
raise ValidationError(
|
||
|
|
message=error_msg,
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="date_format"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Apply constraints
|
||
|
|
self._validate_date_constraints(parsed_date, field_name, constraints or self.default_constraints)
|
||
|
|
|
||
|
|
return parsed_date, detected_format
|
||
|
|
|
||
|
|
def validate_date_range(
|
||
|
|
self,
|
||
|
|
start_date: Union[str, datetime, date],
|
||
|
|
end_date: Union[str, datetime, date],
|
||
|
|
field_name_start: str = "start_date",
|
||
|
|
field_name_end: str = "end_date",
|
||
|
|
allow_same_date: bool = True,
|
||
|
|
max_range_days: Optional[int] = None
|
||
|
|
) -> Tuple[datetime, datetime]:
|
||
|
|
"""
|
||
|
|
Validate a date range with logical constraints
|
||
|
|
|
||
|
|
Args:
|
||
|
|
start_date: Start date (string or datetime)
|
||
|
|
end_date: End date (string or datetime)
|
||
|
|
field_name_start: Name of start date field
|
||
|
|
field_name_end: Name of end date field
|
||
|
|
allow_same_date: Whether start and end can be the same
|
||
|
|
max_range_days: Maximum allowed range in days
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (start_datetime, end_datetime)
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
# Parse start date
|
||
|
|
if isinstance(start_date, str):
|
||
|
|
start_dt, _ = self.validate_date_string(start_date, field_name_start)
|
||
|
|
elif isinstance(start_date, datetime):
|
||
|
|
start_dt = start_date
|
||
|
|
elif isinstance(start_date, date):
|
||
|
|
start_dt = datetime.combine(start_date, datetime.min.time())
|
||
|
|
else:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name_start} must be a string, date, or datetime",
|
||
|
|
field_name=field_name_start,
|
||
|
|
validation_rule="date_type"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Parse end date
|
||
|
|
if isinstance(end_date, str):
|
||
|
|
end_dt, _ = self.validate_date_string(end_date, field_name_end)
|
||
|
|
elif isinstance(end_date, datetime):
|
||
|
|
end_dt = end_date
|
||
|
|
elif isinstance(end_date, date):
|
||
|
|
end_dt = datetime.combine(end_date, datetime.min.time())
|
||
|
|
else:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name_end} must be a string, date, or datetime",
|
||
|
|
field_name=field_name_end,
|
||
|
|
validation_rule="date_type"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Validate range logic
|
||
|
|
if start_dt > end_dt:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name_start} cannot be after {field_name_end}",
|
||
|
|
field_name=field_name_start,
|
||
|
|
validation_rule="date_range_order"
|
||
|
|
)
|
||
|
|
|
||
|
|
if not allow_same_date and start_dt.date() == end_dt.date():
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name_start} and {field_name_end} cannot be the same date",
|
||
|
|
field_name=field_name_start,
|
||
|
|
validation_rule="date_range_same"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check maximum range
|
||
|
|
if max_range_days is not None:
|
||
|
|
range_days = (end_dt - start_dt).days
|
||
|
|
if range_days > max_range_days:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"Date range cannot exceed {max_range_days} days (current: {range_days} days)",
|
||
|
|
field_name=field_name_start,
|
||
|
|
validation_rule="date_range_too_large"
|
||
|
|
)
|
||
|
|
|
||
|
|
return start_dt, end_dt
|
||
|
|
|
||
|
|
def validate_journal_date(
|
||
|
|
self,
|
||
|
|
date_string: str,
|
||
|
|
field_name: str = "journal_date"
|
||
|
|
) -> datetime:
|
||
|
|
"""
|
||
|
|
Validate a date for journal entries with specific constraints
|
||
|
|
|
||
|
|
Args:
|
||
|
|
date_string: Date string to validate
|
||
|
|
field_name: Name of the field for error messages
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Parsed datetime
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
# Journal dates should typically be in ISO format and not too far in the future
|
||
|
|
journal_constraints = {
|
||
|
|
'min_year': 2000, # Journals unlikely before 2000
|
||
|
|
'max_year': datetime.now().year + 1, # Allow up to next year
|
||
|
|
'allow_future': True,
|
||
|
|
'max_future_days': 30, # Allow up to 30 days in future
|
||
|
|
'allow_past': True,
|
||
|
|
'max_past_days': 365 * 20, # Allow up to 20 years in past
|
||
|
|
}
|
||
|
|
|
||
|
|
# Prefer ISO date format for journals
|
||
|
|
preferred_formats = ['iso_date', 'compact_date', 'us_date', 'eu_date']
|
||
|
|
|
||
|
|
parsed_date, detected_format = self.validate_date_string(
|
||
|
|
date_string,
|
||
|
|
field_name,
|
||
|
|
allowed_formats=preferred_formats,
|
||
|
|
constraints=journal_constraints
|
||
|
|
)
|
||
|
|
|
||
|
|
# Additional journal-specific validations
|
||
|
|
today = datetime.now().date()
|
||
|
|
date_only = parsed_date.date()
|
||
|
|
|
||
|
|
# Warn about future dates (but don't reject)
|
||
|
|
if date_only > today:
|
||
|
|
days_future = (date_only - today).days
|
||
|
|
if days_future > 7: # More than a week in future
|
||
|
|
self.logger.warning(f"Journal date is {days_future} days in the future: {date_string}")
|
||
|
|
|
||
|
|
# Check for weekend dates (informational)
|
||
|
|
if date_only.weekday() >= 5: # Saturday = 5, Sunday = 6
|
||
|
|
self.logger.debug(f"Journal date is on weekend: {date_string}")
|
||
|
|
|
||
|
|
return parsed_date
|
||
|
|
|
||
|
|
def validate_obsidian_date_format(
|
||
|
|
self,
|
||
|
|
date_string: str,
|
||
|
|
obsidian_format: str = "YYYY-MM-DD",
|
||
|
|
field_name: str = "date"
|
||
|
|
) -> datetime:
|
||
|
|
"""
|
||
|
|
Validate date against Obsidian's date format configuration
|
||
|
|
|
||
|
|
Args:
|
||
|
|
date_string: Date string to validate
|
||
|
|
obsidian_format: Obsidian date format (e.g., "YYYY-MM-DD", "DD-MM-YYYY")
|
||
|
|
field_name: Name of the field for error messages
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Parsed datetime
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
# Convert Obsidian format to Python strptime format
|
||
|
|
python_format = self._obsidian_to_python_format(obsidian_format)
|
||
|
|
|
||
|
|
if not date_string or not isinstance(date_string, str):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} must be a non-empty string",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="non_empty_string"
|
||
|
|
)
|
||
|
|
|
||
|
|
date_string = date_string.strip()
|
||
|
|
|
||
|
|
try:
|
||
|
|
parsed_date = datetime.strptime(date_string, python_format)
|
||
|
|
except ValueError as e:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} must match Obsidian format '{obsidian_format}': {str(e)}",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="obsidian_date_format"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Apply basic constraints
|
||
|
|
self._validate_date_constraints(parsed_date, field_name, self.default_constraints)
|
||
|
|
|
||
|
|
return parsed_date
|
||
|
|
|
||
|
|
def get_supported_formats(self) -> Dict[str, str]:
|
||
|
|
"""
|
||
|
|
Get list of supported date formats
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Dictionary of format_name -> description
|
||
|
|
"""
|
||
|
|
return {name: info['description'] for name, info in self.date_formats.items()}
|
||
|
|
|
||
|
|
def suggest_date_format(self, date_string: str) -> List[Tuple[str, str]]:
|
||
|
|
"""
|
||
|
|
Suggest possible date formats for a given string
|
||
|
|
|
||
|
|
Args:
|
||
|
|
date_string: Date string to analyze
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
List of (format_name, description) tuples for matching patterns
|
||
|
|
"""
|
||
|
|
suggestions = []
|
||
|
|
|
||
|
|
for format_name, format_info in self.date_formats.items():
|
||
|
|
if re.match(format_info['pattern'], date_string.strip()):
|
||
|
|
suggestions.append((format_name, format_info['description']))
|
||
|
|
|
||
|
|
return suggestions
|
||
|
|
|
||
|
|
def _validate_date_constraints(
|
||
|
|
self,
|
||
|
|
date_obj: datetime,
|
||
|
|
field_name: str,
|
||
|
|
constraints: Dict[str, Any]
|
||
|
|
) -> None:
|
||
|
|
"""Validate date against constraints"""
|
||
|
|
# Year constraints
|
||
|
|
if date_obj.year < constraints.get('min_year', 1900):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} year cannot be before {constraints['min_year']}",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="min_year"
|
||
|
|
)
|
||
|
|
|
||
|
|
if date_obj.year > constraints.get('max_year', 2100):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} year cannot be after {constraints['max_year']}",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="max_year"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Future date constraints
|
||
|
|
today = datetime.now()
|
||
|
|
if date_obj > today:
|
||
|
|
if not constraints.get('allow_future', True):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} cannot be in the future",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="no_future_dates"
|
||
|
|
)
|
||
|
|
|
||
|
|
days_future = (date_obj - today).days
|
||
|
|
max_future = constraints.get('max_future_days', 365 * 10)
|
||
|
|
if days_future > max_future:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} cannot be more than {max_future} days in the future",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="max_future_days"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Past date constraints
|
||
|
|
if date_obj < today:
|
||
|
|
if not constraints.get('allow_past', True):
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} cannot be in the past",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="no_past_dates"
|
||
|
|
)
|
||
|
|
|
||
|
|
days_past = (today - date_obj).days
|
||
|
|
max_past = constraints.get('max_past_days', 365 * 50)
|
||
|
|
if days_past > max_past:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} cannot be more than {max_past} days in the past",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="max_past_days"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _obsidian_to_python_format(self, obsidian_format: str) -> str:
|
||
|
|
"""Convert Obsidian date format to Python strptime format"""
|
||
|
|
# Common Obsidian format mappings
|
||
|
|
mappings = {
|
||
|
|
'YYYY': '%Y', # 4-digit year
|
||
|
|
'YY': '%y', # 2-digit year
|
||
|
|
'MM': '%m', # Month with zero padding
|
||
|
|
'M': '%m', # Month without zero padding (Python doesn't distinguish)
|
||
|
|
'DD': '%d', # Day with zero padding
|
||
|
|
'D': '%d', # Day without zero padding (Python doesn't distinguish)
|
||
|
|
'HH': '%H', # Hour (24-hour)
|
||
|
|
'hh': '%I', # Hour (12-hour)
|
||
|
|
'mm': '%M', # Minute
|
||
|
|
'ss': '%S', # Second
|
||
|
|
'A': '%p', # AM/PM
|
||
|
|
}
|
||
|
|
|
||
|
|
python_format = obsidian_format
|
||
|
|
|
||
|
|
# Replace in order of length (longest first to avoid partial replacements)
|
||
|
|
for obsidian_token in sorted(mappings.keys(), key=len, reverse=True):
|
||
|
|
python_format = python_format.replace(obsidian_token, mappings[obsidian_token])
|
||
|
|
|
||
|
|
return python_format
|
||
|
|
|
||
|
|
|
||
|
|
class DateRangeValidator:
|
||
|
|
"""Specialized validator for date ranges and periods"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.logger = logging.getLogger(__name__)
|
||
|
|
self.date_validator = DateValidator()
|
||
|
|
|
||
|
|
def validate_journal_date_range(
|
||
|
|
self,
|
||
|
|
start_date: Optional[str] = None,
|
||
|
|
end_date: Optional[str] = None,
|
||
|
|
period: Optional[str] = None
|
||
|
|
) -> Tuple[datetime, datetime]:
|
||
|
|
"""
|
||
|
|
Validate a date range for journal operations
|
||
|
|
|
||
|
|
Args:
|
||
|
|
start_date: Start date string (optional)
|
||
|
|
end_date: End date string (optional)
|
||
|
|
period: Period specification (e.g., "week", "month", "year")
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (start_datetime, end_datetime)
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
today = datetime.now()
|
||
|
|
|
||
|
|
# Handle period-based ranges
|
||
|
|
if period:
|
||
|
|
return self._get_period_range(period, today)
|
||
|
|
|
||
|
|
# Handle explicit date ranges
|
||
|
|
if start_date and end_date:
|
||
|
|
return self.date_validator.validate_date_range(
|
||
|
|
start_date, end_date,
|
||
|
|
max_range_days=365 * 2 # Max 2 years for journal ranges
|
||
|
|
)
|
||
|
|
|
||
|
|
# Handle single date (default to that day)
|
||
|
|
if start_date:
|
||
|
|
start_dt = self.date_validator.validate_journal_date(start_date)
|
||
|
|
end_dt = start_dt.replace(hour=23, minute=59, second=59)
|
||
|
|
return start_dt, end_dt
|
||
|
|
|
||
|
|
if end_date:
|
||
|
|
end_dt = self.date_validator.validate_journal_date(end_date)
|
||
|
|
start_dt = end_dt.replace(hour=0, minute=0, second=0)
|
||
|
|
return start_dt, end_dt
|
||
|
|
|
||
|
|
# Default to today
|
||
|
|
start_dt = today.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
end_dt = today.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
|
|
return start_dt, end_dt
|
||
|
|
|
||
|
|
def _get_period_range(self, period: str, reference_date: datetime) -> Tuple[datetime, datetime]:
|
||
|
|
"""Get date range for a period specification"""
|
||
|
|
period = period.lower().strip()
|
||
|
|
|
||
|
|
if period in ['today', 'day']:
|
||
|
|
start = reference_date.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
|
|
|
||
|
|
elif period in ['yesterday']:
|
||
|
|
yesterday = reference_date - timedelta(days=1)
|
||
|
|
start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
|
|
|
||
|
|
elif period in ['week', 'this_week']:
|
||
|
|
# Start of week (Monday)
|
||
|
|
days_since_monday = reference_date.weekday()
|
||
|
|
start = (reference_date - timedelta(days=days_since_monday)).replace(
|
||
|
|
hour=0, minute=0, second=0, microsecond=0
|
||
|
|
)
|
||
|
|
end = (start + timedelta(days=6)).replace(
|
||
|
|
hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
elif period in ['last_week']:
|
||
|
|
# Previous week
|
||
|
|
days_since_monday = reference_date.weekday()
|
||
|
|
this_week_start = reference_date - timedelta(days=days_since_monday)
|
||
|
|
start = (this_week_start - timedelta(days=7)).replace(
|
||
|
|
hour=0, minute=0, second=0, microsecond=0
|
||
|
|
)
|
||
|
|
end = (start + timedelta(days=6)).replace(
|
||
|
|
hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
elif period in ['month', 'this_month']:
|
||
|
|
# Start of month
|
||
|
|
start = reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
# End of month
|
||
|
|
if reference_date.month == 12:
|
||
|
|
next_month = reference_date.replace(year=reference_date.year + 1, month=1, day=1)
|
||
|
|
else:
|
||
|
|
next_month = reference_date.replace(month=reference_date.month + 1, day=1)
|
||
|
|
end = (next_month - timedelta(days=1)).replace(
|
||
|
|
hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
elif period in ['last_month']:
|
||
|
|
# Previous month
|
||
|
|
if reference_date.month == 1:
|
||
|
|
last_month = reference_date.replace(year=reference_date.year - 1, month=12, day=1)
|
||
|
|
else:
|
||
|
|
last_month = reference_date.replace(month=reference_date.month - 1, day=1)
|
||
|
|
|
||
|
|
start = last_month.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
|
|
||
|
|
# End of last month
|
||
|
|
this_month_start = reference_date.replace(day=1)
|
||
|
|
end = (this_month_start - timedelta(days=1)).replace(
|
||
|
|
hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
elif period in ['year', 'this_year']:
|
||
|
|
start = reference_date.replace(
|
||
|
|
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||
|
|
)
|
||
|
|
end = reference_date.replace(
|
||
|
|
month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
elif period in ['last_year']:
|
||
|
|
last_year = reference_date.year - 1
|
||
|
|
start = reference_date.replace(
|
||
|
|
year=last_year, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||
|
|
)
|
||
|
|
end = reference_date.replace(
|
||
|
|
year=last_year, month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
|
||
|
|
)
|
||
|
|
|
||
|
|
else:
|
||
|
|
# Try to parse as number of days
|
||
|
|
try:
|
||
|
|
if period.endswith('d') or period.endswith('days'):
|
||
|
|
days = int(period.rstrip('days').rstrip('d'))
|
||
|
|
start = (reference_date - timedelta(days=days)).replace(
|
||
|
|
hour=0, minute=0, second=0, microsecond=0
|
||
|
|
)
|
||
|
|
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||
|
|
else:
|
||
|
|
raise ValueError("Invalid period format")
|
||
|
|
except ValueError:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"Unknown period specification: {period}. "
|
||
|
|
f"Supported: today, yesterday, week, month, year, last_week, last_month, last_year, or Nd (N days)",
|
||
|
|
field_name="period",
|
||
|
|
validation_rule="unknown_period"
|
||
|
|
)
|
||
|
|
|
||
|
|
return start, end
|
||
|
|
|
||
|
|
|
||
|
|
# Global validator instances
|
||
|
|
date_validator = DateValidator()
|
||
|
|
date_range_validator = DateRangeValidator()
|
||
|
|
|
||
|
|
|
||
|
|
def validate_date_input(
|
||
|
|
date_input: Union[str, datetime, date, None],
|
||
|
|
field_name: str = "date",
|
||
|
|
required: bool = True,
|
||
|
|
format_hint: Optional[str] = None
|
||
|
|
) -> Optional[datetime]:
|
||
|
|
"""
|
||
|
|
Convenience function for validating date inputs
|
||
|
|
|
||
|
|
Args:
|
||
|
|
date_input: Date input to validate
|
||
|
|
field_name: Name of the field for error messages
|
||
|
|
required: Whether the date is required
|
||
|
|
format_hint: Hint about expected format
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Validated datetime or None if not required and empty
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValidationError: If validation fails
|
||
|
|
"""
|
||
|
|
if date_input is None or (isinstance(date_input, str) and not date_input.strip()):
|
||
|
|
if required:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} is required",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="required"
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
|
||
|
|
if isinstance(date_input, datetime):
|
||
|
|
return date_input
|
||
|
|
elif isinstance(date_input, date):
|
||
|
|
return datetime.combine(date_input, datetime.min.time())
|
||
|
|
elif isinstance(date_input, str):
|
||
|
|
# Use format hint if provided
|
||
|
|
allowed_formats = None
|
||
|
|
if format_hint:
|
||
|
|
if format_hint in date_validator.date_formats:
|
||
|
|
allowed_formats = [format_hint]
|
||
|
|
else:
|
||
|
|
# Try to match format hint to known formats
|
||
|
|
for fmt_name, fmt_info in date_validator.date_formats.items():
|
||
|
|
if format_hint.lower() in fmt_info['description'].lower():
|
||
|
|
allowed_formats = [fmt_name]
|
||
|
|
break
|
||
|
|
|
||
|
|
parsed_date, _ = date_validator.validate_date_string(
|
||
|
|
date_input, field_name, allowed_formats=allowed_formats
|
||
|
|
)
|
||
|
|
return parsed_date
|
||
|
|
else:
|
||
|
|
raise ValidationError(
|
||
|
|
message=f"{field_name} must be a string, date, or datetime",
|
||
|
|
field_name=field_name,
|
||
|
|
validation_rule="date_type"
|
||
|
|
)
|