"""Input validation utilities for TheChart application.""" import re from datetime import datetime from typing import Any class InputValidator: """Handles input validation for various data types in the application.""" @staticmethod def validate_date(date_str: str) -> tuple[bool, str, datetime | None]: """ Validate date string and return parsed datetime if valid. Args: date_str: Date string to validate Returns: Tuple of (is_valid, error_message, parsed_date) """ if not date_str or not date_str.strip(): return False, "Date cannot be empty", None date_str = date_str.strip() # Common date formats to try date_formats = [ "%m/%d/%Y", # 01/15/2025 "%m-%d-%Y", # 01-15-2025 "%Y-%m-%d", # 2025-01-15 "%m/%d/%y", # 01/15/25 "%m-%d-%y", # 01-15-25 ] for date_format in date_formats: try: parsed_date = datetime.strptime(date_str, date_format) # Check for reasonable date range (not too far in past/future) current_year = datetime.now().year if not (1900 <= parsed_date.year <= current_year + 10): continue return True, "", parsed_date except ValueError: continue return False, "Invalid date format. Use MM/DD/YYYY format.", None @staticmethod def validate_pathology_score(score: Any) -> tuple[bool, str, int]: """ Validate pathology score (0-10 scale). Args: score: Score value to validate Returns: Tuple of (is_valid, error_message, validated_score) """ try: score_int = int(score) if 0 <= score_int <= 10: return True, "", score_int else: return False, "Pathology score must be between 0 and 10", 0 except (ValueError, TypeError): return False, "Pathology score must be a valid number", 0 @staticmethod def validate_medicine_taken(taken: Any) -> tuple[bool, str, int]: """ Validate medicine taken boolean (0 or 1). Args: taken: Boolean-like value to validate Returns: Tuple of (is_valid, error_message, validated_value) """ try: taken_int = int(taken) if taken_int in (0, 1): return True, "", taken_int else: return False, "Medicine taken must be 0 (not taken) or 1 (taken)", 0 except (ValueError, TypeError): return False, "Medicine taken must be a valid boolean value", 0 @staticmethod def validate_dose_amount(dose_str: str) -> tuple[bool, str, str]: """ Validate dose amount string. Args: dose_str: Dose string to validate Returns: Tuple of (is_valid, error_message, cleaned_dose) """ if not dose_str: return True, "", "" # Empty dose is valid dose_str = dose_str.strip() # Allow alphanumeric characters, spaces, periods, and common dose units if re.match(r"^[\w\s\.\/\-\+]+$", dose_str): # Limit length to prevent extremely long entries if len(dose_str) <= 50: return True, "", dose_str else: return ( False, "Dose description too long (max 50 characters)", dose_str[:50], ) else: return False, "Dose contains invalid characters", "" @staticmethod def validate_note(note_str: str) -> tuple[bool, str, str]: """ Validate and sanitize note text. Args: note_str: Note string to validate Returns: Tuple of (is_valid, error_message, cleaned_note) """ if not note_str: return True, "", "" # Empty note is valid note_str = note_str.strip() # Remove any potential harmful characters while preserving readability cleaned_note = re.sub(r"[^\w\s\.\,\!\?\:\;\-\(\)\[\]\'\"]+", "", note_str) # Limit length if len(cleaned_note) <= 500: return True, "", cleaned_note else: return False, "Note too long (max 500 characters)", cleaned_note[:500] @staticmethod def validate_filename(filename: str) -> tuple[bool, str, str]: """ Validate filename for export operations. Args: filename: Filename to validate Returns: Tuple of (is_valid, error_message, cleaned_filename) """ if not filename or not filename.strip(): return False, "Filename cannot be empty", "" filename = filename.strip() # Remove/replace invalid filename characters invalid_chars = r'[<>:"/\\|?*]' cleaned_filename = re.sub(invalid_chars, "_", filename) # Ensure reasonable length if len(cleaned_filename) <= 100: return True, "", cleaned_filename else: return ( False, "Filename too long (max 100 characters)", cleaned_filename[:100], ) @staticmethod def validate_time_format(time_str: str) -> tuple[bool, str, datetime | None]: """ Validate time string for dose tracking. Args: time_str: Time string to validate Returns: Tuple of (is_valid, error_message, parsed_time) """ if not time_str or not time_str.strip(): return False, "Time cannot be empty", None time_str = time_str.strip() # Common time formats time_formats = [ "%I:%M %p", # 02:30 PM "%H:%M", # 14:30 "%I:%M%p", # 2:30PM (no space) "%I%p", # 2PM ] for time_format in time_formats: try: parsed_time = datetime.strptime(time_str, time_format) return True, "", parsed_time except ValueError: continue return False, "Invalid time format. Use HH:MM AM/PM or HH:MM (24-hour)", None @staticmethod def sanitize_csv_field(field_str: str) -> str: """ Sanitize field for CSV output to prevent injection attacks. Args: field_str: Field string to sanitize Returns: Sanitized string safe for CSV """ if not isinstance(field_str, str): field_str = str(field_str) # Remove potential CSV injection characters dangerous_prefixes = ["=", "+", "-", "@"] cleaned = field_str.strip() # If field starts with dangerous character, prepend space if cleaned and cleaned[0] in dangerous_prefixes: cleaned = " " + cleaned return cleaned @staticmethod def validate_entry_completeness( entry_data: dict[str, Any], ) -> tuple[bool, list[str]]: """ Validate that an entry has the minimum required data. Args: entry_data: Dictionary containing entry data Returns: Tuple of (is_complete, list_of_missing_fields) """ missing_fields = [] # Check required fields if not entry_data.get("date"): missing_fields.append("Date") # Check that at least one pathology or medicine is recorded has_pathology_data = any( entry_data.get(key, 0) > 0 for key in entry_data if not key.endswith("_doses") and key not in ["date", "note"] ) has_medicine_data = any( entry_data.get(key, 0) > 0 for key in entry_data if not key.endswith("_doses") and key not in ["date", "note"] ) if not (has_pathology_data or has_medicine_data): missing_fields.append("At least one pathology score or medicine entry") return len(missing_fields) == 0, missing_fields