feat: Implement search and filter functionality in MedTrackerApp

- Added DataFilter class for managing filtering and searching of medical data.
- Introduced SearchFilterWidget for UI controls related to search and filters.
- Integrated search and filter features into MedTrackerApp, allowing users to filter data by date range, medicine status, and pathology scores.
- Implemented quick filters for common use cases (last week, last month, high symptoms).
- Enhanced data loading and display logic to accommodate filtered data.
- Added error handling for data loading issues.
- Updated UIManager to reflect filter status in the application.
- Improved entry validation in add_new_entry method to ensure data integrity.
This commit is contained in:
William Valentin
2025-08-06 09:55:47 -07:00
parent 780d44775d
commit 7bb06fabdd
10 changed files with 2288 additions and 30 deletions
+266
View File
@@ -0,0 +1,266 @@
"""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