Refactor validation and UI components into thechart package
- Introduced validation utilities in src/thechart/validation with InputValidator class for various data types. - Migrated theme management to thechart.ui.theme_manager, providing a legacy shim for backward compatibility. - Updated tooltip system to thechart.ui.tooltip_system, maintaining legacy imports. - Created compatibility shim for undo utilities, redirecting to thechart.core.undo_manager. - Ensured all new modules are properly documented and maintain existing functionality.
This commit is contained in:
+9
-287
@@ -1,291 +1,13 @@
|
||||
"""Input validation utilities for TheChart application."""
|
||||
"""Compatibility shim for InputValidator.
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
This module preserves the legacy import path
|
||||
`from input_validator import InputValidator` while the canonical
|
||||
implementation now lives under `thechart.validation.input_validator`.
|
||||
New code should import from `thechart.validation`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
class InputValidator:
|
||||
"""Handles input validation for various data types in the application."""
|
||||
from thechart.validation import InputValidator
|
||||
|
||||
@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]]:
|
||||
"""
|
||||
Backward-compat entry completeness check.
|
||||
|
||||
Delegates to validate_entry_completeness_with_keys when possible.
|
||||
"""
|
||||
# Heuristic split: treat keys ending with _doses and note/date as
|
||||
# non-core and assume the rest are a mix of pathologies and medicines;
|
||||
# callers should prefer the explicit API below.
|
||||
keys = [
|
||||
k
|
||||
for k in entry_data
|
||||
if k not in {"date", "note"} and not str(k).endswith("_doses")
|
||||
]
|
||||
# Even split guess is unreliable; use value patterns instead:
|
||||
path_keys = [k for k in keys if isinstance(entry_data.get(k), int | float)]
|
||||
med_keys = [k for k in keys if k not in path_keys]
|
||||
return InputValidator.validate_entry_completeness_with_keys(
|
||||
entry_data, path_keys, med_keys
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_entry_completeness_with_keys(
|
||||
entry_data: dict[str, Any],
|
||||
pathology_keys: list[str],
|
||||
medicine_keys: list[str],
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Validate that an entry has the minimum required data using explicit keys.
|
||||
|
||||
Args:
|
||||
entry_data: Dictionary containing entry data
|
||||
pathology_keys: Keys representing pathology scores (numeric, >0 meaningful)
|
||||
medicine_keys: Keys representing medicine taken flags (0/1 boolean)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_complete, list_of_missing_fields)
|
||||
"""
|
||||
missing_fields: list[str] = []
|
||||
if not entry_data.get("date"):
|
||||
missing_fields.append("Date")
|
||||
|
||||
def _as_int(v: Any) -> int:
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
try:
|
||||
return int(float(v))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
has_pathology = any(_as_int(entry_data.get(k, 0)) > 0 for k in pathology_keys)
|
||||
has_medicine = any(_as_int(entry_data.get(k, 0)) == 1 for k in medicine_keys)
|
||||
|
||||
if not (has_pathology or has_medicine):
|
||||
missing_fields.append("At least one pathology score or medicine entry")
|
||||
|
||||
return len(missing_fields) == 0, missing_fields
|
||||
__all__ = ["InputValidator"]
|
||||
|
||||
Reference in New Issue
Block a user