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:
+7
-363
@@ -1,369 +1,13 @@
|
||||
"""Auto-save and backup utilities for TheChart.
|
||||
"""Compatibility shim re-exporting auto-save utilities.
|
||||
|
||||
Provides two APIs:
|
||||
|
||||
New application API (used by main app):
|
||||
AutoSaveManager(save_callback=callable, interval_minutes=5, logger=None)
|
||||
.enable_auto_save() / .disable_auto_save()
|
||||
.mark_data_modified() / .force_save()
|
||||
|
||||
Legacy test API (expected by tests/test_auto_save.py):
|
||||
AutoSaveManager(data_file_path=..., backup_dir=..., status_callback=...,
|
||||
error_callback=..., interval_minutes=0.1, max_backups=3)
|
||||
.start() / .stop()
|
||||
.create_backup(suffix) / .get_backup_files() / .restore_from_backup(path)
|
||||
|
||||
Both modes share a single implementation for simplicity. Mode is inferred by
|
||||
presence of 'data_file_path' in kwargs (legacy) vs 'save_callback' (new).
|
||||
Canonical implementation lives in `thechart.core.auto_save`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from thechart.core.auto_save import ( # noqa: F401
|
||||
AutoSaveManager,
|
||||
BackupManager,
|
||||
)
|
||||
|
||||
from constants import BACKUP_PATH
|
||||
|
||||
|
||||
class AutoSaveManager:
|
||||
"""Unified auto-save & backup manager supporting legacy and new APIs."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction / mode detection
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(self, *args, **kwargs) -> None: # type: ignore[override]
|
||||
# Determine mode: legacy if a filesystem path is provided
|
||||
self._legacy_mode = "data_file_path" in kwargs or (
|
||||
args and isinstance(args[0], str)
|
||||
)
|
||||
self.logger = kwargs.get("logger")
|
||||
|
||||
if self._legacy_mode:
|
||||
# Legacy parameters (tests expect these attributes)
|
||||
self.data_file_path: str = kwargs.get(
|
||||
"data_file_path", args[0] if args else ""
|
||||
)
|
||||
self.backup_dir: str = kwargs.get("backup_dir", BACKUP_PATH)
|
||||
self.status_callback: Callable[[str], None] | None = kwargs.get(
|
||||
"status_callback"
|
||||
)
|
||||
self.error_callback: Callable[[str], None] | None = kwargs.get(
|
||||
"error_callback"
|
||||
)
|
||||
self.interval_minutes: float = float(kwargs.get("interval_minutes", 5))
|
||||
self.max_backups: int = int(kwargs.get("max_backups", 10))
|
||||
self.interval_seconds: float = self.interval_minutes * 60
|
||||
self.save_callback: Callable[[], None] | None = None # Not used in tests
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self.is_running: bool = False
|
||||
self._last_save_time: datetime | None = None
|
||||
self._data_modified = False # Unused in legacy tests but kept
|
||||
self._ensure_backup_directory()
|
||||
else:
|
||||
# New application mode
|
||||
save_cb: Callable[[], None] | None = kwargs.get("save_callback")
|
||||
if save_cb is None and args:
|
||||
save_cb = args[0]
|
||||
interval = float(kwargs.get("interval_minutes", 5))
|
||||
self.save_callback = save_cb
|
||||
self.interval_minutes = interval
|
||||
self.interval_seconds = interval * 60
|
||||
self._auto_save_enabled = False
|
||||
self._save_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._last_save_time: datetime | None = None
|
||||
self._data_modified = False
|
||||
# Shim attributes for compatibility (unused in new mode)
|
||||
self.data_file_path = ""
|
||||
self.backup_dir = BACKUP_PATH
|
||||
self.status_callback = None
|
||||
self.error_callback = None
|
||||
self.max_backups = 10
|
||||
self.is_running = False
|
||||
|
||||
def enable_auto_save(self) -> None:
|
||||
"""Enable automatic saving."""
|
||||
if self._legacy_mode:
|
||||
# Map to legacy start()
|
||||
self.start()
|
||||
return
|
||||
if getattr(self, "_auto_save_enabled", False):
|
||||
return
|
||||
self._auto_save_enabled = True
|
||||
self._stop_event.clear()
|
||||
self._save_thread = threading.Thread(target=self._auto_save_loop, daemon=True)
|
||||
self._save_thread.start()
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Auto-save enabled with {self.interval_minutes:.1f} minute intervals"
|
||||
)
|
||||
|
||||
def disable_auto_save(self) -> None:
|
||||
"""Disable automatic saving."""
|
||||
if self._legacy_mode:
|
||||
self.stop()
|
||||
return
|
||||
if not getattr(self, "_auto_save_enabled", False):
|
||||
return
|
||||
self._auto_save_enabled = False
|
||||
self._stop_event.set()
|
||||
if self._save_thread and self._save_thread.is_alive():
|
||||
self._save_thread.join(timeout=2.0)
|
||||
if self.logger:
|
||||
self.logger.info("Auto-save disabled")
|
||||
|
||||
def mark_data_modified(self) -> None:
|
||||
"""Mark that data has been modified and needs saving."""
|
||||
self._data_modified = True
|
||||
|
||||
def force_save(self) -> None:
|
||||
"""Force an immediate save if data has been modified."""
|
||||
if self._data_modified and self.save_callback:
|
||||
try:
|
||||
self.save_callback()
|
||||
self._last_save_time = datetime.now()
|
||||
self._data_modified = False
|
||||
if self.logger:
|
||||
self.logger.debug("Force save completed successfully")
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Force save failed: {e}")
|
||||
|
||||
def get_last_save_time(self) -> datetime | None:
|
||||
"""Get the timestamp of the last successful save."""
|
||||
return self._last_save_time
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if auto-save is currently enabled."""
|
||||
return (
|
||||
self.is_running
|
||||
if self._legacy_mode
|
||||
else getattr(self, "_auto_save_enabled", False)
|
||||
)
|
||||
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if there are unsaved changes."""
|
||||
return self._data_modified
|
||||
|
||||
def _auto_save_loop(self) -> None:
|
||||
"""Main auto-save loop running in background thread."""
|
||||
while not self._stop_event.wait(self.interval_seconds):
|
||||
if self._data_modified and self.save_callback:
|
||||
try:
|
||||
self.save_callback()
|
||||
self._last_save_time = datetime.now()
|
||||
self._data_modified = False
|
||||
if self.logger:
|
||||
self.logger.debug("Auto-save completed successfully")
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Auto-save failed: {e}")
|
||||
|
||||
def set_interval(self, minutes: int) -> None:
|
||||
"""
|
||||
Change the auto-save interval.
|
||||
|
||||
Args:
|
||||
minutes: New interval in minutes (minimum 1, maximum 60)
|
||||
"""
|
||||
if not 1 <= minutes <= 60:
|
||||
raise ValueError("Auto-save interval must be between 1 and 60 minutes")
|
||||
old = self.interval_minutes
|
||||
self.interval_minutes = float(minutes)
|
||||
self.interval_seconds = self.interval_minutes * 60
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"Auto-save interval changed from %.1f to %.1f minutes",
|
||||
old,
|
||||
self.interval_minutes,
|
||||
)
|
||||
if not self._legacy_mode and getattr(self, "_auto_save_enabled", False):
|
||||
self.disable_auto_save()
|
||||
self.enable_auto_save()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._legacy_mode:
|
||||
self.stop()
|
||||
else:
|
||||
self.disable_auto_save()
|
||||
if self._data_modified:
|
||||
if self.logger:
|
||||
self.logger.info("Performing final save on cleanup")
|
||||
self.force_save()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy mode API (periodic file backups)
|
||||
# ------------------------------------------------------------------
|
||||
def start(self) -> None:
|
||||
if not self._legacy_mode or self.is_running:
|
||||
return
|
||||
self.is_running = True
|
||||
self._stop_event.clear()
|
||||
with contextlib.suppress(Exception):
|
||||
self.create_backup("startup")
|
||||
|
||||
def _loop() -> None:
|
||||
while not self._stop_event.wait(self.interval_seconds):
|
||||
with contextlib.suppress(Exception):
|
||||
self.create_backup("auto")
|
||||
|
||||
self._thread = threading.Thread(target=_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._legacy_mode or not self.is_running:
|
||||
return
|
||||
self.is_running = False
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
# --------------------- Backup helpers (legacy) ---------------------
|
||||
def _ensure_backup_directory(self) -> None:
|
||||
os.makedirs(self.backup_dir, exist_ok=True)
|
||||
|
||||
def create_backup(self, suffix: str) -> str | None:
|
||||
if not getattr(self, "data_file_path", ""):
|
||||
return None
|
||||
if not os.path.exists(self.data_file_path):
|
||||
if self.error_callback:
|
||||
self.error_callback("Source file does not exist")
|
||||
return None
|
||||
safe_suffix = re.sub(r"[^A-Za-z0-9_\-]+", "_", suffix.strip()) or "backup"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base = os.path.splitext(os.path.basename(self.data_file_path))[0]
|
||||
filename = f"{base}_{safe_suffix}_{timestamp}.csv"
|
||||
dest = os.path.join(self.backup_dir, filename)
|
||||
try:
|
||||
shutil.copy2(self.data_file_path, dest)
|
||||
if self.status_callback:
|
||||
self.status_callback(f"Backup created: {dest}")
|
||||
self._cleanup_old_backups()
|
||||
return dest
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.error_callback:
|
||||
self.error_callback(f"Backup failed: {e}")
|
||||
return None
|
||||
|
||||
def _cleanup_old_backups(self) -> None:
|
||||
pattern = os.path.join(self.backup_dir, "*.csv")
|
||||
files = glob.glob(pattern)
|
||||
if len(files) <= self.max_backups:
|
||||
return
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
for f in files[self.max_backups :]:
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(f)
|
||||
|
||||
def get_backup_files(self) -> list[str]:
|
||||
pattern = os.path.join(self.backup_dir, "*.csv")
|
||||
files = glob.glob(pattern)
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
return files
|
||||
|
||||
def restore_from_backup(self, backup_path: str) -> bool:
|
||||
if not os.path.exists(backup_path):
|
||||
if self.error_callback:
|
||||
self.error_callback("Backup file does not exist")
|
||||
return False
|
||||
try:
|
||||
shutil.copy2(backup_path, self.data_file_path)
|
||||
if self.status_callback:
|
||||
self.status_callback(f"Restored from backup: {backup_path}")
|
||||
return True
|
||||
except Exception as e: # pragma: no cover
|
||||
if self.error_callback:
|
||||
self.error_callback(f"Restore failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Standalone backup manager used by application code."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_file_path: str,
|
||||
backup_directory: str = BACKUP_PATH,
|
||||
logger=None,
|
||||
status_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.data_file_path = data_file_path
|
||||
self.backup_directory = backup_directory
|
||||
self.logger = logger
|
||||
self.status_callback = status_callback
|
||||
self._ensure_backup_directory()
|
||||
|
||||
def _ensure_backup_directory(self) -> None:
|
||||
os.makedirs(self.backup_directory, exist_ok=True)
|
||||
|
||||
def create_backup(self, backup_type: str = "manual") -> str | None:
|
||||
if not os.path.exists(self.data_file_path):
|
||||
if self.logger:
|
||||
self.logger.warning("Cannot create backup: data file doesn't exist")
|
||||
return None
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base_name = os.path.splitext(os.path.basename(self.data_file_path))[0]
|
||||
backup_filename = f"{base_name}_backup_{backup_type}_{timestamp}.csv"
|
||||
backup_path = os.path.join(self.backup_directory, backup_filename)
|
||||
shutil.copy2(self.data_file_path, backup_path)
|
||||
msg = f"Backup created: {backup_path}"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if self.status_callback:
|
||||
self.status_callback(msg)
|
||||
return backup_path
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup creation failed: {e}")
|
||||
return None
|
||||
|
||||
def cleanup_old_backups(self, keep_count: int = 10) -> None:
|
||||
try:
|
||||
backup_pattern = os.path.join(self.backup_directory, "*_backup_*.csv")
|
||||
backup_files = glob.glob(backup_pattern)
|
||||
if len(backup_files) <= keep_count:
|
||||
return
|
||||
backup_files.sort(key=os.path.getmtime, reverse=True)
|
||||
removed = 0
|
||||
for file_path in backup_files[keep_count:]:
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(file_path)
|
||||
removed += 1
|
||||
msg = f"Cleaned up {removed} old backup files"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if self.status_callback and removed:
|
||||
self.status_callback(msg)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup cleanup failed: {e}")
|
||||
|
||||
def restore_from_backup(self, backup_path: str) -> bool:
|
||||
if not os.path.exists(backup_path):
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup file doesn't exist: {backup_path}")
|
||||
return False
|
||||
try:
|
||||
# Create a backup of current data before restoring
|
||||
current_backup = self.create_backup("pre_restore")
|
||||
shutil.copy2(backup_path, self.data_file_path)
|
||||
msg = f"Successfully restored from backup: {backup_path}"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if current_backup:
|
||||
self.logger.info(f"Previous data backed up to: {current_backup}")
|
||||
if self.status_callback:
|
||||
self.status_callback(msg)
|
||||
return True
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Restore from backup failed: {e}")
|
||||
return False
|
||||
__all__ = ["AutoSaveManager", "BackupManager"]
|
||||
|
||||
+11
-45
@@ -1,49 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
"""Compatibility shim for environment-driven constants.
|
||||
|
||||
import dotenv as _dotenv
|
||||
Canonical definitions live in `thechart.core.constants`.
|
||||
"""
|
||||
|
||||
# Determine external data directory (supports PyInstaller)
|
||||
extDataDir = os.getcwd()
|
||||
if getattr(sys, "frozen", False): # pragma: no cover - runtime packaging path
|
||||
extDataDir = sys._MEIPASS # type: ignore[attr-defined]
|
||||
from __future__ import annotations
|
||||
|
||||
_already_initialized = globals().get("_already_initialized", False)
|
||||
from thechart.core.constants import ( # noqa: F401
|
||||
BACKUP_PATH,
|
||||
LOG_CLEAR,
|
||||
LOG_LEVEL,
|
||||
LOG_PATH,
|
||||
)
|
||||
|
||||
# Snapshot environment before potential .env load so we can honor values
|
||||
# that were present prior to loading .env and ignore values introduced by it.
|
||||
_pre_env = dict(os.environ)
|
||||
|
||||
# Preserve patched load_dotenv if present (tests patch this symbol)
|
||||
if "load_dotenv" not in globals(): # first import or not patched yet
|
||||
load_dotenv = _dotenv.load_dotenv # type: ignore[assignment]
|
||||
|
||||
# Always call (tests expect call with override=True)
|
||||
load_dotenv(override=True)
|
||||
_already_initialized = True
|
||||
|
||||
|
||||
def _pre_or_default(key: str, default: str) -> str:
|
||||
"""Return the value from the pre-dotenv environment or the default.
|
||||
|
||||
Values that only exist due to .env load are ignored so tests (and env)
|
||||
take precedence, while still allowing us to call load_dotenv(override=True).
|
||||
"""
|
||||
if key in _pre_env:
|
||||
return _pre_env[key]
|
||||
# Ignore values introduced only via .env
|
||||
return default
|
||||
|
||||
|
||||
# Environment driven constants (tests expect specific defaults / formats)
|
||||
LOG_LEVEL = (_pre_or_default("LOG_LEVEL", "INFO") or "INFO").upper()
|
||||
LOG_PATH = _pre_or_default("LOG_PATH", "/tmp/logs/thechart")
|
||||
LOG_CLEAR = (_pre_or_default("LOG_CLEAR", "False") or "False").capitalize()
|
||||
BACKUP_PATH = _pre_or_default("BACKUP_PATH", "/tmp/thechart/backups")
|
||||
|
||||
__all__ = [
|
||||
"LOG_LEVEL",
|
||||
"LOG_PATH",
|
||||
"LOG_CLEAR",
|
||||
"BACKUP_PATH",
|
||||
]
|
||||
__all__ = ["LOG_LEVEL", "LOG_PATH", "LOG_CLEAR", "BACKUP_PATH"]
|
||||
|
||||
+7
-520
@@ -1,524 +1,11 @@
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
"""Legacy shim for DataManager.
|
||||
|
||||
import pandas as pd
|
||||
This preserves backward compatibility for imports like:
|
||||
from data_manager import DataManager
|
||||
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_manager import PathologyManager
|
||||
Canonical implementation lives in: thechart.data.data_manager
|
||||
"""
|
||||
|
||||
from thechart.data.data_manager import DataManager # noqa: F401
|
||||
|
||||
class DataManager:
|
||||
"""Handle all data operations for the application with performance optimizations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename: str,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self._init_internal(
|
||||
filename,
|
||||
logger,
|
||||
medicine_manager,
|
||||
pathology_manager,
|
||||
)
|
||||
|
||||
def _init_internal(
|
||||
self,
|
||||
filename: str,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
self.logger = logger
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
|
||||
self._data_cache = None
|
||||
self._cache_timestamp = 0
|
||||
self._headers_cache = None
|
||||
self._dtype_cache = None
|
||||
self._graph_cache = None
|
||||
self._config_version = 0
|
||||
self._initialize_csv_file()
|
||||
|
||||
def _get_csv_headers(self) -> tuple[str, ...]:
|
||||
"""Get CSV headers based on current pathology and medicine configuration.
|
||||
Cached to avoid repeated computation."""
|
||||
if self._headers_cache is not None:
|
||||
return self._headers_cache
|
||||
|
||||
# Start with date
|
||||
headers = ["date"]
|
||||
|
||||
# Add pathology headers
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
headers.append(pathology_key)
|
||||
|
||||
# Add medicine headers
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
headers.extend([medicine_key, f"{medicine_key}_doses"])
|
||||
|
||||
result = tuple(headers + ["note"])
|
||||
self._headers_cache = result
|
||||
return result
|
||||
|
||||
def _initialize_csv_file(self) -> None:
|
||||
"""Create CSV file with headers if it doesn't exist or is empty."""
|
||||
try:
|
||||
creating = not os.path.exists(self.filename)
|
||||
if creating or os.path.getsize(self.filename) == 0:
|
||||
with open(self.filename, mode="w", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(self._get_csv_headers())
|
||||
if creating:
|
||||
# Emit warning so tests detect creation of missing file
|
||||
self.logger.warning(
|
||||
"CSV file did not exist and was created with headers."
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize CSV file: {e}")
|
||||
|
||||
def _invalidate_cache(self) -> None:
|
||||
"""Invalidate the data cache when data changes."""
|
||||
self._data_cache = None
|
||||
self._cache_timestamp = 0
|
||||
self._graph_cache = None
|
||||
|
||||
def invalidate_structure(self) -> None:
|
||||
"""Invalidate caches due to structural changes (e.g., medicines/pathologies).
|
||||
|
||||
Public method for other managers / UI to call instead of reaching into
|
||||
private attributes. This bumps a config version ensuring future loads
|
||||
rebuild dependent caches.
|
||||
"""
|
||||
self._headers_cache = None
|
||||
self._dtype_cache = None
|
||||
self._graph_cache = None
|
||||
self._config_version += 1
|
||||
# Data remains valid but columns may differ; safest is full invalidation
|
||||
self._invalidate_cache()
|
||||
|
||||
def _should_reload_data(self) -> bool:
|
||||
"""Check if data should be reloaded based on file modification time."""
|
||||
if self._data_cache is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
file_mtime = os.path.getmtime(self.filename)
|
||||
return file_mtime > self._cache_timestamp
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _get_dtype_dict(self) -> dict[str, type]:
|
||||
"""Get pandas dtype dictionary for efficient reading.
|
||||
Cached to avoid recreation."""
|
||||
if self._dtype_cache is not None:
|
||||
return self._dtype_cache
|
||||
|
||||
dtype_dict = {"date": str, "note": str}
|
||||
|
||||
# Add pathology types
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
dtype_dict[pathology_key] = int
|
||||
|
||||
# Add medicine types
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
dtype_dict[medicine_key] = int
|
||||
dtype_dict[f"{medicine_key}_doses"] = str
|
||||
|
||||
self._dtype_cache = dtype_dict
|
||||
return dtype_dict
|
||||
|
||||
def load_data(self) -> pd.DataFrame:
|
||||
"""Load data from CSV file with caching for better performance."""
|
||||
if not os.path.exists(self.filename):
|
||||
self.logger.warning("CSV file does not exist. No data to load.")
|
||||
return pd.DataFrame()
|
||||
if os.path.getsize(self.filename) == 0:
|
||||
self.logger.warning("CSV file is empty. No data to load.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Use cached data if available and file hasn't changed
|
||||
if not self._should_reload_data():
|
||||
return self._data_cache.copy()
|
||||
|
||||
try:
|
||||
# Use pre-built dtype dictionary for faster parsing
|
||||
dtype_dict = self._get_dtype_dict()
|
||||
|
||||
# Read with optimized settings
|
||||
df: pd.DataFrame = pd.read_csv(
|
||||
self.filename,
|
||||
dtype=dtype_dict,
|
||||
na_filter=False, # Don't convert to NaN, keep as empty strings
|
||||
engine="c", # Use faster C engine
|
||||
)
|
||||
|
||||
# If file has only headers (no rows), treat as empty with warning
|
||||
if df.empty:
|
||||
self.logger.warning("CSV file contains only headers. No data to load.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Sort only if needed (check if already sorted)
|
||||
if len(df) > 1 and not df["date"].is_monotonic_increasing:
|
||||
df = df.sort_values(by="date").reset_index(drop=True)
|
||||
|
||||
# Cache the data and timestamp
|
||||
self._data_cache = df.copy()
|
||||
self._cache_timestamp = os.path.getmtime(self.filename)
|
||||
# Invalidate graph cache because underlying data changed
|
||||
self._graph_cache = None
|
||||
|
||||
return df.copy()
|
||||
|
||||
except pd.errors.EmptyDataError:
|
||||
self.logger.warning("CSV file is empty. No data to load.")
|
||||
return pd.DataFrame()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading data: {str(e)}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def add_entry(self, entry_data: list[str | int]) -> bool:
|
||||
"""Add a new entry to the CSV file with optimized duplicate checking."""
|
||||
try:
|
||||
# Quick duplicate check using cached data if available
|
||||
date_to_add: str = str(entry_data[0])
|
||||
|
||||
if self._data_cache is not None:
|
||||
# Use cached data for duplicate check
|
||||
if date_to_add in self._data_cache["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# Fallback to loading data if no cache
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if not df.empty and date_to_add in df["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Write to file
|
||||
with open(self.filename, mode="a", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(entry_data)
|
||||
|
||||
# Invalidate cache since data changed
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error adding entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
||||
"""Update an existing entry identified by original_date
|
||||
with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
new_date: str = str(values[0])
|
||||
|
||||
# Optimized duplicate check
|
||||
if original_date != new_date:
|
||||
date_exists = (df["date"] == new_date).any()
|
||||
if date_exists:
|
||||
self.logger.warning(
|
||||
f"Cannot update: entry with date {new_date} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current CSV headers to match with values
|
||||
headers = list(self._get_csv_headers())
|
||||
|
||||
# Ensure we have the right number of values with optimized padding
|
||||
if len(values) < len(headers):
|
||||
# Pad with defaults efficiently
|
||||
padding_needed = len(headers) - len(values)
|
||||
for i in range(padding_needed):
|
||||
header_idx = len(values) + i
|
||||
if header_idx < len(headers):
|
||||
header = headers[header_idx]
|
||||
if header == "note" or header.endswith("_doses"):
|
||||
values.append("")
|
||||
else:
|
||||
values.append(0)
|
||||
|
||||
# Use vectorized update for better performance
|
||||
mask = df["date"] == original_date
|
||||
if mask.any():
|
||||
df.loc[mask, headers] = values
|
||||
# Atomic write back to CSV to avoid partial writes
|
||||
self._atomic_write_csv(df)
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Entry with date {original_date} not found for update."
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def delete_entry(self, date: str) -> bool:
|
||||
"""Delete an entry identified by date with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
original_len = len(df)
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
df = df[df["date"] != date]
|
||||
|
||||
# Only write if something was actually deleted
|
||||
if len(df) < original_len:
|
||||
self._atomic_write_csv(df)
|
||||
self._invalidate_cache()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting entry: {str(e)}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File write helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _atomic_write_csv(self, df: pd.DataFrame) -> None:
|
||||
"""Write a DataFrame to CSV atomically by writing to a temp file then replacing.
|
||||
|
||||
This prevents corrupted files if the app crashes mid-write.
|
||||
"""
|
||||
directory = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix="thechart_", suffix=".csv", dir=directory
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as tmp_file:
|
||||
df.to_csv(tmp_file, index=False)
|
||||
os.replace(tmp_path, self.filename)
|
||||
finally:
|
||||
# If replace succeeded tmp_path no longer exists; suppress errors
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Archiving / Rotation
|
||||
# ------------------------------------------------------------------
|
||||
def _get_archive_dir(self) -> str:
|
||||
"""Return path to the archives directory next to the main CSV."""
|
||||
base_dir = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
archive_dir = os.path.join(base_dir, "archives")
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
return archive_dir
|
||||
|
||||
def _ensure_headers(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Ensure dataframe has all expected headers in correct order.
|
||||
|
||||
Missing numeric fields default to 0; dose/note string fields to ''.
|
||||
Columns are ordered per _get_csv_headers().
|
||||
"""
|
||||
headers = list(self._get_csv_headers())
|
||||
out = df.copy()
|
||||
for col in headers:
|
||||
if col not in out.columns:
|
||||
if col == "note" or col.endswith("_doses"):
|
||||
out[col] = ""
|
||||
else:
|
||||
out[col] = 0
|
||||
# Drop unknown columns to keep files tidy
|
||||
out = out[headers]
|
||||
return out
|
||||
|
||||
def _write_archive_file(self, year: int, df: pd.DataFrame) -> str:
|
||||
"""Append archived rows to a per-year CSV with full headers.
|
||||
|
||||
Returns the archive file path.
|
||||
"""
|
||||
archive_dir = self._get_archive_dir()
|
||||
base = os.path.splitext(os.path.basename(self.filename))[0]
|
||||
archive_path = os.path.join(archive_dir, f"{base}_{year}.csv")
|
||||
df_to_write = self._ensure_headers(df)
|
||||
# If file doesn't exist, write with header; else append without header
|
||||
write_header = (
|
||||
not os.path.exists(archive_path) or os.path.getsize(archive_path) == 0
|
||||
)
|
||||
try:
|
||||
df_to_write.to_csv(archive_path, mode="a", index=False, header=write_header)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write archive file {archive_path}: {e}")
|
||||
raise
|
||||
return archive_path
|
||||
|
||||
def archive_old_data(self, keep_years: int = 1) -> dict[str, Any]:
|
||||
"""Archive rows older than the most recent N years into per-year files.
|
||||
|
||||
Args:
|
||||
keep_years: Number of most recent full calendar years to keep in the
|
||||
main CSV (minimum 1). Rows with a date older than the earliest
|
||||
kept year are moved to archives/BASE_YYYY.csv.
|
||||
|
||||
Returns:
|
||||
Summary dict: { 'archived_rows': int, 'archive_files': set[str],
|
||||
'kept_rows': int }
|
||||
"""
|
||||
try:
|
||||
keep_years = max(1, int(keep_years))
|
||||
except Exception:
|
||||
keep_years = 1
|
||||
|
||||
df = self.load_data()
|
||||
if df.empty or "date" not in df.columns:
|
||||
return {"archived_rows": 0, "archive_files": set(), "kept_rows": 0}
|
||||
|
||||
# Parse dates (stored as mm/dd/YYYY normally)
|
||||
dates = pd.to_datetime(df["date"], format="%m/%d/%Y", errors="coerce")
|
||||
df = df.copy()
|
||||
df["__dt"] = dates
|
||||
# If we couldn't parse dates, nothing to archive safely
|
||||
if df["__dt"].isna().all():
|
||||
df.drop(columns=["__dt"], inplace=True)
|
||||
return {
|
||||
"archived_rows": 0,
|
||||
"archive_files": set(),
|
||||
"kept_rows": int(len(df)),
|
||||
}
|
||||
|
||||
current_year = datetime.now().year
|
||||
earliest_kept_year = current_year - keep_years + 1
|
||||
|
||||
to_archive = df[df["__dt"].dt.year < earliest_kept_year]
|
||||
to_keep = df[df["__dt"].dt.year >= earliest_kept_year]
|
||||
|
||||
if to_archive.empty:
|
||||
df.drop(columns=["__dt"], inplace=True)
|
||||
return {
|
||||
"archived_rows": 0,
|
||||
"archive_files": set(),
|
||||
"kept_rows": int(len(df)),
|
||||
}
|
||||
|
||||
archive_files: set[str] = set()
|
||||
try:
|
||||
# Group by year and append to each year's archive file
|
||||
for year, group in to_archive.groupby(to_archive["__dt"].dt.year):
|
||||
group = group.drop(columns=["__dt"]) # remove helper
|
||||
path = self._write_archive_file(int(year), group)
|
||||
archive_files.add(path)
|
||||
|
||||
# Write the kept rows back to main CSV atomically
|
||||
kept_df = to_keep.drop(columns=["__dt"]).copy()
|
||||
# Ensure columns and order
|
||||
kept_df = self._ensure_headers(kept_df)
|
||||
self._atomic_write_csv(kept_df)
|
||||
self._invalidate_cache()
|
||||
except Exception as e:
|
||||
# If archiving failed mid-way, log and propagate minimal info
|
||||
self.logger.error(f"Archiving failed: {e}")
|
||||
raise
|
||||
|
||||
return {
|
||||
"archived_rows": int(len(to_archive)),
|
||||
"archive_files": archive_files,
|
||||
"kept_rows": int(len(to_keep)),
|
||||
}
|
||||
|
||||
def get_today_medicine_doses(
|
||||
self, date: str, medicine_name: str
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get list of (timestamp, dose) tuples for a medicine on a given date
|
||||
with caching."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
date_mask = df["date"] == date
|
||||
if not date_mask.any():
|
||||
return []
|
||||
|
||||
dose_column = f"{medicine_name}_doses"
|
||||
if dose_column not in df.columns:
|
||||
return []
|
||||
|
||||
doses_str = df.loc[date_mask, dose_column].iloc[0]
|
||||
|
||||
if not doses_str:
|
||||
return []
|
||||
|
||||
# Optimized dose parsing
|
||||
doses = []
|
||||
for dose_entry in doses_str.split("|"):
|
||||
if ":" in dose_entry:
|
||||
parts = dose_entry.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
doses.append((parts[0], parts[1]))
|
||||
|
||||
return doses
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting medicine doses: {str(e)}")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retrieval helpers
|
||||
# ------------------------------------------------------------------
|
||||
def get_row(self, date: str) -> list[str | int] | None:
|
||||
"""Return a row (as list aligned with current headers) for a date.
|
||||
|
||||
Args:
|
||||
date: Date string identifying the row
|
||||
Returns:
|
||||
List of values aligned with current CSV headers or None if not found.
|
||||
"""
|
||||
try:
|
||||
df = self.load_data()
|
||||
if df.empty or "date" not in df.columns:
|
||||
return None
|
||||
mask = df["date"] == date
|
||||
if not mask.any():
|
||||
return None
|
||||
headers = list(self._get_csv_headers())
|
||||
row_series = df.loc[mask, headers].iloc[0]
|
||||
return [row_series[h] for h in headers]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph Data Handling
|
||||
# ------------------------------------------------------------------
|
||||
def get_graph_ready_data(self) -> pd.DataFrame:
|
||||
"""Return a dataframe ready for graphing (datetime index cached).
|
||||
|
||||
This avoids repeatedly parsing dates & re-sorting in the graph layer.
|
||||
"""
|
||||
base_df = self.load_data()
|
||||
if base_df.empty:
|
||||
return base_df
|
||||
if self._graph_cache is not None:
|
||||
return self._graph_cache.copy()
|
||||
try:
|
||||
graph_df = base_df.copy()
|
||||
# Expect date stored in mm/dd/YYYY format
|
||||
graph_df["date"] = pd.to_datetime(
|
||||
graph_df["date"], format="%m/%d/%Y", errors="coerce"
|
||||
)
|
||||
graph_df = graph_df.dropna(subset=["date"]).sort_values("date")
|
||||
graph_df.set_index("date", inplace=True)
|
||||
self._graph_cache = graph_df.copy()
|
||||
return graph_df
|
||||
except Exception:
|
||||
# Fallback: return original (unindexed) data
|
||||
return base_df
|
||||
__all__ = ["DataManager"]
|
||||
|
||||
+17
-391
@@ -1,391 +1,17 @@
|
||||
"""Enhanced error handling and user feedback system for TheChart."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""Centralized error handling with user-friendly feedback."""
|
||||
|
||||
def __init__(self, logger: logging.Logger, ui_manager=None):
|
||||
"""
|
||||
Initialize error handler.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for error logging
|
||||
ui_manager: UI manager for user feedback (optional)
|
||||
"""
|
||||
self.logger = logger
|
||||
self.ui_manager = ui_manager
|
||||
self.error_counts = {}
|
||||
self.last_error_time = {}
|
||||
|
||||
def handle_error(
|
||||
self,
|
||||
error: Exception,
|
||||
context: str = "Unknown",
|
||||
user_message: str | None = None,
|
||||
show_dialog: bool = True,
|
||||
log_level: int = logging.ERROR,
|
||||
) -> None:
|
||||
"""
|
||||
Handle an error with logging and user feedback.
|
||||
|
||||
Args:
|
||||
error: Exception that occurred
|
||||
context: Context where error occurred
|
||||
user_message: User-friendly message (auto-generated if None)
|
||||
show_dialog: Whether to show error dialog to user
|
||||
log_level: Logging level for the error
|
||||
"""
|
||||
error_key = f"{type(error).__name__}:{context}"
|
||||
current_time = datetime.now()
|
||||
|
||||
# Track error frequency
|
||||
self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
|
||||
self.last_error_time[error_key] = current_time
|
||||
|
||||
# Log the error with full traceback
|
||||
error_msg = f"Error in {context}: {str(error)}"
|
||||
if log_level >= logging.ERROR:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
elif log_level >= logging.WARNING:
|
||||
self.logger.warning(error_msg)
|
||||
else:
|
||||
self.logger.debug(error_msg)
|
||||
|
||||
# Generate user-friendly message if not provided
|
||||
if user_message is None:
|
||||
user_message = self._generate_user_message(error, context)
|
||||
|
||||
# Update UI status if available
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(f"Error: {user_message}", "error")
|
||||
|
||||
# Show dialog if requested (tests expect a direct UI call method)
|
||||
if show_dialog and self.ui_manager:
|
||||
# Prefer a UI method when provided by UI manager in tests
|
||||
show_fn = getattr(self.ui_manager, "show_error_dialog", None)
|
||||
if callable(show_fn):
|
||||
show_fn(user_message)
|
||||
else:
|
||||
self._show_error_dialog(user_message, error, context)
|
||||
|
||||
def handle_validation_error(
|
||||
self, field_name: str, error_message: str, suggested_fix: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Handle validation errors with specific guidance.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field with validation error
|
||||
error_message: Specific error message
|
||||
suggested_fix: Suggested fix for the user
|
||||
"""
|
||||
full_message = f"Validation error in {field_name}: {error_message}"
|
||||
if suggested_fix:
|
||||
full_message += f"\n\nSuggested fix: {suggested_fix}"
|
||||
|
||||
self.logger.warning(f"Validation error: {field_name} - {error_message}")
|
||||
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(
|
||||
f"Invalid {field_name}: {error_message}", "warning"
|
||||
)
|
||||
|
||||
def handle_file_error(
|
||||
self,
|
||||
operation: str,
|
||||
file_path: str,
|
||||
error: Exception,
|
||||
recovery_action: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Handle file operation errors with recovery suggestions.
|
||||
|
||||
Args:
|
||||
operation: Type of file operation (read, write, delete, etc.)
|
||||
file_path: Path to the file
|
||||
error: Exception that occurred
|
||||
recovery_action: Suggested recovery action
|
||||
"""
|
||||
context = f"File {operation}: {file_path}"
|
||||
user_message = f"Failed to {operation} file: {file_path}"
|
||||
|
||||
if recovery_action:
|
||||
user_message += f"\n\nSuggested action: {recovery_action}"
|
||||
|
||||
self.handle_error(error, context, user_message)
|
||||
|
||||
def handle_data_error(
|
||||
self,
|
||||
operation: str,
|
||||
data_type: str,
|
||||
error: Exception,
|
||||
recovery_suggestions: list[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle data-related errors with specific guidance.
|
||||
|
||||
Args:
|
||||
operation: Data operation being performed
|
||||
data_type: Type of data involved
|
||||
error: Exception that occurred
|
||||
recovery_suggestions: List of recovery suggestions
|
||||
"""
|
||||
context = f"Data {operation}: {data_type}"
|
||||
user_message = f"Data error during {operation} of {data_type}"
|
||||
|
||||
if recovery_suggestions:
|
||||
user_message += "\n\nTry these solutions:\n"
|
||||
user_message += "\n".join(
|
||||
f"• {suggestion}" for suggestion in recovery_suggestions
|
||||
)
|
||||
|
||||
self.handle_error(error, context, user_message)
|
||||
|
||||
def log_performance_warning(
|
||||
self, operation: str, duration_seconds: float, threshold_seconds: float = 1.0
|
||||
) -> None:
|
||||
"""
|
||||
Log performance warnings for slow operations.
|
||||
|
||||
Args:
|
||||
operation: Operation that was slow
|
||||
duration_seconds: How long it took
|
||||
threshold_seconds: Threshold for considering it slow
|
||||
"""
|
||||
if duration_seconds > threshold_seconds:
|
||||
self.logger.warning(
|
||||
f"Performance warning: {operation} took {duration_seconds:.2f}s "
|
||||
f"(threshold: {threshold_seconds:.2f}s)"
|
||||
)
|
||||
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(
|
||||
f"Operation completed but was slow: {operation}", "warning"
|
||||
)
|
||||
|
||||
def get_error_summary(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get summary of errors that have occurred.
|
||||
|
||||
Returns:
|
||||
Dictionary with error statistics
|
||||
"""
|
||||
return {
|
||||
"total_errors": sum(self.error_counts.values()),
|
||||
"unique_errors": len(self.error_counts),
|
||||
"error_counts": self.error_counts.copy(),
|
||||
"last_error_times": self.last_error_time.copy(),
|
||||
}
|
||||
|
||||
def _generate_user_message(self, error: Exception, context: str) -> str:
|
||||
"""Generate user-friendly error message based on error type."""
|
||||
error_type = type(error).__name__
|
||||
|
||||
# Common error type mappings
|
||||
user_messages = {
|
||||
"FileNotFoundError": "The requested file could not be found.",
|
||||
"PermissionError": "Permission denied. Check file permissions.",
|
||||
"ValueError": "Invalid data format or value.",
|
||||
"TypeError": "Incorrect data type provided.",
|
||||
"KeyError": "Required data field is missing.",
|
||||
"ConnectionError": "Network connection failed.",
|
||||
"MemoryError": "Insufficient memory to complete operation.",
|
||||
"OSError": "System operation failed.",
|
||||
}
|
||||
|
||||
base_message = user_messages.get(
|
||||
error_type, f"An unexpected error occurred: {str(error)}"
|
||||
)
|
||||
return f"{base_message} (Context: {context})"
|
||||
|
||||
def _show_error_dialog(
|
||||
self, user_message: str, error: Exception, context: str
|
||||
) -> None:
|
||||
"""Show error dialog to user with details."""
|
||||
from tkinter import messagebox
|
||||
|
||||
# For now, show a simple error dialog
|
||||
# In a more advanced implementation, we could show a custom dialog
|
||||
# with error details, reporting options, etc.
|
||||
|
||||
title = f"Error in {context}"
|
||||
messagebox.showerror(title, user_message)
|
||||
|
||||
|
||||
class OperationTimer:
|
||||
"""Context manager for timing operations and detecting performance issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_handler: ErrorHandler | None,
|
||||
operation_name: str,
|
||||
warning_threshold: float = 1.0,
|
||||
):
|
||||
"""
|
||||
Initialize operation timer.
|
||||
|
||||
Args:
|
||||
operation_name: Name of the operation being timed
|
||||
error_handler: Error handler for performance warnings
|
||||
warning_threshold: Threshold in seconds for performance warnings
|
||||
"""
|
||||
self.error_handler = error_handler
|
||||
self.operation_name = operation_name
|
||||
self.warning_threshold = warning_threshold
|
||||
self.start_time: float | None = None
|
||||
|
||||
def __enter__(self):
|
||||
"""Start timing the operation."""
|
||||
import time
|
||||
|
||||
self.start_time = time.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc_val, _exc_tb):
|
||||
"""End timing and check for performance issues."""
|
||||
import time
|
||||
|
||||
if self.start_time is not None:
|
||||
duration = time.time() - self.start_time
|
||||
|
||||
if duration > self.warning_threshold and self.error_handler:
|
||||
self.error_handler.log_performance_warning(
|
||||
self.operation_name, duration, self.warning_threshold
|
||||
)
|
||||
|
||||
# Don't suppress any exceptions
|
||||
return False
|
||||
|
||||
|
||||
def handle_exceptions(error_handler: ErrorHandler, context: str = "Operation"):
|
||||
"""
|
||||
Decorator for automatic exception handling.
|
||||
|
||||
Args:
|
||||
error_handler: ErrorHandler instance
|
||||
context: Context description for error logging
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
error_handler.handle_error(e, f"{context}:{func.__name__}")
|
||||
# Re-raise the exception if it's critical
|
||||
if isinstance(e, MemoryError | KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
return None
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class UserFeedback:
|
||||
"""Enhanced user feedback system with progress tracking."""
|
||||
|
||||
def __init__(self, ui_manager=None, logger: logging.Logger | None = None):
|
||||
"""
|
||||
Initialize user feedback system.
|
||||
|
||||
Args:
|
||||
ui_manager: UI manager for status updates
|
||||
logger: Logger for debugging feedback operations
|
||||
"""
|
||||
self.ui_manager = ui_manager
|
||||
self.logger = logger
|
||||
self.current_operation: str | None = None
|
||||
self.operation_start_time: float | None = None
|
||||
|
||||
def start_operation(
|
||||
self, operation_name: str, estimated_duration: float | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Start a long-running operation with user feedback.
|
||||
|
||||
Args:
|
||||
operation_name: Name of the operation
|
||||
estimated_duration: Estimated duration in seconds (optional)
|
||||
"""
|
||||
import time
|
||||
|
||||
self.current_operation = operation_name
|
||||
self.operation_start_time = time.time()
|
||||
|
||||
if self.ui_manager:
|
||||
message = f"Starting: {operation_name}"
|
||||
if estimated_duration:
|
||||
message += f" (estimated: {estimated_duration:.1f}s)"
|
||||
self.ui_manager.update_status(message, "info")
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"Started operation: {operation_name}")
|
||||
|
||||
def update_progress(
|
||||
self, progress_text: str, percentage: float | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Update progress of current operation.
|
||||
|
||||
Args:
|
||||
progress_text: Progress description
|
||||
percentage: Progress percentage (0-100, optional)
|
||||
"""
|
||||
if not self.current_operation:
|
||||
return
|
||||
|
||||
if self.ui_manager:
|
||||
message = f"{self.current_operation}: {progress_text}"
|
||||
if percentage is not None:
|
||||
message += f" ({percentage:.1f}%)"
|
||||
self.ui_manager.update_status(message, "info")
|
||||
|
||||
def complete_operation(self, success: bool = True, final_message: str = "") -> None:
|
||||
"""
|
||||
Complete the current operation with final status.
|
||||
|
||||
Args:
|
||||
success: Whether operation completed successfully
|
||||
final_message: Final status message
|
||||
"""
|
||||
if not self.current_operation:
|
||||
return
|
||||
|
||||
import time
|
||||
|
||||
duration = None
|
||||
if self.operation_start_time:
|
||||
duration = time.time() - self.operation_start_time
|
||||
|
||||
if self.ui_manager:
|
||||
if final_message:
|
||||
message = final_message
|
||||
else:
|
||||
status_word = "completed" if success else "failed"
|
||||
message = f"{self.current_operation} {status_word}"
|
||||
|
||||
if duration:
|
||||
message += f" ({duration:.1f}s)"
|
||||
|
||||
status_type = "success" if success else "error"
|
||||
self.ui_manager.update_status(message, status_type)
|
||||
|
||||
if self.logger:
|
||||
status_word = "completed" if success else "failed"
|
||||
log_message = f"Operation {status_word}: {self.current_operation}"
|
||||
if duration:
|
||||
log_message += f" (duration: {duration:.1f}s)"
|
||||
|
||||
if success:
|
||||
self.logger.info(log_message)
|
||||
else:
|
||||
self.logger.error(log_message)
|
||||
|
||||
# Reset operation tracking
|
||||
self.current_operation = None
|
||||
self.operation_start_time = None
|
||||
"""Compatibility shim for error handling utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from thechart.core.error_handler import ( # noqa: F401
|
||||
ErrorHandler,
|
||||
OperationTimer,
|
||||
UserFeedback,
|
||||
handle_exceptions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ErrorHandler",
|
||||
"OperationTimer",
|
||||
"handle_exceptions",
|
||||
"UserFeedback",
|
||||
]
|
||||
|
||||
+6
-438
@@ -1,443 +1,11 @@
|
||||
"""
|
||||
Export Manager for TheChart Application
|
||||
"""Compatibility shim for ExportManager.
|
||||
|
||||
Handles exporting data and graphs to various formats:
|
||||
- CSV data to JSON, XML
|
||||
- Graphs to PDF (with data tables)
|
||||
Canonical implementation lives in `thechart.export.export_manager`.
|
||||
This keeps `from export_manager import ExportManager` working.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.dom import minidom
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from thechart.export import ExportManager # noqa: F401
|
||||
|
||||
from data_manager import DataManager
|
||||
from graph_manager import GraphManager
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_manager import PathologyManager
|
||||
|
||||
|
||||
class ExportManager:
|
||||
"""Handle data and graph export operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_manager: DataManager,
|
||||
graph_manager: GraphManager,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self.data_manager = data_manager
|
||||
self.graph_manager = graph_manager
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
self.logger = logger
|
||||
|
||||
def export_data_to_json(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to JSON format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
|
||||
# Convert DataFrame to dictionary with better structure
|
||||
export_data = {
|
||||
"metadata": {
|
||||
"export_date": datetime.now().isoformat(),
|
||||
"total_entries": len(df),
|
||||
"date_range": {
|
||||
"start": df["date"].min() if not df.empty else None,
|
||||
"end": df["date"].max() if not df.empty else None,
|
||||
},
|
||||
"pathologies": list(self.pathology_manager.get_pathology_keys()),
|
||||
"medicines": list(self.medicine_manager.get_medicine_keys()),
|
||||
},
|
||||
"entries": df.to_dict(orient="records"),
|
||||
}
|
||||
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.info(f"Data exported to JSON: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to JSON: {str(e)}")
|
||||
return False
|
||||
|
||||
def export_data_to_xml(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to XML format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
|
||||
# Create root element
|
||||
root = Element("thechart_data")
|
||||
|
||||
# Add metadata
|
||||
metadata = SubElement(root, "metadata")
|
||||
SubElement(metadata, "export_date").text = datetime.now().isoformat()
|
||||
SubElement(metadata, "total_entries").text = str(len(df))
|
||||
|
||||
# Date range
|
||||
date_range = SubElement(metadata, "date_range")
|
||||
SubElement(date_range, "start").text = (
|
||||
df["date"].min() if not df.empty else ""
|
||||
)
|
||||
SubElement(date_range, "end").text = (
|
||||
df["date"].max() if not df.empty else ""
|
||||
)
|
||||
|
||||
# Pathologies
|
||||
pathologies = SubElement(metadata, "pathologies")
|
||||
for pathology in self.pathology_manager.get_pathology_keys():
|
||||
SubElement(pathologies, "pathology").text = pathology
|
||||
|
||||
# Medicines
|
||||
medicines = SubElement(metadata, "medicines")
|
||||
for medicine in self.medicine_manager.get_medicine_keys():
|
||||
SubElement(medicines, "medicine").text = medicine
|
||||
|
||||
# Add entries
|
||||
entries = SubElement(root, "entries")
|
||||
for _, row in df.iterrows():
|
||||
entry = SubElement(entries, "entry")
|
||||
for column, value in row.items():
|
||||
elem = SubElement(entry, column.replace(" ", "_"))
|
||||
elem.text = str(value) if pd.notna(value) else ""
|
||||
|
||||
# Pretty print XML
|
||||
rough_string = tostring(root, "utf-8")
|
||||
reparsed = minidom.parseString(rough_string)
|
||||
pretty_xml = reparsed.toprettyxml(indent=" ")
|
||||
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
f.write(pretty_xml)
|
||||
|
||||
self.logger.info(f"Data exported to XML: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to XML: {str(e)}")
|
||||
return False
|
||||
|
||||
def _save_graph_as_image(self, temp_dir: Path) -> str | None:
|
||||
"""Save current graph as temporary image for PDF inclusion."""
|
||||
try:
|
||||
# Check if graph manager exists
|
||||
if self.graph_manager is None:
|
||||
self.logger.warning("No graph manager available for export")
|
||||
return None
|
||||
|
||||
# Check if graph manager and figure exist
|
||||
if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None:
|
||||
self.logger.warning("No graph figure available for export")
|
||||
return None
|
||||
|
||||
# Ensure graph is up to date with current data
|
||||
df = self.data_manager.load_data()
|
||||
if not df.empty:
|
||||
self.graph_manager.update_graph(df)
|
||||
else:
|
||||
self.logger.warning("No data available to update graph for export")
|
||||
return None
|
||||
|
||||
# Ensure temp directory exists
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_image_path = temp_dir / "graph.png"
|
||||
|
||||
# Save the current figure
|
||||
self.graph_manager.fig.savefig(
|
||||
str(temp_image_path),
|
||||
dpi=150,
|
||||
bbox_inches="tight",
|
||||
facecolor="white",
|
||||
edgecolor="none",
|
||||
)
|
||||
|
||||
# Ensure the figure data is properly flushed to disk
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
plt.draw()
|
||||
plt.pause(0.01) # Small pause to ensure file is written
|
||||
|
||||
# Verify the file was actually created and has content
|
||||
if not temp_image_path.exists():
|
||||
self.logger.error(
|
||||
f"Graph image file was not created: {temp_image_path}"
|
||||
)
|
||||
return None
|
||||
|
||||
if temp_image_path.stat().st_size == 0:
|
||||
self.logger.error(f"Graph image file is empty: {temp_image_path}")
|
||||
return None
|
||||
|
||||
self.logger.info(f"Graph image saved successfully: {temp_image_path}")
|
||||
return str(temp_image_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving graph image: {str(e)}")
|
||||
return None
|
||||
|
||||
def export_to_pdf(
|
||||
self,
|
||||
export_path: str,
|
||||
include_graph: bool = True,
|
||||
df: pd.DataFrame | None = None,
|
||||
) -> bool:
|
||||
"""Export data and optionally graph to PDF format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
|
||||
# Create PDF document in landscape format for better table/graph display
|
||||
doc = SimpleDocTemplate(
|
||||
export_path,
|
||||
pagesize=landscape(A4),
|
||||
rightMargin=72,
|
||||
leftMargin=72,
|
||||
topMargin=72,
|
||||
bottomMargin=18,
|
||||
)
|
||||
|
||||
# Get styles
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"CustomTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=18,
|
||||
spaceAfter=30,
|
||||
textColor=colors.darkblue,
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Title
|
||||
story.append(Paragraph("TheChart - Medication Tracker Export", title_style))
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Export metadata
|
||||
export_info = [
|
||||
f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f"Total Entries: {len(df) if not df.empty else 0}",
|
||||
]
|
||||
|
||||
if not df.empty:
|
||||
export_info.extend(
|
||||
[
|
||||
f"Date Range: {df['date'].min()} to {df['date'].max()}",
|
||||
(
|
||||
"Pathologies: "
|
||||
+ ", ".join(self.pathology_manager.get_pathology_keys())
|
||||
),
|
||||
(
|
||||
"Medicines: "
|
||||
+ ", ".join(self.medicine_manager.get_medicine_keys())
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
for info in export_info:
|
||||
story.append(Paragraph(info, styles["Normal"]))
|
||||
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Include graph if requested and available
|
||||
if include_graph:
|
||||
temp_dir = Path(export_path).parent / "temp_export"
|
||||
graph_path = None
|
||||
|
||||
try:
|
||||
graph_path = self._save_graph_as_image(temp_dir)
|
||||
if graph_path and os.path.exists(graph_path):
|
||||
# Add page break before graph for full page display
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(
|
||||
Paragraph("Data Visualization", styles["Heading2"])
|
||||
)
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Full page graph - maintain proportions while maximizing size
|
||||
# Let ReportLab scale proportionally to fit landscape page
|
||||
img = Image(graph_path, width=9 * inch, height=5.4 * inch)
|
||||
story.append(img)
|
||||
else:
|
||||
# Graph not available, add a note instead
|
||||
story.append(PageBreak())
|
||||
story.append(
|
||||
Paragraph("Data Visualization", styles["Heading2"])
|
||||
)
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(
|
||||
Paragraph(
|
||||
"Graph not available - no data to visualize or graph "
|
||||
"not generated yet.",
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error including graph in PDF: {str(e)}")
|
||||
# Add error note instead of failing completely
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("Data Visualization", styles["Heading2"]))
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Graph could not be included: {str(e)}", styles["Normal"]
|
||||
)
|
||||
)
|
||||
|
||||
# Add data table if we have data
|
||||
if not df.empty:
|
||||
# Start table on new page
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("Data Table", styles["Heading2"]))
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Prepare table data - include all columns for full display
|
||||
display_columns = ["date"]
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
display_columns.append(pathology_key)
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
display_columns.append(medicine_key)
|
||||
display_columns.append("note")
|
||||
|
||||
# Filter dataframe to display columns that exist
|
||||
available_columns = [
|
||||
col for col in display_columns if col in df.columns
|
||||
]
|
||||
display_df = df[available_columns].copy()
|
||||
|
||||
# Don't truncate notes - landscape format has full width
|
||||
# Keep notes as-is for complete data visibility
|
||||
|
||||
# Convert to table data
|
||||
table_data = [available_columns] # Headers
|
||||
for _, row in display_df.iterrows():
|
||||
table_data.append(
|
||||
[str(val) if pd.notna(val) else "" for val in row]
|
||||
)
|
||||
|
||||
# Calculate optimal column widths for landscape format
|
||||
col_widths = []
|
||||
for col in available_columns:
|
||||
if col == "date":
|
||||
col_widths.append(1.0 * inch) # Fixed width for dates
|
||||
elif col == "note":
|
||||
col_widths.append(3.5 * inch) # Wider for notes
|
||||
elif col in self.pathology_manager.get_pathology_keys():
|
||||
col_widths.append(0.8 * inch) # Narrow for pathology scores
|
||||
elif col in self.medicine_manager.get_medicine_keys():
|
||||
col_widths.append(0.8 * inch) # Narrow for medicine status
|
||||
else:
|
||||
col_widths.append(1.0 * inch) # Default width
|
||||
|
||||
# Create table with specified column widths and better styling
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
||||
# Left align for better readability
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
# Add more padding for better readability
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
# Slightly larger font for better readability
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("WORDWRAP", (0, 0), (-1, -1), True),
|
||||
# Alternating row colors for better visual separation
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[colors.beige, colors.lightgrey],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
story.append(table)
|
||||
else:
|
||||
story.append(PageBreak())
|
||||
story.append(
|
||||
Paragraph("No data available to export.", styles["Normal"])
|
||||
)
|
||||
|
||||
# Build PDF
|
||||
doc.build(story)
|
||||
|
||||
# Clean up temporary image file after PDF is built
|
||||
if include_graph:
|
||||
temp_dir = Path(export_path).parent / "temp_export"
|
||||
if graph_path and os.path.exists(graph_path):
|
||||
try:
|
||||
os.remove(graph_path)
|
||||
self.logger.debug(f"Cleaned up temporary image: {graph_path}")
|
||||
except OSError as e:
|
||||
self.logger.warning(f"Could not remove temp image: {e}")
|
||||
|
||||
# Clean up temp directory if empty
|
||||
if temp_dir.exists():
|
||||
with contextlib.suppress(OSError):
|
||||
temp_dir.rmdir()
|
||||
|
||||
self.logger.info(f"Data exported to PDF: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to PDF: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_export_info(self) -> dict[str, Any]:
|
||||
"""Get information about available data for export."""
|
||||
df = self.data_manager.load_data()
|
||||
|
||||
return {
|
||||
"total_entries": len(df) if not df.empty else 0,
|
||||
"date_range": {
|
||||
"start": df["date"].min() if not df.empty else None,
|
||||
"end": df["date"].max() if not df.empty else None,
|
||||
},
|
||||
"pathologies": list(self.pathology_manager.get_pathology_keys()),
|
||||
"medicines": list(self.medicine_manager.get_medicine_keys()),
|
||||
"has_data": not df.empty,
|
||||
}
|
||||
__all__ = ["ExportManager"]
|
||||
|
||||
+6
-274
@@ -1,279 +1,11 @@
|
||||
"""
|
||||
Export Window for TheChart Application
|
||||
"""Compatibility shim for ExportWindow.
|
||||
|
||||
Provides a GUI interface for exporting data and graphs to various formats.
|
||||
Canonical implementation now lives in `thechart.ui.export_window`.
|
||||
This keeps `from export_window import ExportWindow` working.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
from __future__ import annotations
|
||||
|
||||
from export_manager import ExportManager
|
||||
from thechart.ui.export_window import ExportWindow # noqa: F401
|
||||
|
||||
|
||||
class ExportWindow:
|
||||
"""Export window for data and graph export functionality."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Tk,
|
||||
export_manager: ExportManager,
|
||||
get_current_filtered_df: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
self.parent = parent
|
||||
self.export_manager = export_manager
|
||||
self._get_current_filtered_df = get_current_filtered_df
|
||||
|
||||
# Create the export window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Export Data")
|
||||
self.window.geometry("500x450") # Made taller to ensure buttons are visible
|
||||
self.window.resizable(False, False)
|
||||
|
||||
# Center the window
|
||||
self._center_window()
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
# Setup the UI
|
||||
self._setup_ui()
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the export window on the parent window."""
|
||||
self.window.update_idletasks()
|
||||
|
||||
# Get window dimensions
|
||||
width = self.window.winfo_width()
|
||||
height = self.window.winfo_height()
|
||||
|
||||
# Get parent window position and size
|
||||
parent_x = self.parent.winfo_rootx()
|
||||
parent_y = self.parent.winfo_rooty()
|
||||
parent_width = self.parent.winfo_width()
|
||||
parent_height = self.parent.winfo_height()
|
||||
|
||||
# Calculate position to center on parent
|
||||
x = parent_x + (parent_width // 2) - (width // 2)
|
||||
y = parent_y + (parent_height // 2) - (height // 2)
|
||||
|
||||
self.window.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Setup the export window UI."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.window, padding="15")
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold")
|
||||
)
|
||||
title_label.pack(pady=(0, 15))
|
||||
|
||||
# Create scrollable content area for the main content
|
||||
content_frame = ttk.Frame(main_frame)
|
||||
content_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Export info section
|
||||
self._create_info_section(content_frame)
|
||||
|
||||
# Export options section
|
||||
self._create_options_section(content_frame)
|
||||
|
||||
# Buttons section - always at the bottom
|
||||
self._create_buttons_section(main_frame)
|
||||
|
||||
def _create_info_section(self, parent: ttk.Frame) -> None:
|
||||
"""Create the data information section."""
|
||||
info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10")
|
||||
info_frame.pack(fill=tk.X, pady=(0, 20))
|
||||
|
||||
# Get export info
|
||||
export_info = self.export_manager.get_export_info()
|
||||
|
||||
# Display information
|
||||
if export_info["has_data"]:
|
||||
info_text = f"""Total Entries: {export_info["total_entries"]}
|
||||
Date Range: {export_info["date_range"]["start"]} to {export_info["date_range"]["end"]}
|
||||
Pathologies: {", ".join(export_info["pathologies"])}
|
||||
Medicines: {", ".join(export_info["medicines"])}"""
|
||||
else:
|
||||
info_text = "No data available for export."
|
||||
|
||||
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT)
|
||||
info_label.pack(anchor=tk.W)
|
||||
|
||||
def _create_options_section(self, parent: ttk.Frame) -> None:
|
||||
"""Create the export options section."""
|
||||
options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10")
|
||||
options_frame.pack(fill=tk.X, pady=(0, 20))
|
||||
|
||||
# Include graph option (for PDF export)
|
||||
self.include_graph_var = tk.BooleanVar(value=True)
|
||||
graph_check = ttk.Checkbutton(
|
||||
options_frame,
|
||||
text="Include graph in PDF export",
|
||||
variable=self.include_graph_var,
|
||||
)
|
||||
graph_check.pack(anchor=tk.W, pady=(0, 10))
|
||||
|
||||
# Export scope option
|
||||
self.scope_var = tk.StringVar(value="all")
|
||||
scope_frame = ttk.Frame(options_frame)
|
||||
scope_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT)
|
||||
ttk.Radiobutton(
|
||||
scope_frame, text="All data", variable=self.scope_var, value="all"
|
||||
).pack(side=tk.LEFT, padx=10)
|
||||
ttk.Radiobutton(
|
||||
scope_frame,
|
||||
text="Current (filtered) view",
|
||||
variable=self.scope_var,
|
||||
value="filtered",
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
# Format selection
|
||||
format_label = ttk.Label(options_frame, text="Export Format:")
|
||||
format_label.pack(anchor=tk.W)
|
||||
|
||||
self.format_var = tk.StringVar(value="JSON")
|
||||
formats = ["JSON", "XML", "PDF"]
|
||||
|
||||
for fmt in formats:
|
||||
radio = ttk.Radiobutton(
|
||||
options_frame, text=fmt, variable=self.format_var, value=fmt
|
||||
)
|
||||
radio.pack(anchor=tk.W, padx=(20, 0))
|
||||
|
||||
def _create_buttons_section(self, parent: ttk.Frame) -> None:
|
||||
"""Create the buttons section."""
|
||||
# Add a separator for visual clarity
|
||||
separator = ttk.Separator(parent, orient="horizontal")
|
||||
separator.pack(fill=tk.X, pady=(10, 10))
|
||||
|
||||
button_frame = ttk.Frame(parent)
|
||||
button_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
|
||||
# Export button with more prominent styling
|
||||
export_btn = ttk.Button(
|
||||
button_frame, text="Export...", command=self._handle_export
|
||||
)
|
||||
export_btn.pack(side=tk.LEFT, padx=(10, 10), pady=5)
|
||||
|
||||
# Cancel button
|
||||
cancel_btn = ttk.Button(
|
||||
button_frame, text="Cancel", command=self.window.destroy
|
||||
)
|
||||
cancel_btn.pack(side=tk.RIGHT, padx=(10, 10), pady=5)
|
||||
|
||||
def _handle_export(self) -> None:
|
||||
"""Handle the export button click."""
|
||||
# Check if we have data to export
|
||||
export_info = self.export_manager.get_export_info()
|
||||
if not export_info["has_data"]:
|
||||
messagebox.showwarning(
|
||||
"No Data", "There is no data available to export.", parent=self.window
|
||||
)
|
||||
return
|
||||
|
||||
# Get selected format
|
||||
selected_format = self.format_var.get()
|
||||
|
||||
# Define file types for dialog
|
||||
file_types = {
|
||||
"JSON": [("JSON files", "*.json"), ("All files", "*.*")],
|
||||
"XML": [("XML files", "*.xml"), ("All files", "*.*")],
|
||||
"PDF": [("PDF files", "*.pdf"), ("All files", "*.*")],
|
||||
}
|
||||
|
||||
# Default filename
|
||||
default_name = f"thechart_export.{selected_format.lower()}"
|
||||
|
||||
# Show save dialog
|
||||
filename = filedialog.asksaveasfilename(
|
||||
parent=self.window,
|
||||
title=f"Export as {selected_format}",
|
||||
defaultextension=f".{selected_format.lower()}",
|
||||
filetypes=file_types[selected_format],
|
||||
initialfile=default_name,
|
||||
)
|
||||
|
||||
if not filename:
|
||||
return
|
||||
|
||||
# Determine scope DataFrame (if requested and available)
|
||||
scoped_df = None
|
||||
if self.scope_var.get() == "filtered" and self._get_current_filtered_df:
|
||||
try:
|
||||
scoped_df = self._get_current_filtered_df()
|
||||
except Exception:
|
||||
scoped_df = None
|
||||
|
||||
# Perform export based on selected format
|
||||
success = False
|
||||
try:
|
||||
if selected_format == "JSON":
|
||||
success = self.export_manager.export_data_to_json(
|
||||
filename, df=scoped_df
|
||||
)
|
||||
elif selected_format == "XML":
|
||||
success = self.export_manager.export_data_to_xml(filename, df=scoped_df)
|
||||
elif selected_format == "PDF":
|
||||
include_graph = self.include_graph_var.get()
|
||||
success = self.export_manager.export_to_pdf(
|
||||
filename, include_graph=include_graph, df=scoped_df
|
||||
)
|
||||
|
||||
if success:
|
||||
messagebox.showinfo(
|
||||
"Export Successful",
|
||||
f"Data exported successfully to:\n{filename}",
|
||||
parent=self.window,
|
||||
)
|
||||
# Ask if user wants to open the file location
|
||||
if messagebox.askyesno(
|
||||
"Open Location",
|
||||
"Would you like to open the file location?",
|
||||
parent=self.window,
|
||||
):
|
||||
self._open_file_location(filename)
|
||||
|
||||
self.window.destroy()
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"Export Failed",
|
||||
f"Failed to export data as {selected_format}. "
|
||||
"Please check the logs for more details.",
|
||||
parent=self.window,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror(
|
||||
"Export Error",
|
||||
f"An error occurred during export:\n{str(e)}",
|
||||
parent=self.window,
|
||||
)
|
||||
|
||||
def _open_file_location(self, filepath: str) -> None:
|
||||
"""Open the file location in the system file manager."""
|
||||
try:
|
||||
file_path = Path(filepath)
|
||||
directory = file_path.parent
|
||||
|
||||
# Use system-specific command to open file manager
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["explorer", str(directory)], check=False)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", str(directory)], check=False)
|
||||
else: # Linux and other Unix-like systems
|
||||
subprocess.run(["xdg-open", str(directory)], check=False)
|
||||
|
||||
except Exception:
|
||||
# If opening file location fails, just ignore silently
|
||||
pass
|
||||
__all__ = ["ExportWindow"]
|
||||
|
||||
+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"]
|
||||
|
||||
+5
-111
@@ -1,117 +1,11 @@
|
||||
"""Application logging utilities.
|
||||
"""Compatibility shim for logger utilities.
|
||||
|
||||
This module centralizes logger initialization and honors environment-driven
|
||||
settings from `constants` (LOG_LEVEL, LOG_PATH, LOG_CLEAR).
|
||||
The canonical implementation resides in `thechart.core.logger`.
|
||||
This module keeps `from logger import init_logger` working for legacy code/tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sys as _sys
|
||||
from thechart.core.logger import init_logger # noqa: F401
|
||||
|
||||
try: # Optional dependency; fall back to plain logging if missing
|
||||
import colorlog # type: ignore
|
||||
except Exception: # pragma: no cover - defensive in case of runtime packaging
|
||||
colorlog = None
|
||||
|
||||
from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
|
||||
|
||||
# Allow tests that patch 'logger.*' to affect this module imported as 'src.logger'
|
||||
_sys.modules.setdefault("logger", _sys.modules.get(__name__))
|
||||
|
||||
|
||||
def _bool_from_str(value: str) -> bool:
|
||||
"""Parse a truthy string into a boolean.
|
||||
|
||||
Accepts: '1', 'true', 'yes', 'y', 'on' (case-insensitive) as True.
|
||||
Everything else is False.
|
||||
"""
|
||||
|
||||
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _level_from_str(level: str) -> int:
|
||||
"""Map a string like 'INFO' to a logging level, defaulting to INFO."""
|
||||
|
||||
try:
|
||||
return getattr(logging, level.upper())
|
||||
except AttributeError:
|
||||
return logging.INFO
|
||||
|
||||
|
||||
def init_logger(dunder_name: str, testing_mode: bool) -> logging.Logger:
|
||||
"""Initialize and return a configured logger.
|
||||
|
||||
- Ensures the log directory exists (LOG_PATH).
|
||||
- Respects LOG_CLEAR: writes files in overwrite mode when true.
|
||||
- Respects LOG_LEVEL for non-testing runs; testing forces DEBUG.
|
||||
- Prevents duplicate handlers on repeated initialization.
|
||||
"""
|
||||
|
||||
log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
|
||||
|
||||
# Do not create directories here to honor init tests mocking mkdir/existence.
|
||||
|
||||
# Configure logger instance
|
||||
logger = logging.getLogger(dunder_name)
|
||||
logger.propagate = False
|
||||
|
||||
# Clear existing handlers to avoid duplicates in re-inits (e.g., tests)
|
||||
if logger.handlers:
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
with contextlib.suppress(Exception):
|
||||
h.close()
|
||||
|
||||
# Level selection
|
||||
if testing_mode:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(_level_from_str(LOG_LEVEL))
|
||||
|
||||
# Console handler (colored if colorlog available)
|
||||
if colorlog is not None:
|
||||
bold_seq = "\033[1m"
|
||||
colorlog_format = f"{bold_seq} %(log_color)s {log_format}"
|
||||
colorlog.basicConfig(format=colorlog_format)
|
||||
sh = colorlog.StreamHandler()
|
||||
sh.setLevel(logger.level)
|
||||
sh.setFormatter(colorlog.ColoredFormatter(colorlog_format))
|
||||
else:
|
||||
sh = logging.StreamHandler()
|
||||
sh.setLevel(logger.level)
|
||||
sh.setFormatter(logging.Formatter(log_format))
|
||||
logger.addHandler(sh)
|
||||
|
||||
# File handlers (overwrite if LOG_CLEAR truthy)
|
||||
write_mode = "w" if _bool_from_str(LOG_CLEAR) else "a"
|
||||
formatter = logging.Formatter(log_format)
|
||||
|
||||
try:
|
||||
fh_all = logging.FileHandler(
|
||||
f"{LOG_PATH}/app.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_all.setLevel(logging.DEBUG)
|
||||
fh_all.setFormatter(formatter)
|
||||
logger.addHandler(fh_all)
|
||||
|
||||
fh_warn = logging.FileHandler(
|
||||
f"{LOG_PATH}/app.warning.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_warn.setLevel(logging.WARNING)
|
||||
fh_warn.setFormatter(formatter)
|
||||
logger.addHandler(fh_warn)
|
||||
|
||||
fh_err = logging.FileHandler(
|
||||
f"{LOG_PATH}/app.error.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_err.setLevel(logging.ERROR)
|
||||
fh_err.setFormatter(formatter)
|
||||
logger.addHandler(fh_err)
|
||||
except (PermissionError, FileNotFoundError):
|
||||
# In restricted environments, fall back to console-only logging
|
||||
# Tests expect graceful handling (no exception propagated)
|
||||
pass
|
||||
|
||||
return logger
|
||||
__all__ = ["init_logger"]
|
||||
|
||||
+41
-20
@@ -9,29 +9,50 @@ from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from auto_save import AutoSaveManager, BackupManager
|
||||
from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
|
||||
from data_manager import DataManager
|
||||
from error_handler import ErrorHandler
|
||||
from export_manager import ExportManager
|
||||
from export_window import ExportWindow
|
||||
from graph_manager import GraphManager
|
||||
from init import logger
|
||||
from input_validator import InputValidator
|
||||
from medicine_management_window import MedicineManagementWindow
|
||||
from medicine_manager import MedicineManager
|
||||
from pathology_management_window import PathologyManagementWindow
|
||||
from pathology_manager import PathologyManager
|
||||
from preferences import get_config_dir, get_pref, save_preferences, set_pref
|
||||
from search_filter import DataFilter
|
||||
from settings_window import SettingsWindow
|
||||
from theme_manager import ThemeManager
|
||||
from ui_manager import UIManager
|
||||
from undo_manager import UndoAction, UndoManager
|
||||
from thechart.analytics import GraphManager
|
||||
from thechart.core.auto_save import AutoSaveManager, BackupManager
|
||||
from thechart.core.constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
|
||||
from thechart.core.error_handler import ErrorHandler
|
||||
from thechart.core.logger import init_logger
|
||||
from thechart.core.preferences import (
|
||||
get_config_dir,
|
||||
get_pref,
|
||||
save_preferences,
|
||||
set_pref,
|
||||
)
|
||||
from thechart.core.undo_manager import UndoAction, UndoManager
|
||||
from thechart.data import DataManager
|
||||
from thechart.export.export_manager import ExportManager
|
||||
from thechart.managers import MedicineManager, PathologyManager
|
||||
from thechart.search.search_filter import DataFilter
|
||||
from thechart.ui import ThemeManager, UIManager
|
||||
from thechart.ui.export_window import ExportWindow
|
||||
from thechart.ui.medicine_management_window import MedicineManagementWindow
|
||||
from thechart.ui.pathology_management_window import PathologyManagementWindow
|
||||
from thechart.ui.settings_window import SettingsWindow
|
||||
from thechart.validation import InputValidator
|
||||
|
||||
# Provide alias module name expected by tests (they patch 'main.*')
|
||||
sys.modules.setdefault("main", sys.modules[__name__])
|
||||
|
||||
# Initialize module-level logger via canonical util
|
||||
testing_mode = bool(LOG_LEVEL == "DEBUG")
|
||||
logger = init_logger("thechart.app", testing_mode=testing_mode)
|
||||
|
||||
# Optional log clearing aligned with legacy behavior
|
||||
if LOG_CLEAR == "True":
|
||||
for _fp in (
|
||||
f"{LOG_PATH}/thechart.log",
|
||||
f"{LOG_PATH}/thechart.warning.log",
|
||||
f"{LOG_PATH}/thechart.error.log",
|
||||
):
|
||||
try:
|
||||
with open(_fp, "w", encoding="utf-8"):
|
||||
pass
|
||||
except Exception:
|
||||
# Non-fatal in app context
|
||||
pass
|
||||
|
||||
|
||||
class MedTrackerApp:
|
||||
def __init__(self, root: tk.Tk) -> None:
|
||||
@@ -1230,7 +1251,7 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
||||
return
|
||||
try:
|
||||
# Local import to defer module load cost until first use
|
||||
from search_filter_ui import SearchFilterWidget # type: ignore
|
||||
from thechart.ui import SearchFilterWidget # type: ignore
|
||||
|
||||
self.search_filter_widget = SearchFilterWidget(
|
||||
self.main_frame,
|
||||
|
||||
@@ -1,401 +1,12 @@
|
||||
"""
|
||||
Medicine management window for adding, editing, and removing medicines.
|
||||
"""Shim for backward compatibility.
|
||||
|
||||
Re-exports canonical implementation from thechart.ui.medicine_management_window.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
from __future__ import annotations
|
||||
|
||||
from medicine_manager import Medicine, MedicineManager
|
||||
|
||||
|
||||
class MedicineManagementWindow:
|
||||
"""Window for managing medicine configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, medicine_manager: MedicineManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Medicines")
|
||||
self.window.geometry("600x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_medicine_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (600 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"600x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the user interface."""
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
main_frame.grid_rowconfigure(1, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame, text="Medicine Management", font=("Arial", 14, "bold")
|
||||
)
|
||||
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10))
|
||||
|
||||
# Medicine list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Current Medicines")
|
||||
list_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(0, 10))
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for medicines
|
||||
columns = ("key", "name", "dosage", "quick_doses", "color", "default")
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Column headings
|
||||
self.tree.heading("key", text="Key")
|
||||
self.tree.heading("name", text="Name")
|
||||
self.tree.heading("dosage", text="Dosage Info")
|
||||
self.tree.heading("quick_doses", text="Quick Doses")
|
||||
self.tree.heading("color", text="Color")
|
||||
self.tree.heading("default", text="Default Enabled")
|
||||
|
||||
# Column widths
|
||||
self.tree.column("key", width=80)
|
||||
self.tree.column("name", width=100)
|
||||
self.tree.column("dosage", width=100)
|
||||
self.tree.column("quick_doses", width=120)
|
||||
self.tree.column("color", width=70)
|
||||
self.tree.column("default", width=100)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Add Medicine", command=self._add_medicine).grid(
|
||||
row=0, column=0, padx=(0, 5)
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Medicine", command=self._edit_medicine
|
||||
).grid(row=0, column=1, padx=5)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Medicine", command=self._remove_medicine
|
||||
).grid(row=0, column=2, padx=5)
|
||||
|
||||
ttk.Button(button_frame, text="Close", command=self._close_window).grid(
|
||||
row=0, column=3, padx=(5, 0)
|
||||
)
|
||||
|
||||
def _populate_medicine_list(self):
|
||||
"""Populate the medicine list."""
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Add medicines
|
||||
for medicine in self.medicine_manager.get_all_medicines().values():
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
medicine.key,
|
||||
medicine.display_name,
|
||||
medicine.dosage_info,
|
||||
", ".join(medicine.quick_doses),
|
||||
medicine.color,
|
||||
"Yes" if medicine.default_enabled else "No",
|
||||
),
|
||||
)
|
||||
|
||||
def _add_medicine(self):
|
||||
"""Add a new medicine."""
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, None, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _edit_medicine(self):
|
||||
"""Edit selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a medicine to edit.")
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
|
||||
if medicine:
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, medicine, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _remove_medicine(self):
|
||||
"""Remove selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a medicine to remove."
|
||||
)
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine_name = item["values"][1]
|
||||
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
f"Are you sure you want to remove '{medicine_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!",
|
||||
):
|
||||
if self.medicine_manager.remove_medicine(medicine_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{medicine_name}' removed successfully!"
|
||||
)
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{medicine_name}'.")
|
||||
|
||||
def _on_medicine_changed(self):
|
||||
"""Called when a medicine is added or edited."""
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application after medicine changes."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
def _close_window(self):
|
||||
"""Close the window."""
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
class MedicineEditDialog:
|
||||
"""Dialog for adding/editing a medicine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
medicine_manager: MedicineManager,
|
||||
medicine: Medicine | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.medicine = medicine
|
||||
self.callback = callback
|
||||
self.is_edit = medicine is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Medicine" if self.is_edit else "Add Medicine")
|
||||
self.dialog.geometry("400x350")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (400 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (350 // 2)
|
||||
self.dialog.geometry(f"400x350+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Fields
|
||||
fields_frame = ttk.Frame(main_frame)
|
||||
fields_frame.grid(row=0, column=0, sticky="ew", pady=(0, 15))
|
||||
fields_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
row = 0
|
||||
|
||||
# Key
|
||||
ttk.Label(fields_frame, text="Key:").grid(row=row, column=0, sticky="w", pady=5)
|
||||
self.key_var = tk.StringVar()
|
||||
key_entry = ttk.Entry(fields_frame, textvariable=self.key_var)
|
||||
key_entry.grid(row=row, column=1, sticky="ew", padx=(10, 0), pady=5)
|
||||
if self.is_edit:
|
||||
key_entry.configure(state="readonly")
|
||||
row += 1
|
||||
|
||||
# Display Name
|
||||
ttk.Label(fields_frame, text="Display Name:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.name_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Dosage Info
|
||||
ttk.Label(fields_frame, text="Dosage Info:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.dosage_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.dosage_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Quick Doses
|
||||
ttk.Label(fields_frame, text="Quick Doses:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.doses_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.doses_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(comma-separated, e.g. 25,50,100)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Color
|
||||
ttk.Label(fields_frame, text="Graph Color:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.color_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.color_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(hex color, e.g. #FF6B6B)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Default Enabled
|
||||
self.default_var = tk.BooleanVar()
|
||||
ttk.Checkbutton(
|
||||
fields_frame,
|
||||
text="Show in graph by default",
|
||||
variable=self.default_var,
|
||||
).grid(row=row, column=0, columnspan=2, sticky="w", pady=5)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0)
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_medicine).grid(
|
||||
row=0, column=0, padx=(0, 10)
|
||||
)
|
||||
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).grid(
|
||||
row=0, column=1
|
||||
)
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.medicine:
|
||||
self.key_var.set(self.medicine.key)
|
||||
self.name_var.set(self.medicine.display_name)
|
||||
self.dosage_var.set(self.medicine.dosage_info)
|
||||
self.doses_var.set(",".join(self.medicine.quick_doses))
|
||||
self.color_var.set(self.medicine.color)
|
||||
self.default_var.set(self.medicine.default_enabled)
|
||||
|
||||
def _save_medicine(self):
|
||||
"""Save the medicine."""
|
||||
# Validate fields
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
dosage = self.dosage_var.get().strip()
|
||||
doses_str = self.doses_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
|
||||
if not all([key, name, dosage, doses_str, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Parse quick doses
|
||||
try:
|
||||
quick_doses = [dose.strip() for dose in doses_str.split(",")]
|
||||
quick_doses = [dose for dose in quick_doses if dose] # Remove empty strings
|
||||
if not quick_doses:
|
||||
raise ValueError("At least one quick dose is required.")
|
||||
except Exception:
|
||||
messagebox.showerror("Error", "Quick doses must be comma-separated values.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
int(color[1:], 16) # Validate hex color
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create medicine object
|
||||
new_medicine = Medicine(
|
||||
key=key,
|
||||
display_name=name,
|
||||
dosage_info=dosage,
|
||||
quick_doses=quick_doses,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
)
|
||||
|
||||
# Save medicine
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.medicine_manager.update_medicine(
|
||||
self.medicine.key, new_medicine
|
||||
)
|
||||
else:
|
||||
success = self.medicine_manager.add_medicine(new_medicine)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Medicine {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} medicine.")
|
||||
try: # noqa: SIM105
|
||||
from thechart.ui.medicine_management_window import * # type: ignore # noqa: F401,F403
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
# Fallback for dev environments not using package layout
|
||||
from src.thechart.ui.medicine_management_window import * # type: ignore # noqa: F401,F403
|
||||
|
||||
+7
-191
@@ -1,195 +1,11 @@
|
||||
"""
|
||||
Medicine configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of medicine configurations.
|
||||
"""Legacy shim: import canonical manager from thechart.managers.
|
||||
|
||||
This module persists for backward compatibility with older imports
|
||||
(`from medicine_manager import MedicineManager`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from thechart.managers import Medicine, MedicineManager # noqa: F401
|
||||
|
||||
@dataclass
|
||||
class Medicine:
|
||||
"""Data class representing a medicine."""
|
||||
|
||||
key: str # Internal key (e.g., "bupropion")
|
||||
display_name: str # Display name (e.g., "Bupropion")
|
||||
dosage_info: str # Dosage information (e.g., "150/300 mg")
|
||||
quick_doses: list[str] # Common dose amounts for quick selection
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = False # Whether to show in graph by default
|
||||
|
||||
|
||||
class MedicineManager:
|
||||
"""Manages medicine configurations and provides access to medicine data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "medicines.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.medicines: dict[str, Medicine] = {}
|
||||
self._load_medicines()
|
||||
|
||||
def _get_default_medicines(self) -> list[Medicine]:
|
||||
"""Get the default medicine configuration."""
|
||||
return [
|
||||
Medicine(
|
||||
key="bupropion",
|
||||
display_name="Bupropion",
|
||||
dosage_info="150/300 mg",
|
||||
quick_doses=["150", "300"],
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="hydroxyzine",
|
||||
display_name="Hydroxyzine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50"],
|
||||
color="#4ECDC4",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="gabapentin",
|
||||
display_name="Gabapentin",
|
||||
dosage_info="100 mg",
|
||||
quick_doses=["100", "300", "600"],
|
||||
color="#45B7D1",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="propranolol",
|
||||
display_name="Propranolol",
|
||||
dosage_info="10 mg",
|
||||
quick_doses=["10", "20", "40"],
|
||||
color="#96CEB4",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="quetiapine",
|
||||
display_name="Quetiapine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50", "100"],
|
||||
color="#FFEAA7",
|
||||
default_enabled=False,
|
||||
),
|
||||
]
|
||||
|
||||
def _load_medicines(self) -> None:
|
||||
"""Load medicines from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.medicines = {}
|
||||
for medicine_data in data.get("medicines", []):
|
||||
medicine = Medicine(**medicine_data)
|
||||
self.medicines[medicine.key] = medicine
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.medicines)} medicines from {self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading medicines config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default medicine configuration."""
|
||||
default_medicines = self._get_default_medicines()
|
||||
self.medicines = {med.key: med for med in default_medicines}
|
||||
self.save_medicines()
|
||||
self.logger.info("Created default medicine configuration")
|
||||
|
||||
def save_medicines(self) -> bool:
|
||||
"""Save current medicines to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"medicines": [asdict(medicine) for medicine in self.medicines.values()]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.medicines)} medicines to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving medicines config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_medicines(self) -> dict[str, Medicine]:
|
||||
"""Get all medicines."""
|
||||
return self.medicines.copy()
|
||||
|
||||
def get_medicine(self, key: str) -> Medicine | None:
|
||||
"""Get a specific medicine by key."""
|
||||
return self.medicines.get(key)
|
||||
|
||||
def add_medicine(self, medicine: Medicine) -> bool:
|
||||
"""Add a new medicine."""
|
||||
if medicine.key in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{medicine.key}' already exists")
|
||||
return False
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def update_medicine(self, key: str, medicine: Medicine) -> bool:
|
||||
"""Update an existing medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != medicine.key:
|
||||
del self.medicines[key]
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def remove_medicine(self, key: str) -> bool:
|
||||
"""Remove a medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.medicines[key]
|
||||
return self.save_medicines()
|
||||
|
||||
def get_medicine_keys(self) -> list[str]:
|
||||
"""Get list of all medicine keys."""
|
||||
return list(self.medicines.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: med.display_name for key, med in self.medicines.items()}
|
||||
|
||||
def get_quick_doses(self, key: str) -> list[str]:
|
||||
"""Get quick dose options for a medicine."""
|
||||
medicine = self.medicines.get(key)
|
||||
return medicine.quick_doses if medicine else ["25", "50"]
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of medicine keys to graph colors."""
|
||||
return {key: med.color for key, med in self.medicines.items()}
|
||||
|
||||
def get_default_enabled_medicines(self) -> list[str]:
|
||||
"""Get list of medicines that should be enabled by default in graphs."""
|
||||
return [key for key, med in self.medicines.items() if med.default_enabled]
|
||||
|
||||
def get_medicine_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get medicine variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), f"{med.display_name} {med.dosage_info}")
|
||||
for key, med in self.medicines.items()
|
||||
}
|
||||
__all__ = ["Medicine", "MedicineManager"]
|
||||
|
||||
@@ -1,425 +1,12 @@
|
||||
"""
|
||||
Pathology management window for adding, editing, and removing pathologies.
|
||||
"""Shim for backward compatibility.
|
||||
|
||||
Re-exports canonical implementation from thechart.ui.pathology_management_window.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
from __future__ import annotations
|
||||
|
||||
from pathology_manager import Pathology, PathologyManager
|
||||
|
||||
|
||||
class PathologyManagementWindow:
|
||||
"""Window for managing pathology configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, pathology_manager: PathologyManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Pathologies")
|
||||
self.window.geometry("800x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_pathology_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (800 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"800x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI components."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Pathology list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Pathologies", padding="5")
|
||||
list_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
|
||||
main_frame.grid_rowconfigure(0, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for pathology list
|
||||
columns = (
|
||||
"Key",
|
||||
"Display Name",
|
||||
"Scale Info",
|
||||
"Color",
|
||||
"Default Enabled",
|
||||
"Scale Range",
|
||||
)
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Configure columns
|
||||
self.tree.heading("Key", text="Key")
|
||||
self.tree.heading("Display Name", text="Display Name")
|
||||
self.tree.heading("Scale Info", text="Scale Info")
|
||||
self.tree.heading("Color", text="Color")
|
||||
self.tree.heading("Default Enabled", text="Default Enabled")
|
||||
self.tree.heading("Scale Range", text="Scale Range")
|
||||
|
||||
self.tree.column("Key", width=120)
|
||||
self.tree.column("Display Name", width=150)
|
||||
self.tree.column("Scale Info", width=150)
|
||||
self.tree.column("Color", width=80)
|
||||
self.tree.column("Default Enabled", width=100)
|
||||
self.tree.column("Scale Range", width=100)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Buttons frame
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Add Pathology", command=self._add_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Pathology", command=self._edit_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Pathology", command=self._remove_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(button_frame, text="Close", command=self.window.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
def _populate_pathology_list(self):
|
||||
"""Populate the pathology list."""
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Add pathologies
|
||||
for pathology in self.pathology_manager.get_all_pathologies().values():
|
||||
scale_range = f"{pathology.scale_min}-{pathology.scale_max}"
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
pathology.key,
|
||||
pathology.display_name,
|
||||
pathology.scale_info,
|
||||
pathology.color,
|
||||
"Yes" if pathology.default_enabled else "No",
|
||||
scale_range,
|
||||
),
|
||||
)
|
||||
|
||||
def _add_pathology(self):
|
||||
"""Add a new pathology."""
|
||||
PathologyEditDialog(
|
||||
self.window, self.pathology_manager, None, self._on_pathology_changed
|
||||
)
|
||||
|
||||
def _edit_pathology(self):
|
||||
"""Edit selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a pathology to edit.")
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
|
||||
if pathology:
|
||||
PathologyEditDialog(
|
||||
self.window,
|
||||
self.pathology_manager,
|
||||
pathology,
|
||||
self._on_pathology_changed,
|
||||
)
|
||||
|
||||
def _remove_pathology(self):
|
||||
"""Remove selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a pathology to remove."
|
||||
)
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology_name = item["values"][1]
|
||||
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
f"Are you sure you want to remove '{pathology_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!",
|
||||
):
|
||||
if self.pathology_manager.remove_pathology(pathology_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{pathology_name}' removed successfully!"
|
||||
)
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{pathology_name}'.")
|
||||
|
||||
def _on_pathology_changed(self):
|
||||
"""Handle pathology changes."""
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
|
||||
class PathologyEditDialog:
|
||||
"""Dialog for adding/editing a pathology."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
pathology_manager: PathologyManager,
|
||||
pathology: Pathology | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.pathology = pathology
|
||||
self.callback = callback
|
||||
self.is_edit = pathology is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Pathology" if self.is_edit else "Add Pathology")
|
||||
self.dialog.geometry("450x400")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (450 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (400 // 2)
|
||||
self.dialog.geometry(f"450x400+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Form fields
|
||||
self.key_var = tk.StringVar()
|
||||
self.name_var = tk.StringVar()
|
||||
self.scale_info_var = tk.StringVar()
|
||||
self.color_var = tk.StringVar()
|
||||
self.default_var = tk.BooleanVar()
|
||||
self.scale_min_var = tk.IntVar(value=0)
|
||||
self.scale_max_var = tk.IntVar(value=10)
|
||||
self.orientation_var = tk.StringVar(value="normal")
|
||||
|
||||
# Key field
|
||||
ttk.Label(main_frame, text="Key:").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
key_entry = ttk.Entry(main_frame, textvariable=self.key_var, width=40)
|
||||
key_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5))
|
||||
ttk.Label(main_frame, text="(alphanumeric, underscores, hyphens only)").grid(
|
||||
row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Display name field
|
||||
ttk.Label(main_frame, text="Display Name:").grid(
|
||||
row=1, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.name_var, width=40).grid(
|
||||
row=1, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale info field
|
||||
ttk.Label(main_frame, text="Scale Info:").grid(
|
||||
row=2, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.scale_info_var, width=40).grid(
|
||||
row=2, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text='(e.g., "0:good, 10:bad")').grid(
|
||||
row=2, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale range
|
||||
scale_frame = ttk.Frame(main_frame)
|
||||
scale_frame.grid(row=3, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Label(main_frame, text="Scale Range:").grid(
|
||||
row=3, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Min:").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_min_var, width=5).grid(
|
||||
row=0, column=1, padx=(5, 10)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Max:").grid(row=0, column=2, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_max_var, width=5).grid(
|
||||
row=0, column=3, padx=5
|
||||
)
|
||||
|
||||
# Scale orientation
|
||||
ttk.Label(main_frame, text="Scale Orientation:").grid(
|
||||
row=4, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
orientation_frame = ttk.Frame(main_frame)
|
||||
orientation_frame.grid(row=4, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Normal (0=good)",
|
||||
variable=self.orientation_var,
|
||||
value="normal",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Inverted (0=bad)",
|
||||
variable=self.orientation_var,
|
||||
value="inverted",
|
||||
).grid(row=0, column=1, sticky="w", padx=(20, 0))
|
||||
|
||||
# Color field
|
||||
ttk.Label(main_frame, text="Color:").grid(
|
||||
row=5, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.color_var, width=40).grid(
|
||||
row=5, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text="(hex format, e.g., #FF6B6B)").grid(
|
||||
row=5, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Default enabled checkbox
|
||||
ttk.Checkbutton(
|
||||
main_frame, text="Show in graph by default", variable=self.default_var
|
||||
).grid(row=6, column=1, sticky="w", pady=(10, 15))
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_pathology).pack(
|
||||
side="right", padx=(5, 0)
|
||||
)
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
# Configure column weights
|
||||
main_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Focus on first field
|
||||
key_entry.focus()
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.pathology:
|
||||
self.key_var.set(self.pathology.key)
|
||||
self.name_var.set(self.pathology.display_name)
|
||||
self.scale_info_var.set(self.pathology.scale_info)
|
||||
self.color_var.set(self.pathology.color)
|
||||
self.default_var.set(self.pathology.default_enabled)
|
||||
self.scale_min_var.set(self.pathology.scale_min)
|
||||
self.scale_max_var.set(self.pathology.scale_max)
|
||||
self.orientation_var.set(self.pathology.scale_orientation)
|
||||
|
||||
def _save_pathology(self):
|
||||
"""Save the pathology."""
|
||||
# Validate fields
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
scale_info = self.scale_info_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
scale_min = self.scale_min_var.get()
|
||||
scale_max = self.scale_max_var.get()
|
||||
|
||||
if not all([key, name, scale_info, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Validate scale range
|
||||
if scale_min >= scale_max:
|
||||
messagebox.showerror("Error", "Scale minimum must be less than maximum.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
int(color[1:], 16) # Validate hex color
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create pathology object
|
||||
new_pathology = Pathology(
|
||||
key=key,
|
||||
display_name=name,
|
||||
scale_info=scale_info,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
scale_min=scale_min,
|
||||
scale_max=scale_max,
|
||||
scale_orientation=self.orientation_var.get(),
|
||||
)
|
||||
|
||||
# Save pathology
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.pathology_manager.update_pathology(
|
||||
self.pathology.key, new_pathology
|
||||
)
|
||||
else:
|
||||
success = self.pathology_manager.add_pathology(new_pathology)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Pathology {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} pathology.")
|
||||
try: # noqa: SIM105
|
||||
from thechart.ui.pathology_management_window import * # type: ignore # noqa: F401,F403
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
# Fallback for dev environments not using package layout
|
||||
from src.thechart.ui.pathology_management_window import * # type: ignore # noqa: F401,F403
|
||||
|
||||
+7
-195
@@ -1,199 +1,11 @@
|
||||
"""
|
||||
Pathology configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of pathology/symptom configurations.
|
||||
"""Legacy shim: import canonical manager from thechart.managers.
|
||||
|
||||
This module persists for backward compatibility with older imports
|
||||
(`from pathology_manager import PathologyManager`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from thechart.managers import Pathology, PathologyManager # noqa: F401
|
||||
|
||||
@dataclass
|
||||
class Pathology:
|
||||
"""Data class representing a pathology/symptom."""
|
||||
|
||||
key: str # Internal key (e.g., "depression")
|
||||
display_name: str # Display name (e.g., "Depression")
|
||||
scale_info: str # Scale information (e.g., "0:good, 10:bad")
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = True # Whether to show in graph by default
|
||||
scale_min: int = 0 # Minimum scale value
|
||||
scale_max: int = 10 # Maximum scale value
|
||||
scale_orientation: str = "normal" # "normal" (0=good) or "inverted" (0=bad)
|
||||
|
||||
|
||||
class PathologyManager:
|
||||
"""Manages pathology configurations and provides access to pathology data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "pathologies.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.pathologies: dict[str, Pathology] = {}
|
||||
self._load_pathologies()
|
||||
|
||||
def _get_default_pathologies(self) -> list[Pathology]:
|
||||
"""Get the default pathology configuration."""
|
||||
return [
|
||||
Pathology(
|
||||
key="depression",
|
||||
display_name="Depression",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="anxiety",
|
||||
display_name="Anxiety",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FFA726",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="sleep",
|
||||
display_name="Sleep Quality",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#66BB6A",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
Pathology(
|
||||
key="appetite",
|
||||
display_name="Appetite",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#42A5F5",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
]
|
||||
|
||||
def _load_pathologies(self) -> None:
|
||||
"""Load pathologies from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.pathologies = {}
|
||||
for pathology_data in data.get("pathologies", []):
|
||||
pathology = Pathology(**pathology_data)
|
||||
self.pathologies[pathology.key] = pathology
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.pathologies)} pathologies from "
|
||||
f"{self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading pathologies config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default pathology configuration."""
|
||||
default_pathologies = self._get_default_pathologies()
|
||||
self.pathologies = {path.key: path for path in default_pathologies}
|
||||
self.save_pathologies()
|
||||
self.logger.info("Created default pathology configuration")
|
||||
|
||||
def save_pathologies(self) -> bool:
|
||||
"""Save current pathologies to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"pathologies": [
|
||||
asdict(pathology) for pathology in self.pathologies.values()
|
||||
]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.pathologies)} pathologies to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving pathologies config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_pathologies(self) -> dict[str, Pathology]:
|
||||
"""Get all pathologies."""
|
||||
return self.pathologies.copy()
|
||||
|
||||
def get_pathology(self, key: str) -> Pathology | None:
|
||||
"""Get a specific pathology by key."""
|
||||
return self.pathologies.get(key)
|
||||
|
||||
def add_pathology(self, pathology: Pathology) -> bool:
|
||||
"""Add a new pathology."""
|
||||
if pathology.key in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{pathology.key}' already exists")
|
||||
return False
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def update_pathology(self, key: str, pathology: Pathology) -> bool:
|
||||
"""Update an existing pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != pathology.key:
|
||||
del self.pathologies[key]
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def remove_pathology(self, key: str) -> bool:
|
||||
"""Remove a pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.pathologies[key]
|
||||
return self.save_pathologies()
|
||||
|
||||
def get_pathology_keys(self) -> list[str]:
|
||||
"""Get list of all pathology keys."""
|
||||
return list(self.pathologies.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: path.display_name for key, path in self.pathologies.items()}
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of pathology keys to graph colors."""
|
||||
return {key: path.color for key, path in self.pathologies.items()}
|
||||
|
||||
def get_default_enabled_pathologies(self) -> list[str]:
|
||||
"""Get list of pathologies that should be enabled by default in graphs."""
|
||||
return [key for key, path in self.pathologies.items() if path.default_enabled]
|
||||
|
||||
def get_pathology_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get pathology variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), path.display_name)
|
||||
for key, path in self.pathologies.items()
|
||||
}
|
||||
|
||||
def get_scale_info(self, key: str) -> tuple[int, int, str, str]:
|
||||
"""Get scale information for a pathology."""
|
||||
pathology = self.get_pathology(key)
|
||||
if pathology:
|
||||
return (
|
||||
pathology.scale_min,
|
||||
pathology.scale_max,
|
||||
pathology.scale_info,
|
||||
pathology.scale_orientation,
|
||||
)
|
||||
return (0, 10, "0-10", "normal")
|
||||
__all__ = ["Pathology", "PathologyManager"]
|
||||
|
||||
+18
-111
@@ -1,117 +1,24 @@
|
||||
"""Application preferences with simple JSON persistence.
|
||||
"""Compatibility shim for preferences API.
|
||||
|
||||
API stays minimal: get_pref/set_pref for reads and writes, plus
|
||||
load_preferences/save_preferences to manage disk state.
|
||||
Canonical implementation lives in `thechart.core.preferences`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from thechart.core.preferences import ( # noqa: F401
|
||||
get_config_dir,
|
||||
get_pref,
|
||||
load_preferences,
|
||||
reset_preferences,
|
||||
save_preferences,
|
||||
set_pref,
|
||||
)
|
||||
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
# After a successful restore, offer to open the backups folder?
|
||||
"prompt_open_folder_after_restore": False,
|
||||
# Remember and restore window geometry between runs
|
||||
"remember_window_geometry": True,
|
||||
"last_window_geometry": "",
|
||||
# Keep window always on top
|
||||
"always_on_top": False,
|
||||
# Search/filter UI state
|
||||
"search_panel_visible": False,
|
||||
"last_filter_state": None,
|
||||
# Table column UX
|
||||
"column_widths": {},
|
||||
"last_sort": {"column": None, "ascending": True},
|
||||
# Data: archiving/rotation
|
||||
"archive_keep_years": 1,
|
||||
}
|
||||
|
||||
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
|
||||
|
||||
|
||||
def _config_dir() -> str:
|
||||
"""Return platform-appropriate config directory for TheChart."""
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||
return os.path.join(base, "TheChart")
|
||||
if sys.platform == "darwin":
|
||||
return os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"TheChart",
|
||||
)
|
||||
# Linux and others: follow XDG
|
||||
base = os.environ.get(
|
||||
"XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")
|
||||
)
|
||||
return os.path.join(base, "thechart")
|
||||
except Exception:
|
||||
# Fallback to current directory if anything goes wrong
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
return os.path.join(_config_dir(), "preferences.json")
|
||||
|
||||
|
||||
def get_config_dir() -> str:
|
||||
"""Public accessor for the application configuration directory."""
|
||||
return _config_dir()
|
||||
|
||||
|
||||
def load_preferences() -> None:
|
||||
"""Load preferences from disk if present, fallback to defaults."""
|
||||
global _PREFERENCES
|
||||
path = _config_path()
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
merged = dict(_DEFAULTS)
|
||||
merged.update(data)
|
||||
_PREFERENCES = merged
|
||||
except Exception:
|
||||
# Ignore corrupt or unreadable files; continue with current prefs
|
||||
pass
|
||||
|
||||
|
||||
def save_preferences() -> None:
|
||||
"""Persist preferences to disk atomically."""
|
||||
path = _config_path()
|
||||
directory = os.path.dirname(path)
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
tmp_path = path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_PREFERENCES, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
# Best-effort persistence; ignore failures silently
|
||||
pass
|
||||
|
||||
|
||||
def reset_preferences() -> None:
|
||||
"""Reset preferences in memory to defaults and persist to disk."""
|
||||
global _PREFERENCES
|
||||
_PREFERENCES = dict(_DEFAULTS)
|
||||
save_preferences()
|
||||
|
||||
|
||||
def get_pref(key: str, default: Any | None = None) -> Any:
|
||||
"""Get a preference value, or default if unset."""
|
||||
return _PREFERENCES.get(key, default)
|
||||
|
||||
|
||||
def set_pref(key: str, value: Any) -> None:
|
||||
"""Set a preference value in memory (call save_preferences to persist)."""
|
||||
_PREFERENCES[key] = value
|
||||
|
||||
|
||||
# Attempt to load preferences on import for convenience
|
||||
load_preferences()
|
||||
__all__ = [
|
||||
"get_config_dir",
|
||||
"load_preferences",
|
||||
"save_preferences",
|
||||
"reset_preferences",
|
||||
"get_pref",
|
||||
"set_pref",
|
||||
]
|
||||
|
||||
+12
-417
@@ -1,421 +1,16 @@
|
||||
"""Search and filter functionality for TheChart application."""
|
||||
"""Legacy shim for search/filter logic.
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
The canonical implementation lives in ``thechart.search``.
|
||||
This module re-exports those for backward compatibility with tests importing
|
||||
``src.search_filter``.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from __future__ import annotations
|
||||
|
||||
from thechart.search.search_filter import ( # noqa: F401
|
||||
DataFilter,
|
||||
QuickFilters,
|
||||
SearchHistory,
|
||||
)
|
||||
|
||||
class DataFilter:
|
||||
"""Handles filtering and searching of medical data."""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""
|
||||
Initialize data filter.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for debugging
|
||||
"""
|
||||
self.logger = logger
|
||||
self.active_filters = {}
|
||||
self.search_term = ""
|
||||
|
||||
def set_date_range_filter(
|
||||
self, start_date: str | None = None, end_date: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Set date range filter.
|
||||
|
||||
Args:
|
||||
start_date: Start date string (inclusive)
|
||||
end_date: End date string (inclusive)
|
||||
"""
|
||||
if start_date or end_date:
|
||||
self.active_filters["date_range"] = {"start": start_date, "end": end_date}
|
||||
elif "date_range" in self.active_filters:
|
||||
del self.active_filters["date_range"]
|
||||
|
||||
def set_medicine_filter(self, medicine_key: str, taken: bool) -> None:
|
||||
"""
|
||||
Filter by medicine taken status.
|
||||
|
||||
Args:
|
||||
medicine_key: Medicine identifier
|
||||
taken: Whether medicine was taken (True) or not taken (False)
|
||||
"""
|
||||
if "medicines" not in self.active_filters:
|
||||
self.active_filters["medicines"] = {}
|
||||
|
||||
self.active_filters["medicines"][medicine_key] = taken
|
||||
|
||||
def set_pathology_range_filter(
|
||||
self,
|
||||
pathology_key: str,
|
||||
min_score: int | None = None,
|
||||
max_score: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Filter by pathology score range.
|
||||
|
||||
Args:
|
||||
pathology_key: Pathology identifier
|
||||
min_score: Minimum score (inclusive)
|
||||
max_score: Maximum score (inclusive)
|
||||
"""
|
||||
if min_score is not None or max_score is not None:
|
||||
if "pathologies" not in self.active_filters:
|
||||
self.active_filters["pathologies"] = {}
|
||||
|
||||
self.active_filters["pathologies"][pathology_key] = {
|
||||
"min": min_score,
|
||||
"max": max_score,
|
||||
}
|
||||
|
||||
def set_search_term(self, search_term: str) -> None:
|
||||
"""
|
||||
Set text search term for notes and other text fields.
|
||||
|
||||
Args:
|
||||
search_term: Text to search for
|
||||
"""
|
||||
self.search_term = search_term.strip()
|
||||
|
||||
def clear_all_filters(self) -> None:
|
||||
"""Clear all active filters and search terms."""
|
||||
self.active_filters.clear()
|
||||
self.search_term = ""
|
||||
|
||||
def clear_filter(self, filter_type: str, filter_key: str | None = None) -> None:
|
||||
"""
|
||||
Clear specific filter.
|
||||
|
||||
Args:
|
||||
filter_type: Type of filter ("date_range", "medicines", "pathologies")
|
||||
filter_key: Specific key within filter type (optional)
|
||||
"""
|
||||
if filter_type in self.active_filters:
|
||||
if filter_key and isinstance(self.active_filters[filter_type], dict):
|
||||
if filter_key in self.active_filters[filter_type]:
|
||||
del self.active_filters[filter_type][filter_key]
|
||||
# Remove parent filter if empty
|
||||
if not self.active_filters[filter_type]:
|
||||
del self.active_filters[filter_type]
|
||||
else:
|
||||
del self.active_filters[filter_type]
|
||||
|
||||
def apply_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Apply all active filters to the dataframe.
|
||||
|
||||
Args:
|
||||
df: Input dataframe
|
||||
|
||||
Returns:
|
||||
Filtered dataframe
|
||||
"""
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
filtered_df = df.copy()
|
||||
|
||||
try:
|
||||
# Apply date range filter
|
||||
filtered_df = self._apply_date_filter(filtered_df)
|
||||
|
||||
# Apply medicine filters
|
||||
filtered_df = self._apply_medicine_filters(filtered_df)
|
||||
|
||||
# Apply pathology filters
|
||||
filtered_df = self._apply_pathology_filters(filtered_df)
|
||||
|
||||
# Apply text search
|
||||
filtered_df = self._apply_text_search(filtered_df)
|
||||
|
||||
if self.logger:
|
||||
original_count = len(df)
|
||||
filtered_count = len(filtered_df)
|
||||
self.logger.debug(
|
||||
f"Applied filters: {original_count} -> {filtered_count} entries"
|
||||
)
|
||||
|
||||
return filtered_df
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"Error applying filters: {e}")
|
||||
return df # Return original data if filtering fails
|
||||
|
||||
def _apply_date_filter(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply date range filter."""
|
||||
if "date_range" not in self.active_filters:
|
||||
return df
|
||||
|
||||
date_filter = self.active_filters["date_range"]
|
||||
start_date = date_filter.get("start")
|
||||
end_date = date_filter.get("end")
|
||||
|
||||
if not start_date and not end_date:
|
||||
return df
|
||||
|
||||
# Support both legacy lowercase 'date' and capitalized 'Date'
|
||||
date_col = (
|
||||
"date" if "date" in df.columns else "Date" if "Date" in df.columns else None
|
||||
)
|
||||
if not date_col:
|
||||
return df
|
||||
|
||||
try:
|
||||
# Convert date column to datetime – attempt multiple formats safely
|
||||
df_dates = pd.to_datetime(df[date_col], errors="coerce")
|
||||
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
if start_date:
|
||||
mask &= df_dates >= pd.to_datetime(start_date, errors="coerce")
|
||||
if end_date:
|
||||
mask &= df_dates <= pd.to_datetime(end_date, errors="coerce")
|
||||
|
||||
return df[mask]
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.warning(f"Date filter failed: {e}")
|
||||
return df
|
||||
|
||||
def _apply_medicine_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply medicine filters."""
|
||||
if "medicines" not in self.active_filters:
|
||||
return df
|
||||
|
||||
medicine_filters = self.active_filters["medicines"]
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
for medicine_key, should_be_taken in medicine_filters.items():
|
||||
if medicine_key in df.columns:
|
||||
col = df[medicine_key]
|
||||
# Heuristic:
|
||||
# - If object dtype and values look like time:dose strings,
|
||||
# use string presence
|
||||
# - Else if numeric (or numeric-like), use non-zero for taken,
|
||||
# zero for not taken
|
||||
# - Else fallback to string presence
|
||||
if col.dtype == object:
|
||||
s = col.astype(str)
|
||||
looks_time_dose = s.str.contains(
|
||||
r":|\|", regex=True, na=False
|
||||
).any()
|
||||
if looks_time_dose:
|
||||
if should_be_taken:
|
||||
mask &= s.str.len() > 0
|
||||
else:
|
||||
mask &= s.str.len() == 0
|
||||
continue
|
||||
# Try numeric-like strings
|
||||
numeric = pd.to_numeric(col, errors="coerce")
|
||||
if numeric.notna().any():
|
||||
if should_be_taken:
|
||||
mask &= numeric.fillna(0) != 0
|
||||
else:
|
||||
mask &= numeric.fillna(0) == 0
|
||||
else:
|
||||
if should_be_taken:
|
||||
mask &= s.str.len() > 0
|
||||
else:
|
||||
mask &= s.str.len() == 0
|
||||
else:
|
||||
# Numeric dtype
|
||||
if should_be_taken:
|
||||
mask &= col.fillna(0) != 0
|
||||
else:
|
||||
mask &= col.fillna(0) == 0
|
||||
|
||||
return df[mask]
|
||||
|
||||
def _apply_pathology_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply pathology score range filters."""
|
||||
if "pathologies" not in self.active_filters:
|
||||
return df
|
||||
|
||||
pathology_filters = self.active_filters["pathologies"]
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
for pathology_key, score_range in pathology_filters.items():
|
||||
if pathology_key in df.columns:
|
||||
# Coerce to numeric; non-numeric -> NaN (excluded by comparisons)
|
||||
col = pd.to_numeric(df[pathology_key], errors="coerce")
|
||||
min_score = score_range.get("min")
|
||||
max_score = score_range.get("max")
|
||||
if min_score is not None:
|
||||
mask &= col >= min_score
|
||||
if max_score is not None:
|
||||
mask &= col <= max_score
|
||||
|
||||
return df[mask]
|
||||
|
||||
def _apply_text_search(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply text search to notes and other text fields."""
|
||||
if not self.search_term:
|
||||
return df
|
||||
|
||||
# Create regex pattern for case-insensitive search
|
||||
try:
|
||||
pattern = re.compile(re.escape(self.search_term), re.IGNORECASE)
|
||||
except re.error: # pragma: no cover - defensive
|
||||
pattern = self.search_term.lower()
|
||||
|
||||
mask = pd.Series(False, index=df.index)
|
||||
|
||||
# Support both Notes/note and Date/date columns
|
||||
note_cols = [c for c in ("Notes", "Note", "note", "notes") if c in df.columns]
|
||||
date_cols = [c for c in ("Date", "date") if c in df.columns]
|
||||
|
||||
for col in note_cols + date_cols:
|
||||
if isinstance(pattern, re.Pattern):
|
||||
mask |= df[col].astype(str).str.contains(pattern, na=False)
|
||||
else:
|
||||
mask |= df[col].astype(str).str.lower().str.contains(pattern, na=False)
|
||||
|
||||
return df[mask]
|
||||
|
||||
def get_filter_summary(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get summary of active filters.
|
||||
|
||||
Returns:
|
||||
Dictionary describing active filters
|
||||
"""
|
||||
summary = {
|
||||
"has_filters": bool(self.active_filters or self.search_term),
|
||||
"filter_count": len(self.active_filters),
|
||||
"search_term": self.search_term,
|
||||
"filters": {},
|
||||
}
|
||||
|
||||
# Date range summary
|
||||
if "date_range" in self.active_filters:
|
||||
date_range = self.active_filters["date_range"]
|
||||
summary["filters"]["date_range"] = {
|
||||
"start": date_range.get("start", "Any"),
|
||||
"end": date_range.get("end", "Any"),
|
||||
}
|
||||
|
||||
# Medicine filters summary
|
||||
if "medicines" in self.active_filters:
|
||||
medicine_filters = self.active_filters["medicines"]
|
||||
summary["filters"]["medicines"] = {
|
||||
"taken": [k for k, v in medicine_filters.items() if v],
|
||||
"not_taken": [k for k, v in medicine_filters.items() if not v],
|
||||
}
|
||||
|
||||
# Pathology filters summary
|
||||
if "pathologies" in self.active_filters:
|
||||
pathology_filters = self.active_filters["pathologies"]
|
||||
summary["filters"]["pathologies"] = {}
|
||||
for key, range_filter in pathology_filters.items():
|
||||
min_val = range_filter.get("min", "Any")
|
||||
max_val = range_filter.get("max", "Any")
|
||||
summary["filters"]["pathologies"][key] = f"{min_val} - {max_val}"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
class QuickFilters:
|
||||
"""Predefined quick filters mirroring test expectations."""
|
||||
|
||||
@staticmethod
|
||||
def last_week(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=6) # inclusive 7 days
|
||||
data_filter.set_date_range_filter(str(start_date), str(end_date))
|
||||
|
||||
@staticmethod
|
||||
def last_month(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=29) # inclusive 30 days
|
||||
data_filter.set_date_range_filter(str(start_date), str(end_date))
|
||||
|
||||
@staticmethod
|
||||
def this_month(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now().date()
|
||||
start_date = now.replace(day=1)
|
||||
data_filter.set_date_range_filter(str(start_date), str(now))
|
||||
|
||||
@staticmethod
|
||||
def high_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None:
|
||||
for pathology_key in pathology_keys:
|
||||
data_filter.set_pathology_range_filter(pathology_key, min_score=8)
|
||||
|
||||
@staticmethod
|
||||
def low_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None:
|
||||
for pathology_key in pathology_keys:
|
||||
data_filter.set_pathology_range_filter(pathology_key, max_score=3)
|
||||
|
||||
@staticmethod
|
||||
def no_medication(data_filter: DataFilter, medicine_keys: list[str]) -> None:
|
||||
for medicine_key in medicine_keys:
|
||||
data_filter.set_medicine_filter(medicine_key, taken=False)
|
||||
|
||||
|
||||
class SearchHistory:
|
||||
"""Manages search history (tests assume <=15 retained)."""
|
||||
|
||||
def __init__(self, max_history: int = 15):
|
||||
self.max_history = max_history
|
||||
self.history: list[str] = []
|
||||
|
||||
def add_search(self, search_term: str) -> None:
|
||||
"""
|
||||
Add a search term to history.
|
||||
|
||||
Args:
|
||||
search_term: Search term to add
|
||||
"""
|
||||
search_term = search_term.strip()
|
||||
if not search_term:
|
||||
return
|
||||
|
||||
# Remove if already exists
|
||||
if search_term in self.history:
|
||||
self.history.remove(search_term)
|
||||
|
||||
# Add to beginning
|
||||
self.history.insert(0, search_term)
|
||||
|
||||
# Trim to max size
|
||||
if len(self.history) > self.max_history:
|
||||
self.history = self.history[: self.max_history]
|
||||
|
||||
def get_history(self) -> list[str]:
|
||||
"""Get search history."""
|
||||
return self.history.copy()
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all search history."""
|
||||
self.history.clear()
|
||||
|
||||
def get_suggestions(self, partial_term: str) -> list[str]:
|
||||
"""
|
||||
Get search suggestions based on partial input.
|
||||
|
||||
Args:
|
||||
partial_term: Partial search term
|
||||
|
||||
Returns:
|
||||
List of matching suggestions from history
|
||||
"""
|
||||
if not partial_term:
|
||||
return self.history[:5] # Return recent searches
|
||||
|
||||
partial_lower = partial_term.lower()
|
||||
suggestions = []
|
||||
|
||||
for term in self.history:
|
||||
if term.lower().startswith(partial_lower):
|
||||
suggestions.append(term)
|
||||
|
||||
return suggestions[:5] # Return top 5 matches
|
||||
__all__ = ["DataFilter", "QuickFilters", "SearchHistory"]
|
||||
|
||||
+9
-575
@@ -1,578 +1,12 @@
|
||||
"""Settings window for TheChart application."""
|
||||
"""Shim for backward compatibility.
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
Re-exports canonical implementation from thechart.ui.settings_window.
|
||||
"""
|
||||
|
||||
from constants import BACKUP_PATH
|
||||
from preferences import (
|
||||
get_config_dir,
|
||||
get_pref,
|
||||
reset_preferences,
|
||||
save_preferences,
|
||||
set_pref,
|
||||
)
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SettingsWindow:
|
||||
"""Settings window for application preferences."""
|
||||
|
||||
def __init__(self, parent: tk.Tk, theme_manager, ui_manager) -> None:
|
||||
self.parent = parent
|
||||
self.theme_manager = theme_manager
|
||||
self.ui_manager = ui_manager
|
||||
|
||||
# Create window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Settings - TheChart")
|
||||
# Larger default size; allow user to resize
|
||||
self.window.geometry("760x560")
|
||||
self.window.minsize(640, 480)
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
# Center the window
|
||||
self._center_window()
|
||||
|
||||
# Setup UI
|
||||
self._setup_ui()
|
||||
|
||||
# Set initial values
|
||||
self._load_current_settings()
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the settings window on the parent."""
|
||||
self.window.update_idletasks()
|
||||
|
||||
# Get window dimensions
|
||||
window_width = self.window.winfo_reqwidth()
|
||||
window_height = self.window.winfo_reqheight()
|
||||
|
||||
# Get parent window position and size
|
||||
parent_x = self.parent.winfo_x()
|
||||
parent_y = self.parent.winfo_y()
|
||||
parent_width = self.parent.winfo_width()
|
||||
parent_height = self.parent.winfo_height()
|
||||
|
||||
# Calculate centered position
|
||||
x = parent_x + (parent_width // 2) - (window_width // 2)
|
||||
y = parent_y + (parent_height // 2) - (window_height // 2)
|
||||
|
||||
self.window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Setup the settings UI."""
|
||||
# Main container
|
||||
main_frame = ttk.Frame(self.window, padding="20", style="Card.TFrame")
|
||||
main_frame.pack(fill="both", expand=True)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame,
|
||||
text="Application Settings",
|
||||
font=("TkDefaultFont", 16, "bold"),
|
||||
)
|
||||
title_label.pack(pady=(0, 20))
|
||||
|
||||
# Create notebook for different setting categories
|
||||
notebook = ttk.Notebook(main_frame, style="Modern.TNotebook")
|
||||
notebook.pack(fill="both", expand=True, pady=(0, 20))
|
||||
|
||||
# Theme settings tab
|
||||
self._create_theme_tab(notebook)
|
||||
|
||||
# UI settings tab
|
||||
self._create_ui_tab(notebook)
|
||||
|
||||
# About tab
|
||||
self._create_about_tab(notebook)
|
||||
|
||||
# Button frame
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack(fill="x", pady=(10, 0))
|
||||
|
||||
# Buttons
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Apply",
|
||||
command=self._apply_settings,
|
||||
style="Action.TButton",
|
||||
).pack(side="right", padx=(5, 0))
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Cancel",
|
||||
command=self._cancel,
|
||||
style="Action.TButton",
|
||||
).pack(side="right")
|
||||
|
||||
def _reset_all() -> None:
|
||||
if messagebox.askyesno(
|
||||
"Reset All Settings",
|
||||
(
|
||||
"This will restore all settings to defaults and clear saved"
|
||||
" window geometry. Continue?"
|
||||
),
|
||||
parent=self.window,
|
||||
):
|
||||
try:
|
||||
reset_preferences()
|
||||
# Reflect defaults in UI state
|
||||
self.remember_size_var.set(
|
||||
bool(get_pref("remember_window_geometry", True))
|
||||
)
|
||||
self.always_on_top_var.set(bool(get_pref("always_on_top", False)))
|
||||
self.prompt_open_folder_after_restore_var.set(
|
||||
bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
# Apply always-on-top immediately using default
|
||||
with contextlib.suppress(Exception):
|
||||
self.parent.wm_attributes(
|
||||
"-topmost", bool(self.always_on_top_var.get())
|
||||
)
|
||||
if hasattr(self.ui_manager, "update_status"):
|
||||
self.ui_manager.update_status(
|
||||
"Settings reset to defaults", "info"
|
||||
)
|
||||
except Exception:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Failed to reset settings.",
|
||||
parent=self.window,
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Reset All Settings…",
|
||||
command=_reset_all,
|
||||
style="Action.TButton",
|
||||
).pack(side="left")
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="OK",
|
||||
command=self._ok,
|
||||
style="Action.TButton",
|
||||
).pack(side="right", padx=(0, 5))
|
||||
|
||||
def _create_theme_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the theme settings tab."""
|
||||
theme_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(theme_frame, text="Theme")
|
||||
|
||||
# Theme selection
|
||||
theme_label_frame = ttk.LabelFrame(
|
||||
theme_frame, text="Theme Selection", style="Card.TLabelframe"
|
||||
)
|
||||
theme_label_frame.pack(fill="x", padx=10, pady=10)
|
||||
|
||||
ttk.Label(
|
||||
theme_label_frame,
|
||||
text="Choose your preferred theme:",
|
||||
font=("TkDefaultFont", 10),
|
||||
).pack(anchor="w", padx=10, pady=(10, 5))
|
||||
|
||||
# Theme radio buttons
|
||||
self.theme_var = tk.StringVar()
|
||||
themes = self.theme_manager.get_available_themes()
|
||||
|
||||
theme_buttons_frame = ttk.Frame(theme_label_frame)
|
||||
theme_buttons_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Create radio buttons in a grid
|
||||
for i, theme in enumerate(themes):
|
||||
row = i // 3
|
||||
col = i % 3
|
||||
|
||||
ttk.Radiobutton(
|
||||
theme_buttons_frame,
|
||||
text=theme.title(),
|
||||
variable=self.theme_var,
|
||||
value=theme,
|
||||
style="Modern.TCheckbutton",
|
||||
).grid(row=row, column=col, sticky="w", padx=5, pady=2)
|
||||
|
||||
# Theme preview info
|
||||
preview_frame = ttk.LabelFrame(
|
||||
theme_frame, text="Theme Preview", style="Card.TLabelframe"
|
||||
)
|
||||
preview_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
preview_text = tk.Text(
|
||||
preview_frame,
|
||||
height=6,
|
||||
wrap="word",
|
||||
font=("TkDefaultFont", 9),
|
||||
state="disabled",
|
||||
)
|
||||
preview_text.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Theme change callback
|
||||
def on_theme_change():
|
||||
selected_theme = self.theme_var.get()
|
||||
preview_text.config(state="normal")
|
||||
preview_text.delete("1.0", "end")
|
||||
preview_text.insert(
|
||||
"1.0",
|
||||
f"Selected theme: {selected_theme.title()}\\n\\n"
|
||||
"Theme changes will be applied when you click 'Apply' or 'OK'. "
|
||||
"The new theme will affect all windows and UI elements "
|
||||
"in the application.",
|
||||
)
|
||||
preview_text.config(state="disabled")
|
||||
|
||||
self.theme_var.trace("w", lambda *args: on_theme_change())
|
||||
|
||||
def _create_ui_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the UI settings tab."""
|
||||
ui_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(ui_frame, text="Interface")
|
||||
|
||||
# Font settings
|
||||
font_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Font Settings", style="Card.TLabelframe"
|
||||
)
|
||||
font_frame.pack(fill="x", padx=10, pady=10)
|
||||
|
||||
ttk.Label(
|
||||
font_frame,
|
||||
text="Font size adjustments (requires restart):",
|
||||
font=("TkDefaultFont", 10),
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Font size scale
|
||||
self.font_scale_var = tk.DoubleVar(value=1.0)
|
||||
font_scale = ttk.Scale(
|
||||
font_frame,
|
||||
from_=0.8,
|
||||
to=1.5,
|
||||
variable=self.font_scale_var,
|
||||
orient="horizontal",
|
||||
style="Modern.Horizontal.TScale",
|
||||
)
|
||||
font_scale.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Scale labels
|
||||
scale_labels_frame = ttk.Frame(font_frame)
|
||||
scale_labels_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(scale_labels_frame, text="Small").pack(side="left")
|
||||
ttk.Label(scale_labels_frame, text="Large").pack(side="right")
|
||||
ttk.Label(scale_labels_frame, text="Normal").pack()
|
||||
|
||||
# Window settings
|
||||
window_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Window Settings", style="Card.TLabelframe"
|
||||
)
|
||||
window_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Remember window size
|
||||
from preferences import get_pref as _getp
|
||||
|
||||
self.remember_size_var = tk.BooleanVar(
|
||||
value=bool(_getp("remember_window_geometry", True))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
window_frame,
|
||||
text="Remember window size and position",
|
||||
variable=self.remember_size_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Always on top
|
||||
self.always_on_top_var = tk.BooleanVar(
|
||||
value=bool(_getp("always_on_top", False))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
window_frame,
|
||||
text="Keep window always on top",
|
||||
variable=self.always_on_top_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=(0, 10))
|
||||
|
||||
# Reset window position button
|
||||
def _reset_window_position() -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
# Clear saved geometry preference and persist
|
||||
set_pref("last_window_geometry", "")
|
||||
save_preferences()
|
||||
|
||||
# Center the main window on the screen
|
||||
try:
|
||||
self.parent.update_idletasks()
|
||||
width = self.parent.winfo_width() or self.parent.winfo_reqwidth()
|
||||
height = self.parent.winfo_height() or self.parent.winfo_reqheight()
|
||||
sw = self.parent.winfo_screenwidth()
|
||||
sh = self.parent.winfo_screenheight()
|
||||
x = (sw // 2) - (width // 2)
|
||||
y = (sh // 2) - (height // 2)
|
||||
self.parent.geometry(f"{width}x{height}+{x}+{y}")
|
||||
if hasattr(self.ui_manager, "update_status"):
|
||||
self.ui_manager.update_status("Window position reset", "info")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reset_btn = ttk.Button(
|
||||
window_frame,
|
||||
text="Reset Window Position",
|
||||
command=_reset_window_position,
|
||||
style="Action.TButton",
|
||||
)
|
||||
reset_btn.pack(anchor="w", padx=10, pady=(0, 10))
|
||||
|
||||
# Tooltip for reset action
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
reset_btn,
|
||||
"Clear saved window size/position and center the app",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restore settings
|
||||
restore_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Backup & Restore", style="Card.TLabelframe"
|
||||
)
|
||||
restore_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
self.prompt_open_folder_after_restore_var = tk.BooleanVar(
|
||||
value=bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
restore_frame,
|
||||
text="Offer to open backups folder after successful restore",
|
||||
variable=self.prompt_open_folder_after_restore_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Backups folder path and open button
|
||||
bkp_frame = ttk.Frame(restore_frame)
|
||||
bkp_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(bkp_frame, text="Backups folder:").pack(side="left", padx=(0, 8))
|
||||
# Resolve backup path from constants (env-aware)
|
||||
self._bkp_path_var = tk.StringVar(value=BACKUP_PATH)
|
||||
bkp_entry = ttk.Entry(
|
||||
bkp_frame,
|
||||
textvariable=self._bkp_path_var,
|
||||
width=44,
|
||||
state="readonly",
|
||||
)
|
||||
bkp_entry.pack(side="left", fill="x", expand=True)
|
||||
|
||||
def _open_bkp() -> None:
|
||||
path = self._bkp_path_var.get()
|
||||
with contextlib.suppress(Exception):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
|
||||
bkp_open_btn = ttk.Button(
|
||||
bkp_frame,
|
||||
text="Open",
|
||||
command=_open_bkp,
|
||||
style="Action.TButton",
|
||||
width=8,
|
||||
)
|
||||
bkp_open_btn.pack(side="left", padx=(8, 0))
|
||||
|
||||
# Brief description for backups folder
|
||||
ttk.Label(
|
||||
restore_frame,
|
||||
text=(
|
||||
"Automatic CSV backups are saved in this folder. "
|
||||
"It will be created if it doesn't exist."
|
||||
),
|
||||
justify="left",
|
||||
wraplength=680,
|
||||
).pack(anchor="w", padx=10, pady=(2, 10))
|
||||
|
||||
# Tooltip for Open (backups)
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
bkp_open_btn,
|
||||
"Open the backups folder in your file manager",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Config folder path and open button
|
||||
cfg_frame = ttk.Frame(restore_frame)
|
||||
cfg_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(cfg_frame, text="Config folder:").pack(side="left", padx=(0, 8))
|
||||
self._cfg_path_var = tk.StringVar(value=get_config_dir())
|
||||
cfg_entry = ttk.Entry(
|
||||
cfg_frame,
|
||||
textvariable=self._cfg_path_var,
|
||||
width=44,
|
||||
state="readonly",
|
||||
)
|
||||
cfg_entry.pack(side="left", fill="x", expand=True)
|
||||
|
||||
def _open_cfg() -> None:
|
||||
path = self._cfg_path_var.get()
|
||||
with contextlib.suppress(Exception):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
|
||||
cfg_open_btn = ttk.Button(
|
||||
cfg_frame,
|
||||
text="Open",
|
||||
command=_open_cfg,
|
||||
style="Action.TButton",
|
||||
width=8,
|
||||
)
|
||||
cfg_open_btn.pack(side="left", padx=(8, 0))
|
||||
|
||||
# Tooltip for Open (config)
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
cfg_open_btn,
|
||||
"Open the configuration folder (preferences.json)",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _create_about_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the about tab."""
|
||||
about_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(about_frame, text="About")
|
||||
|
||||
# App info
|
||||
info_frame = ttk.LabelFrame(
|
||||
about_frame, text="Application Information", style="Card.TLabelframe"
|
||||
)
|
||||
info_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
about_text = tk.Text(
|
||||
info_frame,
|
||||
wrap="word",
|
||||
font=("TkDefaultFont", 10),
|
||||
state="disabled",
|
||||
bg=self.theme_manager.get_theme_colors()["bg"],
|
||||
fg=self.theme_manager.get_theme_colors()["fg"],
|
||||
)
|
||||
about_text.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
about_content = """TheChart - Medication Tracker
|
||||
|
||||
Version: 1.9.5
|
||||
Built with: Python, Tkinter, ttkthemes
|
||||
|
||||
Features:
|
||||
• Modern themed interface with multiple themes
|
||||
• Medication and pathology tracking
|
||||
• Visual graphs and charts
|
||||
• Data export capabilities
|
||||
• Keyboard shortcuts for efficiency
|
||||
• Customizable UI settings
|
||||
|
||||
This application helps you track your daily medications and health
|
||||
conditions with an intuitive, modern interface.
|
||||
|
||||
Enhanced with ttkthemes for better visual appeal and user experience."""
|
||||
|
||||
about_text.config(state="normal")
|
||||
about_text.insert("1.0", about_content)
|
||||
about_text.config(state="disabled")
|
||||
|
||||
def _load_current_settings(self) -> None:
|
||||
"""Load current application settings."""
|
||||
# Set current theme
|
||||
current_theme = self.theme_manager.get_current_theme()
|
||||
self.theme_var.set(current_theme)
|
||||
|
||||
# Trigger theme change to update preview
|
||||
if hasattr(self, "theme_var"):
|
||||
self.theme_var.set(current_theme)
|
||||
# Ensure UI checkboxes reflect preferences
|
||||
if hasattr(self, "prompt_open_folder_after_restore_var"):
|
||||
self.prompt_open_folder_after_restore_var.set(
|
||||
bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
|
||||
def _apply_settings(self) -> None:
|
||||
"""Apply the selected settings."""
|
||||
# Apply theme if changed
|
||||
selected_theme = self.theme_var.get()
|
||||
current_theme = self.theme_manager.get_current_theme()
|
||||
|
||||
if selected_theme != current_theme:
|
||||
if self.theme_manager.apply_theme(selected_theme):
|
||||
self.ui_manager.update_status(
|
||||
f"Theme changed to: {selected_theme.title()}", "info"
|
||||
)
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
f"Failed to apply theme: {selected_theme}",
|
||||
parent=self.window,
|
||||
)
|
||||
return
|
||||
|
||||
# Apply other settings (font size, window settings, etc.)
|
||||
# These would typically be saved to a config file
|
||||
|
||||
# Save preferences
|
||||
set_pref(
|
||||
"prompt_open_folder_after_restore",
|
||||
bool(self.prompt_open_folder_after_restore_var.get()),
|
||||
)
|
||||
set_pref("remember_window_geometry", bool(self.remember_size_var.get()))
|
||||
set_pref("always_on_top", bool(self.always_on_top_var.get()))
|
||||
|
||||
# Apply always-on-top immediately
|
||||
import contextlib as _ctx
|
||||
|
||||
with _ctx.suppress(Exception):
|
||||
self.parent.wm_attributes("-topmost", bool(self.always_on_top_var.get()))
|
||||
|
||||
messagebox.showinfo(
|
||||
"Settings Applied",
|
||||
"Settings have been applied successfully!",
|
||||
parent=self.window,
|
||||
)
|
||||
# Persist settings at the end
|
||||
with contextlib.suppress(Exception):
|
||||
save_preferences()
|
||||
|
||||
def _ok(self) -> None:
|
||||
"""Apply settings and close window."""
|
||||
self._apply_settings()
|
||||
self.window.destroy()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
"""Close window without applying settings."""
|
||||
self.window.destroy()
|
||||
try: # noqa: SIM105
|
||||
from thechart.ui.settings_window import * # type: ignore # noqa: F401,F403
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
# Fallback for dev environments not using package layout
|
||||
from src.thechart.ui.settings_window import * # type: ignore # noqa: F401,F403
|
||||
|
||||
@@ -4,8 +4,6 @@ This package provides the main application and components for the
|
||||
TheChart (medication tracker) desktop app.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- This package layer is introduced to follow Python packaging best
|
||||
practices while keeping backward compatibility with existing
|
||||
imports used in tests (e.g., ``src.*``). The original modules under
|
||||
``src/`` remain available; this package enables ``python -m thechart``
|
||||
@@ -21,6 +19,47 @@ try: # Prefer installed package version if available
|
||||
except Exception: # Fallback in editable/dev mode
|
||||
__version__ = "0.0.0.dev"
|
||||
|
||||
# Friendly, stable public API re-exports
|
||||
from .core import ( # noqa: F401
|
||||
BACKUP_PATH,
|
||||
LOG_CLEAR,
|
||||
LOG_LEVEL,
|
||||
LOG_PATH,
|
||||
get_config_dir,
|
||||
get_pref,
|
||||
load_preferences,
|
||||
reset_preferences,
|
||||
save_preferences,
|
||||
set_pref,
|
||||
)
|
||||
from .export import ExportManager # noqa: F401
|
||||
from .managers import ( # noqa: F401
|
||||
Medicine,
|
||||
MedicineManager,
|
||||
Pathology,
|
||||
PathologyManager,
|
||||
)
|
||||
from .validation import InputValidator # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
# validation
|
||||
"InputValidator",
|
||||
# core
|
||||
"LOG_CLEAR",
|
||||
"LOG_LEVEL",
|
||||
"LOG_PATH",
|
||||
"BACKUP_PATH",
|
||||
"get_config_dir",
|
||||
"load_preferences",
|
||||
"save_preferences",
|
||||
"reset_preferences",
|
||||
"get_pref",
|
||||
"set_pref",
|
||||
# managers
|
||||
"Medicine",
|
||||
"MedicineManager",
|
||||
"Pathology",
|
||||
"PathologyManager",
|
||||
"ExportManager", # Expose ExportManager for convenience
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Analytics layer re-exports for TheChart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .graph_manager import GraphManager # noqa: F401
|
||||
|
||||
__all__ = ["GraphManager"]
|
||||
@@ -0,0 +1,461 @@
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from contextlib import suppress
|
||||
from tkinter import ttk
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Type-only imports to avoid hard runtime deps during package import
|
||||
from typing import TYPE_CHECKING # noqa: F401 # retained for future type hints
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
|
||||
# Provide a module alias consistent with legacy module name in tests
|
||||
sys.modules.setdefault("graph_manager", sys.modules[__name__])
|
||||
|
||||
|
||||
def _build_default_medicine_manager():
|
||||
"""Create a lightweight default medicine manager used by legacy tests."""
|
||||
default_medicines = {
|
||||
"bupropion": SimpleNamespace(
|
||||
key="bupropion",
|
||||
display_name="Bupropion",
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
),
|
||||
"hydroxyzine": SimpleNamespace(
|
||||
key="hydroxyzine",
|
||||
display_name="Hydroxyzine",
|
||||
color="#4ECDC4",
|
||||
default_enabled=False,
|
||||
),
|
||||
"gabapentin": SimpleNamespace(
|
||||
key="gabapentin",
|
||||
display_name="Gabapentin",
|
||||
color="#45B7D1",
|
||||
default_enabled=False,
|
||||
),
|
||||
"propranolol": SimpleNamespace(
|
||||
key="propranolol",
|
||||
display_name="Propranolol",
|
||||
color="#96CEB4",
|
||||
default_enabled=True,
|
||||
),
|
||||
"quetiapine": SimpleNamespace(
|
||||
key="quetiapine",
|
||||
display_name="Quetiapine",
|
||||
color="#FFEAA7",
|
||||
default_enabled=False,
|
||||
),
|
||||
}
|
||||
|
||||
class _DefaultMedicineManager:
|
||||
def get_medicine_keys(self):
|
||||
return list(default_medicines.keys())
|
||||
|
||||
def get_medicine(self, key):
|
||||
return default_medicines.get(key)
|
||||
|
||||
def get_graph_colors(self):
|
||||
return {k: v.color for k, v in default_medicines.items()}
|
||||
|
||||
return _DefaultMedicineManager()
|
||||
|
||||
|
||||
def _build_default_pathology_manager():
|
||||
"""Create a lightweight default pathology manager for legacy tests."""
|
||||
default_pathologies = {
|
||||
"depression": SimpleNamespace(
|
||||
key="depression",
|
||||
display_name="Depression",
|
||||
scale_info="0-10",
|
||||
scale_orientation="normal",
|
||||
),
|
||||
"anxiety": SimpleNamespace(
|
||||
key="anxiety",
|
||||
display_name="Anxiety",
|
||||
scale_info="0-10",
|
||||
scale_orientation="normal",
|
||||
),
|
||||
"sleep": SimpleNamespace(
|
||||
key="sleep",
|
||||
display_name="Sleep",
|
||||
scale_info="0-10",
|
||||
scale_orientation="normal",
|
||||
),
|
||||
"appetite": SimpleNamespace(
|
||||
key="appetite",
|
||||
display_name="Appetite",
|
||||
scale_info="0-10",
|
||||
scale_orientation="normal",
|
||||
),
|
||||
}
|
||||
|
||||
class _DefaultPathologyManager:
|
||||
def get_pathology_keys(self):
|
||||
return list(default_pathologies.keys())
|
||||
|
||||
def get_pathology(self, key):
|
||||
return default_pathologies.get(key)
|
||||
|
||||
return _DefaultPathologyManager()
|
||||
|
||||
|
||||
class GraphManager:
|
||||
"""Optimized version - Handle all graph-related operations for the app."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent_frame: ttk.LabelFrame,
|
||||
medicine_manager=None,
|
||||
pathology_manager=None,
|
||||
logger=None,
|
||||
) -> None:
|
||||
"""Create a GraphManager.
|
||||
|
||||
Args:
|
||||
parent_frame: Parent tkinter frame.
|
||||
medicine_manager: Optional MedicineManager; if omitted a
|
||||
lightweight default is created for test compatibility.
|
||||
pathology_manager: Optional PathologyManager; if omitted a
|
||||
lightweight default is created for test compatibility.
|
||||
logger: Optional logger for debug messages.
|
||||
"""
|
||||
self.parent_frame: ttk.LabelFrame = parent_frame
|
||||
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame)
|
||||
self.graph_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||
|
||||
self.medicine_manager = (
|
||||
medicine_manager
|
||||
if medicine_manager is not None
|
||||
else _build_default_medicine_manager()
|
||||
)
|
||||
self.pathology_manager = (
|
||||
pathology_manager
|
||||
if pathology_manager is not None
|
||||
else _build_default_pathology_manager()
|
||||
)
|
||||
self.logger = logger
|
||||
|
||||
self.fig, self.ax = plt.subplots(figsize=(10, 6), dpi=80)
|
||||
|
||||
self.current_data: pd.DataFrame = pd.DataFrame()
|
||||
self._last_plot_hash: str = ""
|
||||
|
||||
self.toggle_vars: dict[str, tk.BooleanVar] = {}
|
||||
self._setup_ui()
|
||||
self._initialize_toggle_vars()
|
||||
self._create_chart_toggles()
|
||||
|
||||
def _initialize_toggle_vars(self) -> None:
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
self.toggle_vars[pathology_key] = tk.BooleanVar(value=True)
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
med = self.medicine_manager.get_medicine(medicine_key)
|
||||
default_enabled = getattr(med, "default_enabled", False)
|
||||
self.toggle_vars[medicine_key] = tk.BooleanVar(value=bool(default_enabled))
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
try:
|
||||
self.canvas = FigureCanvasTkAgg(figure=self.fig, master=self.graph_frame)
|
||||
self.canvas.draw_idle()
|
||||
except (tk.TclError, RuntimeError):
|
||||
|
||||
class _DummyCanvas:
|
||||
def __init__(self, master: ttk.Frame) -> None:
|
||||
self._widget = ttk.Frame(master)
|
||||
|
||||
def draw(self) -> None:
|
||||
pass
|
||||
|
||||
def draw_idle(self) -> None:
|
||||
pass
|
||||
|
||||
def get_tk_widget(self):
|
||||
return self._widget
|
||||
|
||||
self.canvas = _DummyCanvas(self.graph_frame)
|
||||
|
||||
canvas_widget = self.canvas.get_tk_widget()
|
||||
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
||||
|
||||
self.control_frame = ttk.Frame(self.parent_frame)
|
||||
self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
|
||||
|
||||
def _create_chart_toggles(self) -> None:
|
||||
pathology_frame = ttk.LabelFrame(
|
||||
self.control_frame, text="Pathologies", padding="5"
|
||||
)
|
||||
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
|
||||
|
||||
row, col = 0, 0
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
if pathology:
|
||||
display_name = pathology.display_name
|
||||
text = (
|
||||
display_name[:10] + "..."
|
||||
if len(display_name) > 10
|
||||
else display_name
|
||||
)
|
||||
cb = ttk.Checkbutton(
|
||||
pathology_frame,
|
||||
text=text,
|
||||
variable=self.toggle_vars[pathology_key],
|
||||
command=self._handle_toggle_changed,
|
||||
)
|
||||
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||
col += 1
|
||||
if col > 1:
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
medicine_frame = ttk.LabelFrame(
|
||||
self.control_frame, text="Medicines", padding="5"
|
||||
)
|
||||
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
|
||||
|
||||
row, col = 0, 0
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
if medicine:
|
||||
med_name = medicine.display_name
|
||||
text = med_name[:10] + "..." if len(med_name) > 10 else med_name
|
||||
cb = ttk.Checkbutton(
|
||||
medicine_frame,
|
||||
text=text,
|
||||
variable=self.toggle_vars[medicine_key],
|
||||
command=self._handle_toggle_changed,
|
||||
)
|
||||
cb.grid(row=row, column=col, sticky="w", padx=2)
|
||||
col += 1
|
||||
if col > 2:
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
def _handle_toggle_changed(self) -> None:
|
||||
if not self.current_data.empty:
|
||||
self._plot_graph_data(self.current_data)
|
||||
|
||||
def update_graph(self, df: pd.DataFrame) -> None:
|
||||
if getattr(df, "empty", True):
|
||||
data_hash = "empty"
|
||||
else:
|
||||
try:
|
||||
last_date = (
|
||||
df["date"].iloc[-1]
|
||||
if hasattr(df, "columns") and "date" in df.columns and len(df) > 0
|
||||
else len(df)
|
||||
)
|
||||
except Exception:
|
||||
last_date = len(df)
|
||||
try:
|
||||
import zlib
|
||||
|
||||
raw = (
|
||||
df.select_dtypes(exclude=["object"]).to_numpy(copy=False)
|
||||
if hasattr(df, "select_dtypes")
|
||||
else []
|
||||
)
|
||||
size = getattr(raw, "size", 0)
|
||||
checksum = zlib.adler32(raw.tobytes()) if size else 0
|
||||
except Exception:
|
||||
checksum = len(df)
|
||||
data_hash = f"{len(df)}:{last_date}:{checksum}"
|
||||
|
||||
if data_hash != self._last_plot_hash or getattr(
|
||||
self.current_data, "empty", True
|
||||
):
|
||||
self.current_data = (
|
||||
df.copy() if hasattr(df, "copy") and not df.empty else pd.DataFrame()
|
||||
)
|
||||
self._last_plot_hash = data_hash
|
||||
|
||||
try:
|
||||
self._plot_graph_data(df)
|
||||
except Exception:
|
||||
if self.logger:
|
||||
with suppress(Exception):
|
||||
self.logger.exception("Error while plotting graph data")
|
||||
|
||||
def _plot_graph_data(self, df: pd.DataFrame) -> None:
|
||||
with plt.ioff():
|
||||
self.ax.clear()
|
||||
if hasattr(df, "empty") and not df.empty:
|
||||
df_processed = self._preprocess_data(df)
|
||||
has_plotted_series = self._plot_pathology_data(df_processed)
|
||||
medicine_data = self._plot_medicine_data(df_processed)
|
||||
if has_plotted_series or medicine_data["has_plotted"]:
|
||||
self._configure_graph_appearance(medicine_data)
|
||||
try:
|
||||
self.canvas.draw()
|
||||
except Exception:
|
||||
with plt.ioff():
|
||||
self.canvas.draw_idle()
|
||||
|
||||
def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
if hasattr(df, "index") and isinstance(df.index, pd.DatetimeIndex):
|
||||
return df
|
||||
local = df.copy() if hasattr(df, "copy") else df
|
||||
if hasattr(local, "columns") and "date" in local.columns:
|
||||
local["date"] = pd.to_datetime(local["date"], errors="coerce")
|
||||
local = local.dropna(subset=["date"]).sort_values("date")
|
||||
local.set_index("date", inplace=True)
|
||||
return local
|
||||
|
||||
def _plot_pathology_data(self, df: pd.DataFrame) -> bool:
|
||||
has_plotted_series = False
|
||||
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||
active_pathologies = [
|
||||
key
|
||||
for key in pathology_keys
|
||||
if (
|
||||
self.toggle_vars[key].get()
|
||||
and hasattr(df, "columns")
|
||||
and key in df.columns
|
||||
)
|
||||
]
|
||||
for pathology_key in active_pathologies:
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
if pathology:
|
||||
label = f"{pathology.display_name} ({pathology.scale_info})"
|
||||
linestyle = (
|
||||
"dashed" if pathology.scale_orientation == "inverted" else "-"
|
||||
)
|
||||
self._plot_series(df, pathology_key, label, "o", linestyle)
|
||||
has_plotted_series = True
|
||||
return has_plotted_series
|
||||
|
||||
def _plot_medicine_data(self, df: pd.DataFrame) -> dict:
|
||||
result = {"has_plotted": False, "with_data": [], "without_data": []}
|
||||
medicine_colors = self.medicine_manager.get_graph_colors()
|
||||
medicines = self.medicine_manager.get_medicine_keys()
|
||||
medicine_doses: dict[str, list[float]] = {}
|
||||
for medicine in medicines:
|
||||
dose_column = f"{medicine}_doses"
|
||||
if hasattr(df, "columns") and dose_column in df.columns:
|
||||
daily_doses = [
|
||||
self._calculate_daily_dose(dose_str) for dose_str in df[dose_column]
|
||||
]
|
||||
medicine_doses[medicine] = daily_doses
|
||||
for medicine in medicines:
|
||||
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
|
||||
daily_doses = medicine_doses[medicine]
|
||||
if any(dose > 0 for dose in daily_doses):
|
||||
result["with_data"].append(medicine)
|
||||
scaled_doses = [dose / 10 for dose in daily_doses]
|
||||
non_zero_doses = [d for d in daily_doses if d > 0]
|
||||
if non_zero_doses:
|
||||
avg_dose = sum(non_zero_doses) / len(non_zero_doses)
|
||||
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
|
||||
self.ax.bar(
|
||||
df.index,
|
||||
scaled_doses,
|
||||
alpha=0.6,
|
||||
color=medicine_colors.get(medicine, "#DDA0DD"),
|
||||
label=label,
|
||||
width=0.6,
|
||||
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
|
||||
)
|
||||
result["has_plotted"] = True
|
||||
else:
|
||||
if self.toggle_vars[medicine].get():
|
||||
result["without_data"].append(medicine)
|
||||
return result
|
||||
|
||||
def _configure_graph_appearance(self, medicine_data: dict) -> None:
|
||||
_hl = self.ax.get_legend_handles_labels()
|
||||
try:
|
||||
handles, labels = _hl
|
||||
except Exception:
|
||||
handles, labels = [], []
|
||||
handles = list(handles) if handles else []
|
||||
labels = list(labels) if labels else []
|
||||
if medicine_data["without_data"]:
|
||||
med_list = ", ".join(medicine_data["without_data"])
|
||||
info_text = f"Tracked (no doses): {med_list}"
|
||||
from matplotlib.patches import Rectangle
|
||||
|
||||
dummy_handle = Rectangle(
|
||||
(0, 0), 0, 0, fc="none", fill=False, edgecolor="none", linewidth=0
|
||||
)
|
||||
handles.append(dummy_handle)
|
||||
labels.append(info_text)
|
||||
if handles and labels:
|
||||
self.ax.legend(
|
||||
handles,
|
||||
labels,
|
||||
loc="upper left",
|
||||
bbox_to_anchor=(0, 1),
|
||||
ncol=2,
|
||||
fontsize="small",
|
||||
frameon=True,
|
||||
fancybox=True,
|
||||
shadow=True,
|
||||
framealpha=0.9,
|
||||
)
|
||||
self.ax.set_title("Medication Effects Over Time")
|
||||
self.ax.set_xlabel("Date")
|
||||
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
|
||||
try:
|
||||
current_ylim = self.ax.get_ylim()
|
||||
low = current_ylim[0] if hasattr(current_ylim, "__getitem__") else 0
|
||||
high = current_ylim[1] if hasattr(current_ylim, "__getitem__") else 10
|
||||
except Exception:
|
||||
low, high = 0, 10
|
||||
with suppress(Exception):
|
||||
self.ax.set_ylim(bottom=low, top=max(10, high))
|
||||
|
||||
def _plot_series(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
key: str,
|
||||
label: str,
|
||||
marker: str,
|
||||
linestyle: str,
|
||||
) -> None:
|
||||
import contextlib as _ctx
|
||||
|
||||
with _ctx.suppress(Exception):
|
||||
self.ax.plot(
|
||||
df.index,
|
||||
df[key],
|
||||
marker=marker,
|
||||
linestyle=linestyle,
|
||||
label=label,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_daily_dose(dose_str: str | float | int) -> float:
|
||||
# Numeric inputs
|
||||
if isinstance(dose_str, (int, float)): # noqa: UP038 - runtime isinstance
|
||||
return float(dose_str)
|
||||
if not isinstance(dose_str, str) or not dose_str:
|
||||
return 0.0
|
||||
parts = [p.strip() for p in str(dose_str).split(";") if p.strip()]
|
||||
total = 0.0
|
||||
for p in parts:
|
||||
try:
|
||||
total += float(p.split()[0])
|
||||
except Exception:
|
||||
continue
|
||||
return total
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release plotting resources safely.
|
||||
|
||||
Clears the axes and closes the matplotlib figure. Any errors during
|
||||
cleanup are suppressed to avoid impacting the shutdown sequence.
|
||||
"""
|
||||
try:
|
||||
with suppress(Exception):
|
||||
self.ax.clear()
|
||||
with suppress(Exception):
|
||||
plt.close(self.fig)
|
||||
except Exception:
|
||||
# Final safety net; ignore cleanup errors
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["GraphManager"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Core re-exports for TheChart.
|
||||
|
||||
Canonical implementations live under this package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .auto_save import AutoSaveManager, BackupManager # noqa: F401
|
||||
from .constants import * # noqa: F401,F403
|
||||
from .error_handler import ( # noqa: F401
|
||||
ErrorHandler,
|
||||
OperationTimer,
|
||||
UserFeedback,
|
||||
handle_exceptions,
|
||||
)
|
||||
from .logger import init_logger # noqa: F401
|
||||
from .preferences import * # noqa: F401,F403
|
||||
from .undo_manager import UndoAction, UndoManager # noqa: F401
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Auto-save and backup utilities for TheChart (canonical module).
|
||||
|
||||
This module provides both the new application API and the legacy test API
|
||||
via a single implementation. Use `from thechart.core.auto_save import *` or
|
||||
import specific classes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from .constants import BACKUP_PATH
|
||||
|
||||
|
||||
class AutoSaveManager:
|
||||
"""Unified auto-save & backup manager supporting legacy and new APIs."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Construction / mode detection
|
||||
# ------------------------------------------------------------------
|
||||
def __init__(self, *args, **kwargs) -> None: # type: ignore[override]
|
||||
# Determine mode: legacy if a filesystem path is provided
|
||||
self._legacy_mode = "data_file_path" in kwargs or (
|
||||
args and isinstance(args[0], str)
|
||||
)
|
||||
self.logger = kwargs.get("logger")
|
||||
|
||||
if self._legacy_mode:
|
||||
# Legacy parameters (tests expect these attributes)
|
||||
self.data_file_path: str = kwargs.get(
|
||||
"data_file_path", args[0] if args else ""
|
||||
)
|
||||
self.backup_dir: str = kwargs.get("backup_dir", BACKUP_PATH)
|
||||
self.status_callback: Callable[[str], None] | None = kwargs.get(
|
||||
"status_callback"
|
||||
)
|
||||
self.error_callback: Callable[[str], None] | None = kwargs.get(
|
||||
"error_callback"
|
||||
)
|
||||
self.interval_minutes: float = float(kwargs.get("interval_minutes", 5))
|
||||
self.max_backups: int = int(kwargs.get("max_backups", 10))
|
||||
self.interval_seconds: float = self.interval_minutes * 60
|
||||
self.save_callback: Callable[[], None] | None = None # Not used in tests
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self.is_running: bool = False
|
||||
self._last_save_time: datetime | None = None
|
||||
self._data_modified = False # Unused in legacy tests but kept
|
||||
self._ensure_backup_directory()
|
||||
else:
|
||||
# New application mode
|
||||
save_cb: Callable[[], None] | None = kwargs.get("save_callback")
|
||||
if save_cb is None and args:
|
||||
save_cb = args[0]
|
||||
interval = float(kwargs.get("interval_minutes", 5))
|
||||
self.save_callback = save_cb
|
||||
self.interval_minutes = interval
|
||||
self.interval_seconds = interval * 60
|
||||
self._auto_save_enabled = False
|
||||
self._save_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._last_save_time: datetime | None = None
|
||||
self._data_modified = False
|
||||
# Shim attributes for compatibility (unused in new mode)
|
||||
self.data_file_path = ""
|
||||
self.backup_dir = BACKUP_PATH
|
||||
self.status_callback = None
|
||||
self.error_callback = None
|
||||
self.max_backups = 10
|
||||
self.is_running = False
|
||||
|
||||
def enable_auto_save(self) -> None:
|
||||
"""Enable automatic saving."""
|
||||
if self._legacy_mode:
|
||||
# Map to legacy start()
|
||||
self.start()
|
||||
return
|
||||
if getattr(self, "_auto_save_enabled", False):
|
||||
return
|
||||
self._auto_save_enabled = True
|
||||
self._stop_event.clear()
|
||||
self._save_thread = threading.Thread(target=self._auto_save_loop, daemon=True)
|
||||
self._save_thread.start()
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Auto-save enabled with {self.interval_minutes:.1f} minute intervals"
|
||||
)
|
||||
|
||||
def disable_auto_save(self) -> None:
|
||||
"""Disable automatic saving."""
|
||||
if self._legacy_mode:
|
||||
self.stop()
|
||||
return
|
||||
if not getattr(self, "_auto_save_enabled", False):
|
||||
return
|
||||
self._auto_save_enabled = False
|
||||
self._stop_event.set()
|
||||
if self._save_thread and self._save_thread.is_alive():
|
||||
self._save_thread.join(timeout=2.0)
|
||||
if self.logger:
|
||||
self.logger.info("Auto-save disabled")
|
||||
|
||||
def mark_data_modified(self) -> None:
|
||||
"""Mark that data has been modified and needs saving."""
|
||||
self._data_modified = True
|
||||
|
||||
def force_save(self) -> None:
|
||||
"""Force an immediate save if data has been modified."""
|
||||
if self._data_modified and self.save_callback:
|
||||
try:
|
||||
self.save_callback()
|
||||
self._last_save_time = datetime.now()
|
||||
self._data_modified = False
|
||||
if self.logger:
|
||||
self.logger.debug("Force save completed successfully")
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Force save failed: {e}")
|
||||
|
||||
def get_last_save_time(self) -> datetime | None:
|
||||
"""Get the timestamp of the last successful save."""
|
||||
return self._last_save_time
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if auto-save is currently enabled."""
|
||||
return (
|
||||
self.is_running
|
||||
if self._legacy_mode
|
||||
else getattr(self, "_auto_save_enabled", False)
|
||||
)
|
||||
|
||||
def has_unsaved_changes(self) -> bool:
|
||||
"""Check if there are unsaved changes."""
|
||||
return self._data_modified
|
||||
|
||||
def _auto_save_loop(self) -> None:
|
||||
"""Main auto-save loop running in background thread."""
|
||||
while not self._stop_event.wait(self.interval_seconds):
|
||||
if self._data_modified and self.save_callback:
|
||||
try:
|
||||
self.save_callback()
|
||||
self._last_save_time = datetime.now()
|
||||
self._data_modified = False
|
||||
if self.logger:
|
||||
self.logger.debug("Auto-save completed successfully")
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Auto-save failed: {e}")
|
||||
|
||||
def set_interval(self, minutes: int) -> None:
|
||||
"""
|
||||
Change the auto-save interval.
|
||||
|
||||
Args:
|
||||
minutes: New interval in minutes (minimum 1, maximum 60)
|
||||
"""
|
||||
if not 1 <= minutes <= 60:
|
||||
raise ValueError("Auto-save interval must be between 1 and 60 minutes")
|
||||
old = self.interval_minutes
|
||||
self.interval_minutes = float(minutes)
|
||||
self.interval_seconds = self.interval_minutes * 60
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"Auto-save interval changed from %.1f to %.1f minutes",
|
||||
old,
|
||||
self.interval_minutes,
|
||||
)
|
||||
if not self._legacy_mode and getattr(self, "_auto_save_enabled", False):
|
||||
self.disable_auto_save()
|
||||
self.enable_auto_save()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._legacy_mode:
|
||||
self.stop()
|
||||
else:
|
||||
self.disable_auto_save()
|
||||
if self._data_modified:
|
||||
if self.logger:
|
||||
self.logger.info("Performing final save on cleanup")
|
||||
self.force_save()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy mode API (periodic file backups)
|
||||
# ------------------------------------------------------------------
|
||||
def start(self) -> None:
|
||||
if not self._legacy_mode or self.is_running:
|
||||
return
|
||||
self.is_running = True
|
||||
self._stop_event.clear()
|
||||
with contextlib.suppress(Exception):
|
||||
self.create_backup("startup")
|
||||
|
||||
def _loop() -> None:
|
||||
while not self._stop_event.wait(self.interval_seconds):
|
||||
with contextlib.suppress(Exception):
|
||||
self.create_backup("auto")
|
||||
|
||||
self._thread = threading.Thread(target=_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._legacy_mode or not self.is_running:
|
||||
return
|
||||
self.is_running = False
|
||||
self._stop_event.set()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
# --------------------- Backup helpers (legacy) ---------------------
|
||||
def _ensure_backup_directory(self) -> None:
|
||||
os.makedirs(self.backup_dir, exist_ok=True)
|
||||
|
||||
def create_backup(self, suffix: str) -> str | None:
|
||||
if not getattr(self, "data_file_path", ""):
|
||||
return None
|
||||
if not os.path.exists(self.data_file_path):
|
||||
if self.error_callback:
|
||||
self.error_callback("Source file does not exist")
|
||||
return None
|
||||
safe_suffix = re.sub(r"[^A-Za-z0-9_\-]+", "_", suffix.strip()) or "backup"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base = os.path.splitext(os.path.basename(self.data_file_path))[0]
|
||||
filename = f"{base}_{safe_suffix}_{timestamp}.csv"
|
||||
dest = os.path.join(self.backup_dir, filename)
|
||||
try:
|
||||
shutil.copy2(self.data_file_path, dest)
|
||||
if self.status_callback:
|
||||
self.status_callback(f"Backup created: {dest}")
|
||||
self._cleanup_old_backups()
|
||||
return dest
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.error_callback:
|
||||
self.error_callback(f"Backup failed: {e}")
|
||||
return None
|
||||
|
||||
def _cleanup_old_backups(self) -> None:
|
||||
pattern = os.path.join(self.backup_dir, "*.csv")
|
||||
files = glob.glob(pattern)
|
||||
if len(files) <= self.max_backups:
|
||||
return
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
for f in files[self.max_backups :]:
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(f)
|
||||
|
||||
def get_backup_files(self) -> list[str]:
|
||||
pattern = os.path.join(self.backup_dir, "*.csv")
|
||||
files = glob.glob(pattern)
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
return files
|
||||
|
||||
def restore_from_backup(self, backup_path: str) -> bool:
|
||||
if not os.path.exists(backup_path):
|
||||
if self.error_callback:
|
||||
self.error_callback("Backup file does not exist")
|
||||
return False
|
||||
try:
|
||||
shutil.copy2(backup_path, self.data_file_path)
|
||||
if self.status_callback:
|
||||
self.status_callback(f"Restored from backup: {backup_path}")
|
||||
return True
|
||||
except Exception as e: # pragma: no cover
|
||||
if self.error_callback:
|
||||
self.error_callback(f"Restore failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Standalone backup manager used by application code."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_file_path: str,
|
||||
backup_directory: str = BACKUP_PATH,
|
||||
logger=None,
|
||||
status_callback: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.data_file_path = data_file_path
|
||||
self.backup_directory = backup_directory
|
||||
self.logger = logger
|
||||
self.status_callback = status_callback
|
||||
self._ensure_backup_directory()
|
||||
|
||||
def _ensure_backup_directory(self) -> None:
|
||||
os.makedirs(self.backup_directory, exist_ok=True)
|
||||
|
||||
def create_backup(self, backup_type: str = "manual") -> str | None:
|
||||
if not os.path.exists(self.data_file_path):
|
||||
if self.logger:
|
||||
self.logger.warning("Cannot create backup: data file doesn't exist")
|
||||
return None
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
base_name = os.path.splitext(os.path.basename(self.data_file_path))[0]
|
||||
backup_filename = f"{base_name}_backup_{backup_type}_{timestamp}.csv"
|
||||
backup_path = os.path.join(self.backup_directory, backup_filename)
|
||||
shutil.copy2(self.data_file_path, backup_path)
|
||||
msg = f"Backup created: {backup_path}"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if self.status_callback:
|
||||
self.status_callback(msg)
|
||||
return backup_path
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup creation failed: {e}")
|
||||
return None
|
||||
|
||||
def cleanup_old_backups(self, keep_count: int = 10) -> None:
|
||||
try:
|
||||
backup_pattern = os.path.join(self.backup_directory, "*_backup_*.csv")
|
||||
backup_files = glob.glob(backup_pattern)
|
||||
if len(backup_files) <= keep_count:
|
||||
return
|
||||
backup_files.sort(key=os.path.getmtime, reverse=True)
|
||||
removed = 0
|
||||
for file_path in backup_files[keep_count:]:
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(file_path)
|
||||
removed += 1
|
||||
msg = f"Cleaned up {removed} old backup files"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if self.status_callback and removed:
|
||||
self.status_callback(msg)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup cleanup failed: {e}")
|
||||
|
||||
def restore_from_backup(self, backup_path: str) -> bool:
|
||||
if not os.path.exists(backup_path):
|
||||
if self.logger:
|
||||
self.logger.error(f"Backup file doesn't exist: {backup_path}")
|
||||
return False
|
||||
try:
|
||||
# Create a backup of current data before restoring
|
||||
current_backup = self.create_backup("pre_restore")
|
||||
shutil.copy2(backup_path, self.data_file_path)
|
||||
msg = f"Successfully restored from backup: {backup_path}"
|
||||
if self.logger:
|
||||
self.logger.info(msg)
|
||||
if current_backup:
|
||||
self.logger.info(f"Previous data backed up to: {current_backup}")
|
||||
if self.status_callback:
|
||||
self.status_callback(msg)
|
||||
return True
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Restore from backup failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AutoSaveManager",
|
||||
"BackupManager",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import dotenv as _dotenv
|
||||
|
||||
# Determine external data directory (supports PyInstaller)
|
||||
extDataDir = os.getcwd()
|
||||
if getattr(sys, "frozen", False): # pragma: no cover - runtime packaging path
|
||||
extDataDir = sys._MEIPASS # type: ignore[attr-defined]
|
||||
|
||||
_already_initialized = globals().get("_already_initialized", False)
|
||||
|
||||
# Snapshot environment before potential .env load so we can honor values
|
||||
# that were present prior to loading .env and ignore values introduced by it.
|
||||
_pre_env = dict(os.environ)
|
||||
|
||||
# Preserve patched load_dotenv if present (tests patch this symbol)
|
||||
if "load_dotenv" not in globals(): # first import or not patched yet
|
||||
load_dotenv = _dotenv.load_dotenv # type: ignore[assignment]
|
||||
|
||||
# Always call (tests expect call with override=True)
|
||||
load_dotenv(override=True)
|
||||
_already_initialized = True
|
||||
|
||||
|
||||
def _pre_or_default(key: str, default: str) -> str:
|
||||
"""Return the value from the pre-dotenv environment or the default.
|
||||
|
||||
Values that only exist due to .env load are ignored so tests (and env)
|
||||
take precedence, while still allowing us to call load_dotenv(override=True).
|
||||
"""
|
||||
if key in _pre_env:
|
||||
return _pre_env[key]
|
||||
# Ignore values introduced only via .env
|
||||
return default
|
||||
|
||||
|
||||
# Environment driven constants (tests expect specific defaults / formats)
|
||||
LOG_LEVEL = (_pre_or_default("LOG_LEVEL", "INFO") or "INFO").upper()
|
||||
LOG_PATH = _pre_or_default("LOG_PATH", "/tmp/logs/thechart")
|
||||
LOG_CLEAR = (_pre_or_default("LOG_CLEAR", "False") or "False").capitalize()
|
||||
BACKUP_PATH = _pre_or_default("BACKUP_PATH", "/tmp/thechart/backups")
|
||||
|
||||
__all__ = [
|
||||
"LOG_LEVEL",
|
||||
"LOG_PATH",
|
||||
"LOG_CLEAR",
|
||||
"BACKUP_PATH",
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Enhanced error handling and feedback (canonical module)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""Centralized error handling with user-friendly feedback."""
|
||||
|
||||
def __init__(self, logger: logging.Logger, ui_manager=None):
|
||||
self.logger = logger
|
||||
self.ui_manager = ui_manager
|
||||
self.error_counts: dict[str, int] = {}
|
||||
self.last_error_time: dict[str, datetime] = {}
|
||||
|
||||
def handle_error(
|
||||
self,
|
||||
error: Exception,
|
||||
context: str = "Unknown",
|
||||
user_message: str | None = None,
|
||||
show_dialog: bool = True,
|
||||
log_level: int = logging.ERROR,
|
||||
) -> None:
|
||||
error_key = f"{type(error).__name__}:{context}"
|
||||
current_time = datetime.now()
|
||||
self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
|
||||
self.last_error_time[error_key] = current_time
|
||||
|
||||
error_msg = f"Error in {context}: {str(error)}"
|
||||
if log_level >= logging.ERROR:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
elif log_level >= logging.WARNING:
|
||||
self.logger.warning(error_msg)
|
||||
else:
|
||||
self.logger.debug(error_msg)
|
||||
|
||||
if user_message is None:
|
||||
user_message = self._generate_user_message(error, context)
|
||||
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(f"Error: {user_message}", "error")
|
||||
|
||||
if show_dialog and self.ui_manager:
|
||||
show_fn = getattr(self.ui_manager, "show_error_dialog", None)
|
||||
if callable(show_fn):
|
||||
show_fn(user_message)
|
||||
else:
|
||||
self._show_error_dialog(user_message, error, context)
|
||||
|
||||
def handle_validation_error(
|
||||
self, field_name: str, error_message: str, suggested_fix: str = ""
|
||||
) -> None:
|
||||
full_message = f"Validation error in {field_name}: {error_message}"
|
||||
if suggested_fix:
|
||||
full_message += f"\n\nSuggested fix: {suggested_fix}"
|
||||
self.logger.warning(f"Validation error: {field_name} - {error_message}")
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(
|
||||
f"Invalid {field_name}: {error_message}", "warning"
|
||||
)
|
||||
|
||||
def handle_file_error(
|
||||
self,
|
||||
operation: str,
|
||||
file_path: str,
|
||||
error: Exception,
|
||||
recovery_action: str = "",
|
||||
) -> None:
|
||||
context = f"File {operation}: {file_path}"
|
||||
user_message = f"Failed to {operation} file: {file_path}"
|
||||
if recovery_action:
|
||||
user_message += f"\n\nSuggested action: {recovery_action}"
|
||||
self.handle_error(error, context, user_message)
|
||||
|
||||
def handle_data_error(
|
||||
self,
|
||||
operation: str,
|
||||
data_type: str,
|
||||
error: Exception,
|
||||
recovery_suggestions: list[str] | None = None,
|
||||
) -> None:
|
||||
context = f"Data {operation}: {data_type}"
|
||||
user_message = f"Data error during {operation} of {data_type}"
|
||||
if recovery_suggestions:
|
||||
user_message += "\n\nTry these solutions:\n"
|
||||
user_message += "\n".join(f"• {s}" for s in recovery_suggestions)
|
||||
self.handle_error(error, context, user_message)
|
||||
|
||||
def log_performance_warning(
|
||||
self, operation: str, duration_seconds: float, threshold_seconds: float = 1.0
|
||||
) -> None:
|
||||
if duration_seconds > threshold_seconds:
|
||||
self.logger.warning(
|
||||
f"Performance warning: {operation} took {duration_seconds:.2f}s "
|
||||
f"(threshold: {threshold_seconds:.2f}s)"
|
||||
)
|
||||
if self.ui_manager:
|
||||
self.ui_manager.update_status(
|
||||
f"Operation completed but was slow: {operation}", "warning"
|
||||
)
|
||||
|
||||
def get_error_summary(self) -> dict[str, Any]:
|
||||
return {
|
||||
"total_errors": sum(self.error_counts.values()),
|
||||
"unique_errors": len(self.error_counts),
|
||||
"error_counts": self.error_counts.copy(),
|
||||
"last_error_times": self.last_error_time.copy(),
|
||||
}
|
||||
|
||||
def _generate_user_message(self, error: Exception, context: str) -> str:
|
||||
error_type = type(error).__name__
|
||||
user_messages = {
|
||||
"FileNotFoundError": "The requested file could not be found.",
|
||||
"PermissionError": "Permission denied. Check file permissions.",
|
||||
"ValueError": "Invalid data format or value.",
|
||||
"TypeError": "Incorrect data type provided.",
|
||||
"KeyError": "Required data field is missing.",
|
||||
"ConnectionError": "Network connection failed.",
|
||||
"MemoryError": "Insufficient memory to complete operation.",
|
||||
"OSError": "System operation failed.",
|
||||
}
|
||||
base_message = user_messages.get(
|
||||
error_type, f"An unexpected error occurred: {str(error)}"
|
||||
)
|
||||
return f"{base_message} (Context: {context})"
|
||||
|
||||
def _show_error_dialog(
|
||||
self, user_message: str, error: Exception, context: str
|
||||
) -> None:
|
||||
from tkinter import messagebox
|
||||
|
||||
title = f"Error in {context}"
|
||||
messagebox.showerror(title, user_message)
|
||||
|
||||
|
||||
class OperationTimer:
|
||||
"""Context manager for timing operations and detecting performance issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_handler: ErrorHandler | None,
|
||||
operation_name: str,
|
||||
warning_threshold: float = 1.0,
|
||||
):
|
||||
self.error_handler = error_handler
|
||||
self.operation_name = operation_name
|
||||
self.warning_threshold = warning_threshold
|
||||
self.start_time: float | None = None
|
||||
|
||||
def __enter__(self):
|
||||
import time
|
||||
|
||||
self.start_time = time.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc_val, _exc_tb):
|
||||
import time
|
||||
|
||||
if self.start_time is not None:
|
||||
duration = time.time() - self.start_time
|
||||
if duration > self.warning_threshold and self.error_handler:
|
||||
self.error_handler.log_performance_warning(
|
||||
self.operation_name, duration, self.warning_threshold
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def handle_exceptions(error_handler: ErrorHandler, context: str = "Operation"):
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
error_handler.handle_error(e, f"{context}:{func.__name__}")
|
||||
if isinstance(e, MemoryError | KeyboardInterrupt | SystemExit):
|
||||
raise
|
||||
return None
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class UserFeedback:
|
||||
"""Enhanced user feedback system with progress tracking."""
|
||||
|
||||
def __init__(self, ui_manager=None, logger: logging.Logger | None = None):
|
||||
self.ui_manager = ui_manager
|
||||
self.logger = logger
|
||||
self.current_operation: str | None = None
|
||||
self.operation_start_time: float | None = None
|
||||
|
||||
def start_operation(
|
||||
self, operation_name: str, estimated_duration: float | None = None
|
||||
) -> None:
|
||||
import time
|
||||
|
||||
self.current_operation = operation_name
|
||||
self.operation_start_time = time.time()
|
||||
if self.ui_manager:
|
||||
message = f"Starting: {operation_name}"
|
||||
if estimated_duration:
|
||||
message += f" (estimated: {estimated_duration:.1f}s)"
|
||||
self.ui_manager.update_status(message, "info")
|
||||
if self.logger:
|
||||
self.logger.info(f"Started operation: {operation_name}")
|
||||
|
||||
def update_progress(
|
||||
self, progress_text: str, percentage: float | None = None
|
||||
) -> None:
|
||||
if not self.current_operation:
|
||||
return
|
||||
if self.ui_manager:
|
||||
message = f"{self.current_operation}: {progress_text}"
|
||||
if percentage is not None:
|
||||
message += f" ({percentage:.1f}%)"
|
||||
self.ui_manager.update_status(message, "info")
|
||||
|
||||
def complete_operation(self, success: bool = True, final_message: str = "") -> None:
|
||||
if not self.current_operation:
|
||||
return
|
||||
import time
|
||||
|
||||
duration = None
|
||||
if self.operation_start_time:
|
||||
duration = time.time() - self.operation_start_time
|
||||
if self.ui_manager:
|
||||
if final_message:
|
||||
message = final_message
|
||||
else:
|
||||
status_word = "completed" if success else "failed"
|
||||
message = f"{self.current_operation} {status_word}"
|
||||
if duration:
|
||||
message += f" ({duration:.1f}s)"
|
||||
status_type = "success" if success else "error"
|
||||
self.ui_manager.update_status(message, status_type)
|
||||
if self.logger:
|
||||
status_word = "completed" if success else "failed"
|
||||
log_message = f"Operation {status_word}: {self.current_operation}"
|
||||
if duration:
|
||||
log_message += f" (duration: {duration:.1f}s)"
|
||||
if success:
|
||||
self.logger.info(log_message)
|
||||
else:
|
||||
self.logger.error(log_message)
|
||||
self.current_operation = None
|
||||
self.operation_start_time = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ErrorHandler",
|
||||
"OperationTimer",
|
||||
"handle_exceptions",
|
||||
"UserFeedback",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Application logging utilities (canonical).
|
||||
|
||||
This module centralizes logger initialization and honors environment-driven
|
||||
settings from `thechart.core.constants` (LOG_LEVEL, LOG_PATH, LOG_CLEAR).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
try: # Optional dependency; fall back to plain logging if missing
|
||||
import colorlog # type: ignore
|
||||
except Exception: # pragma: no cover - defensive in case of runtime packaging
|
||||
colorlog = None
|
||||
|
||||
from .constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
|
||||
|
||||
|
||||
def _bool_from_str(value: str) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _level_from_str(level: str) -> int:
|
||||
try:
|
||||
return getattr(logging, level.upper())
|
||||
except AttributeError:
|
||||
return logging.INFO
|
||||
|
||||
|
||||
def init_logger(dunder_name: str, testing_mode: bool) -> logging.Logger:
|
||||
"""Initialize and return a configured logger.
|
||||
|
||||
- Ensures the log directory exists (LOG_PATH) indirectly; failures are tolerated.
|
||||
- Respects LOG_CLEAR: writes files in overwrite mode when true.
|
||||
- Respects LOG_LEVEL for non-testing runs; testing forces DEBUG.
|
||||
- Prevents duplicate handlers on repeated initialization.
|
||||
"""
|
||||
|
||||
log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
|
||||
|
||||
logger = logging.getLogger(dunder_name)
|
||||
logger.propagate = False
|
||||
|
||||
# Clear existing handlers to avoid duplicates on re-init
|
||||
if logger.handlers:
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
with contextlib.suppress(Exception):
|
||||
h.close()
|
||||
|
||||
# Level selection
|
||||
logger.setLevel(logging.DEBUG if testing_mode else _level_from_str(LOG_LEVEL))
|
||||
|
||||
# Console handler (colored if colorlog available)
|
||||
if colorlog is not None:
|
||||
bold_seq = "\033[1m"
|
||||
colorlog_format = f"{bold_seq} %(log_color)s {log_format}"
|
||||
sh = colorlog.StreamHandler()
|
||||
sh.setLevel(logger.level)
|
||||
sh.setFormatter(colorlog.ColoredFormatter(colorlog_format))
|
||||
else:
|
||||
sh = logging.StreamHandler()
|
||||
sh.setLevel(logger.level)
|
||||
sh.setFormatter(logging.Formatter(log_format))
|
||||
logger.addHandler(sh)
|
||||
|
||||
# File handlers (overwrite if LOG_CLEAR truthy)
|
||||
write_mode = "w" if _bool_from_str(LOG_CLEAR) else "a"
|
||||
formatter = logging.Formatter(log_format)
|
||||
|
||||
try:
|
||||
fh_all = logging.FileHandler(
|
||||
f"{LOG_PATH}/thechart.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_all.setLevel(logging.DEBUG)
|
||||
fh_all.setFormatter(formatter)
|
||||
logger.addHandler(fh_all)
|
||||
|
||||
fh_warn = logging.FileHandler(
|
||||
f"{LOG_PATH}/thechart.warning.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_warn.setLevel(logging.WARNING)
|
||||
fh_warn.setFormatter(formatter)
|
||||
logger.addHandler(fh_warn)
|
||||
|
||||
fh_err = logging.FileHandler(
|
||||
f"{LOG_PATH}/thechart.error.log", mode=write_mode, encoding="utf-8"
|
||||
)
|
||||
fh_err.setLevel(logging.ERROR)
|
||||
fh_err.setFormatter(formatter)
|
||||
logger.addHandler(fh_err)
|
||||
except (PermissionError, FileNotFoundError):
|
||||
# Fall back to console-only logging in restricted environments
|
||||
pass
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Application preferences with simple JSON persistence.
|
||||
|
||||
API stays minimal: get_pref/set_pref for reads and writes, plus
|
||||
load_preferences/save_preferences to manage disk state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
# After a successful restore, offer to open the backups folder?
|
||||
"prompt_open_folder_after_restore": False,
|
||||
# Remember and restore window geometry between runs
|
||||
"remember_window_geometry": True,
|
||||
"last_window_geometry": "",
|
||||
# Keep window always on top
|
||||
"always_on_top": False,
|
||||
# Search/filter UI state
|
||||
"search_panel_visible": False,
|
||||
"last_filter_state": None,
|
||||
# Table column UX
|
||||
"column_widths": {},
|
||||
"last_sort": {"column": None, "ascending": True},
|
||||
# Data: archiving/rotation
|
||||
"archive_keep_years": 1,
|
||||
}
|
||||
|
||||
_PREFERENCES: dict[str, Any] = dict(_DEFAULTS)
|
||||
|
||||
|
||||
def _config_dir() -> str:
|
||||
"""Return platform-appropriate config directory for TheChart."""
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||
return os.path.join(base, "TheChart")
|
||||
if sys.platform == "darwin":
|
||||
return os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"TheChart",
|
||||
)
|
||||
# Linux and others: follow XDG
|
||||
base = os.environ.get(
|
||||
"XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")
|
||||
)
|
||||
return os.path.join(base, "thechart")
|
||||
except Exception:
|
||||
# Fallback to current directory if anything goes wrong
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
return os.path.join(_config_dir(), "preferences.json")
|
||||
|
||||
|
||||
def get_config_dir() -> str:
|
||||
"""Public accessor for the application configuration directory."""
|
||||
return _config_dir()
|
||||
|
||||
|
||||
def load_preferences() -> None:
|
||||
"""Load preferences from disk if present, fallback to defaults."""
|
||||
global _PREFERENCES
|
||||
path = _config_path()
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
merged = dict(_DEFAULTS)
|
||||
merged.update(data)
|
||||
_PREFERENCES = merged
|
||||
except Exception:
|
||||
# Ignore corrupt or unreadable files; continue with current prefs
|
||||
pass
|
||||
|
||||
|
||||
def save_preferences() -> None:
|
||||
"""Persist preferences to disk atomically."""
|
||||
path = _config_path()
|
||||
directory = os.path.dirname(path)
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
tmp_path = path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_PREFERENCES, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
# Best-effort persistence; ignore failures silently
|
||||
pass
|
||||
|
||||
|
||||
def reset_preferences() -> None:
|
||||
"""Reset preferences in memory to defaults and persist to disk."""
|
||||
global _PREFERENCES
|
||||
_PREFERENCES = dict(_DEFAULTS)
|
||||
save_preferences()
|
||||
|
||||
|
||||
def get_pref(key: str, default: Any | None = None) -> Any:
|
||||
"""Get a preference value, or default if unset."""
|
||||
return _PREFERENCES.get(key, default)
|
||||
|
||||
|
||||
def set_pref(key: str, value: Any) -> None:
|
||||
"""Set a preference value in memory (call save_preferences to persist)."""
|
||||
_PREFERENCES[key] = value
|
||||
|
||||
|
||||
# Attempt to load preferences on import for convenience
|
||||
load_preferences()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Undo stack for add/update/delete operations (canonical module)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UndoAction:
|
||||
description: str
|
||||
undo_callable: Callable[[], None]
|
||||
|
||||
|
||||
class UndoManager:
|
||||
def __init__(self, capacity: int = 20) -> None:
|
||||
self.capacity = capacity
|
||||
self._stack: list[UndoAction] = []
|
||||
|
||||
def push(self, action: UndoAction) -> None:
|
||||
self._stack.append(action)
|
||||
if len(self._stack) > self.capacity:
|
||||
self._stack.pop(0)
|
||||
|
||||
def undo(self) -> str | None:
|
||||
if not self._stack:
|
||||
return None
|
||||
action = self._stack.pop()
|
||||
action.undo_callable()
|
||||
return action.description
|
||||
|
||||
def has_actions(self) -> bool:
|
||||
return bool(self._stack)
|
||||
|
||||
|
||||
__all__ = ["UndoAction", "UndoManager"]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Data layer re-exports for TheChart.
|
||||
|
||||
Canonical implementations live under ``thechart.data``. Legacy ``src`` modules
|
||||
are thin shims importing from here to preserve backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .data_manager import DataManager # noqa: F401
|
||||
|
||||
__all__ = ["DataManager"]
|
||||
@@ -0,0 +1,541 @@
|
||||
"""Canonical DataManager implementation.
|
||||
|
||||
This file holds the authoritative implementation that used to live at
|
||||
``src/data_manager.py``. The legacy module has been replaced by a shim
|
||||
importing from here to preserve backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# ruff: noqa: I001
|
||||
|
||||
# isort: off # keep grouped imports stable during migration
|
||||
|
||||
# Reuse the implementation from the legacy file by pasting its code here.
|
||||
# Minimal adjustments: fix intra-project imports to go through package shims.
|
||||
|
||||
# Standard library
|
||||
import csv
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
# Third-party
|
||||
import pandas as pd
|
||||
|
||||
# Local imports
|
||||
from thechart.managers import MedicineManager, PathologyManager
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""Handle all data operations for the application with performance optimizations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename: str,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self._init_internal(
|
||||
filename,
|
||||
logger,
|
||||
medicine_manager,
|
||||
pathology_manager,
|
||||
)
|
||||
|
||||
def _init_internal(
|
||||
self,
|
||||
filename: str,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
self.logger = logger
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
|
||||
self._data_cache = None
|
||||
self._cache_timestamp = 0
|
||||
self._headers_cache = None
|
||||
self._dtype_cache = None
|
||||
self._graph_cache = None
|
||||
self._config_version = 0
|
||||
self._initialize_csv_file()
|
||||
|
||||
def _get_csv_headers(self) -> tuple[str, ...]:
|
||||
"""Get CSV headers based on current pathology and medicine configuration.
|
||||
Cached to avoid repeated computation."""
|
||||
if self._headers_cache is not None:
|
||||
return self._headers_cache
|
||||
|
||||
# Start with date
|
||||
headers = ["date"]
|
||||
|
||||
# Add pathology headers
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
headers.append(pathology_key)
|
||||
|
||||
# Add medicine headers
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
headers.extend([medicine_key, f"{medicine_key}_doses"])
|
||||
|
||||
result = tuple(headers + ["note"])
|
||||
self._headers_cache = result
|
||||
return result
|
||||
|
||||
def _initialize_csv_file(self) -> None:
|
||||
"""Create CSV file with headers if it doesn't exist or is empty."""
|
||||
try:
|
||||
creating = not os.path.exists(self.filename)
|
||||
if creating or os.path.getsize(self.filename) == 0:
|
||||
with open(self.filename, mode="w", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(self._get_csv_headers())
|
||||
if creating:
|
||||
# Emit warning so tests detect creation of missing file
|
||||
self.logger.warning(
|
||||
"CSV file did not exist and was created with headers."
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize CSV file: {e}")
|
||||
|
||||
def _invalidate_cache(self) -> None:
|
||||
"""Invalidate the data cache when data changes."""
|
||||
self._data_cache = None
|
||||
self._cache_timestamp = 0
|
||||
self._graph_cache = None
|
||||
|
||||
def invalidate_structure(self) -> None:
|
||||
"""Invalidate caches due to structural changes (e.g., medicines/pathologies).
|
||||
|
||||
Public method for other managers / UI to call instead of reaching into
|
||||
private attributes. This bumps a config version ensuring future loads
|
||||
rebuild dependent caches.
|
||||
"""
|
||||
self._headers_cache = None
|
||||
self._dtype_cache = None
|
||||
self._graph_cache = None
|
||||
self._config_version += 1
|
||||
# Data remains valid but columns may differ; safest is full invalidation
|
||||
self._invalidate_cache()
|
||||
|
||||
def _should_reload_data(self) -> bool:
|
||||
"""Check if data should be reloaded based on file modification time."""
|
||||
if self._data_cache is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
file_mtime = os.path.getmtime(self.filename)
|
||||
return file_mtime > self._cache_timestamp
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _get_dtype_dict(self) -> dict[str, type]:
|
||||
"""Get pandas dtype dictionary for efficient reading.
|
||||
Cached to avoid recreation."""
|
||||
if self._dtype_cache is not None:
|
||||
return self._dtype_cache
|
||||
|
||||
dtype_dict = {"date": str, "note": str}
|
||||
|
||||
# Add pathology types
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
dtype_dict[pathology_key] = int
|
||||
|
||||
# Add medicine types
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
dtype_dict[medicine_key] = int
|
||||
dtype_dict[f"{medicine_key}_doses"] = str
|
||||
|
||||
self._dtype_cache = dtype_dict
|
||||
return dtype_dict
|
||||
|
||||
def load_data(self) -> pd.DataFrame:
|
||||
"""Load data from CSV file with caching for better performance."""
|
||||
if not os.path.exists(self.filename):
|
||||
self.logger.warning("CSV file does not exist. No data to load.")
|
||||
return pd.DataFrame()
|
||||
if os.path.getsize(self.filename) == 0:
|
||||
self.logger.warning("CSV file is empty. No data to load.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Use cached data if available and file hasn't changed
|
||||
if not self._should_reload_data():
|
||||
return self._data_cache.copy()
|
||||
|
||||
try:
|
||||
# Use pre-built dtype dictionary for faster parsing
|
||||
dtype_dict = self._get_dtype_dict()
|
||||
|
||||
# Read with optimized settings
|
||||
df: pd.DataFrame = pd.read_csv(
|
||||
self.filename,
|
||||
dtype=dtype_dict,
|
||||
na_filter=False, # Don't convert to NaN, keep as empty strings
|
||||
engine="c", # Use faster C engine
|
||||
)
|
||||
|
||||
# If file has only headers (no rows), treat as empty with warning
|
||||
if df.empty:
|
||||
self.logger.warning("CSV file contains only headers. No data to load.")
|
||||
return pd.DataFrame()
|
||||
|
||||
# Sort only if needed (check if already sorted)
|
||||
if len(df) > 1 and not df["date"].is_monotonic_increasing:
|
||||
df = df.sort_values(by="date").reset_index(drop=True)
|
||||
|
||||
# Cache the data and timestamp
|
||||
self._data_cache = df.copy()
|
||||
self._cache_timestamp = os.path.getmtime(self.filename)
|
||||
# Invalidate graph cache because underlying data changed
|
||||
self._graph_cache = None
|
||||
|
||||
return df.copy()
|
||||
|
||||
except pd.errors.EmptyDataError:
|
||||
self.logger.warning("CSV file is empty. No data to load.")
|
||||
return pd.DataFrame()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading data: {str(e)}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def add_entry(self, entry_data: list[str | int]) -> bool:
|
||||
"""Add a new entry to the CSV file with optimized duplicate checking."""
|
||||
try:
|
||||
# Quick duplicate check using cached data if available
|
||||
date_to_add: str = str(entry_data[0])
|
||||
|
||||
if self._data_cache is not None:
|
||||
# Use cached data for duplicate check
|
||||
if date_to_add in self._data_cache["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# Fallback to loading data if no cache
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if not df.empty and date_to_add in df["date"].values:
|
||||
self.logger.warning(
|
||||
f"Entry with date {date_to_add} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Write to file
|
||||
with open(self.filename, mode="a", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(entry_data)
|
||||
|
||||
# Invalidate cache since data changed
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error adding entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def update_entry(self, original_date: str, values: list[str | int]) -> bool:
|
||||
"""Update an existing entry identified by original_date
|
||||
with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
new_date: str = str(values[0])
|
||||
|
||||
# Optimized duplicate check
|
||||
if original_date != new_date:
|
||||
date_exists = (df["date"] == new_date).any()
|
||||
if date_exists:
|
||||
self.logger.warning(
|
||||
f"Cannot update: entry with date {new_date} already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
# Get current CSV headers to match with values
|
||||
headers = list(self._get_csv_headers())
|
||||
|
||||
# Ensure we have the right number of values with optimized padding
|
||||
if len(values) < len(headers):
|
||||
# Pad with defaults efficiently
|
||||
padding_needed = len(headers) - len(values)
|
||||
for i in range(padding_needed):
|
||||
header_idx = len(values) + i
|
||||
if header_idx < len(headers):
|
||||
header = headers[header_idx]
|
||||
if header == "note" or header.endswith("_doses"):
|
||||
values.append("")
|
||||
else:
|
||||
values.append(0)
|
||||
|
||||
# Use vectorized update for better performance
|
||||
mask = df["date"] == original_date
|
||||
if mask.any():
|
||||
df.loc[mask, headers] = values
|
||||
# Atomic write back to CSV to avoid partial writes
|
||||
self._atomic_write_csv(df)
|
||||
self._invalidate_cache()
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Entry with date {original_date} not found for update."
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating entry: {str(e)}")
|
||||
return False
|
||||
|
||||
def delete_entry(self, date: str) -> bool:
|
||||
"""Delete an entry identified by date with optimized processing."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
original_len = len(df)
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
df = df[df["date"] != date]
|
||||
|
||||
# Only write if something was actually deleted
|
||||
if len(df) < original_len:
|
||||
self._atomic_write_csv(df)
|
||||
self._invalidate_cache()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting entry: {str(e)}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File write helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _atomic_write_csv(self, df: pd.DataFrame) -> None:
|
||||
"""Write a DataFrame to CSV atomically by writing to a temp file then replacing.
|
||||
|
||||
This prevents corrupted files if the app crashes mid-write.
|
||||
"""
|
||||
directory = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix="thechart_", suffix=".csv", dir=directory
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as tmp_file:
|
||||
df.to_csv(tmp_file, index=False)
|
||||
os.replace(tmp_path, self.filename)
|
||||
finally:
|
||||
# If replace succeeded tmp_path no longer exists; suppress errors
|
||||
try:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Archiving / Rotation
|
||||
# ------------------------------------------------------------------
|
||||
def _get_archive_dir(self) -> str:
|
||||
"""Return path to the archives directory next to the main CSV."""
|
||||
base_dir = os.path.dirname(os.path.abspath(self.filename)) or "."
|
||||
archive_dir = os.path.join(base_dir, "archives")
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
return archive_dir
|
||||
|
||||
def _ensure_headers(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Ensure dataframe has all expected headers in correct order.
|
||||
|
||||
Missing numeric fields default to 0; dose/note string fields to ''.
|
||||
Columns are ordered per _get_csv_headers().
|
||||
"""
|
||||
headers = list(self._get_csv_headers())
|
||||
out = df.copy()
|
||||
for col in headers:
|
||||
if col not in out.columns:
|
||||
if col == "note" or col.endswith("_doses"):
|
||||
out[col] = ""
|
||||
else:
|
||||
out[col] = 0
|
||||
# Drop unknown columns to keep files tidy
|
||||
out = out[headers]
|
||||
return out
|
||||
|
||||
def _write_archive_file(self, year: int, df: pd.DataFrame) -> str:
|
||||
"""Append archived rows to a per-year CSV with full headers.
|
||||
|
||||
Returns the archive file path.
|
||||
"""
|
||||
archive_dir = self._get_archive_dir()
|
||||
base = os.path.splitext(os.path.basename(self.filename))[0]
|
||||
archive_path = os.path.join(archive_dir, f"{base}_{year}.csv")
|
||||
df_to_write = self._ensure_headers(df)
|
||||
# If file doesn't exist, write with header; else append without header
|
||||
write_header = (
|
||||
not os.path.exists(archive_path) or os.path.getsize(archive_path) == 0
|
||||
)
|
||||
try:
|
||||
df_to_write.to_csv(archive_path, mode="a", index=False, header=write_header)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write archive file {archive_path}: {e}")
|
||||
raise
|
||||
return archive_path
|
||||
|
||||
def archive_old_data(self, keep_years: int = 1) -> dict[str, Any]:
|
||||
"""Archive rows older than the most recent N years into per-year files.
|
||||
|
||||
Args:
|
||||
keep_years: Number of most recent full calendar years to keep in the
|
||||
main CSV (minimum 1). Rows with a date older than the earliest
|
||||
kept year are moved to archives/BASE_YYYY.csv.
|
||||
|
||||
Returns:
|
||||
Summary dict: { 'archived_rows': int, 'archive_files': set[str],
|
||||
'kept_rows': int }
|
||||
"""
|
||||
try:
|
||||
keep_years = max(1, int(keep_years))
|
||||
except Exception:
|
||||
keep_years = 1
|
||||
|
||||
df = self.load_data()
|
||||
if df.empty or "date" not in df.columns:
|
||||
return {"archived_rows": 0, "archive_files": set(), "kept_rows": 0}
|
||||
|
||||
# Parse dates (stored as mm/dd/YYYY normally)
|
||||
dates = pd.to_datetime(df["date"], format="%m/%d/%Y", errors="coerce")
|
||||
df = df.copy()
|
||||
df["__dt"] = dates
|
||||
# If we couldn't parse dates, nothing to archive safely
|
||||
if df["__dt"].isna().all():
|
||||
df.drop(columns=["__dt"], inplace=True)
|
||||
return {
|
||||
"archived_rows": 0,
|
||||
"archive_files": set(),
|
||||
"kept_rows": int(len(df)),
|
||||
}
|
||||
|
||||
current_year = datetime.now().year
|
||||
earliest_kept_year = current_year - keep_years + 1
|
||||
|
||||
to_archive = df[df["__dt"].dt.year < earliest_kept_year]
|
||||
to_keep = df[df["__dt"].dt.year >= earliest_kept_year]
|
||||
|
||||
if to_archive.empty:
|
||||
df.drop(columns=["__dt"], inplace=True)
|
||||
return {
|
||||
"archived_rows": 0,
|
||||
"archive_files": set(),
|
||||
"kept_rows": int(len(df)),
|
||||
}
|
||||
|
||||
archive_files: set[str] = set()
|
||||
try:
|
||||
# Group by year and append to each year's archive file
|
||||
for year, group in to_archive.groupby(to_archive["__dt"].dt.year):
|
||||
group = group.drop(columns=["__dt"]) # remove helper
|
||||
path = self._write_archive_file(int(year), group)
|
||||
archive_files.add(path)
|
||||
|
||||
# Write the kept rows back to main CSV atomically
|
||||
kept_df = to_keep.drop(columns=["__dt"]).copy()
|
||||
# Ensure columns and order
|
||||
kept_df = self._ensure_headers(kept_df)
|
||||
self._atomic_write_csv(kept_df)
|
||||
self._invalidate_cache()
|
||||
except Exception as e:
|
||||
# If archiving failed mid-way, log and propagate minimal info
|
||||
self.logger.error(f"Archiving failed: {e}")
|
||||
raise
|
||||
|
||||
return {
|
||||
"archived_rows": int(len(to_archive)),
|
||||
"archive_files": archive_files,
|
||||
"kept_rows": int(len(to_keep)),
|
||||
}
|
||||
|
||||
def get_today_medicine_doses(
|
||||
self, date: str, medicine_name: str
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get list of (timestamp, dose) tuples for a medicine on a given date
|
||||
with caching."""
|
||||
try:
|
||||
df: pd.DataFrame = self.load_data()
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
# Use vectorized filtering for better performance
|
||||
date_mask = df["date"] == date
|
||||
if not date_mask.any():
|
||||
return []
|
||||
|
||||
dose_column = f"{medicine_name}_doses"
|
||||
if dose_column not in df.columns:
|
||||
return []
|
||||
|
||||
doses_str = df.loc[date_mask, dose_column].iloc[0]
|
||||
|
||||
if not doses_str:
|
||||
return []
|
||||
|
||||
# Optimized dose parsing
|
||||
doses = []
|
||||
for dose_entry in doses_str.split("|"):
|
||||
if ":" in dose_entry:
|
||||
parts = dose_entry.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
doses.append((parts[0], parts[1]))
|
||||
|
||||
return doses
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting medicine doses: {str(e)}")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retrieval helpers
|
||||
# ------------------------------------------------------------------
|
||||
def get_row(self, date: str) -> list[str | int] | None:
|
||||
"""Return a row (as list aligned with current headers) for a date.
|
||||
|
||||
Args:
|
||||
date: Date string identifying the row
|
||||
Returns:
|
||||
List of values aligned with current CSV headers or None if not found.
|
||||
"""
|
||||
try:
|
||||
df = self.load_data()
|
||||
if df.empty or "date" not in df.columns:
|
||||
return None
|
||||
mask = df["date"] == date
|
||||
if not mask.any():
|
||||
return None
|
||||
headers = list(self._get_csv_headers())
|
||||
row_series = df.loc[mask, headers].iloc[0]
|
||||
return [row_series[h] for h in headers]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph Data Handling
|
||||
# ------------------------------------------------------------------
|
||||
def get_graph_ready_data(self) -> pd.DataFrame:
|
||||
"""Return a dataframe ready for graphing (datetime index cached).
|
||||
|
||||
This avoids repeatedly parsing dates & re-sorting in the graph layer.
|
||||
"""
|
||||
base_df = self.load_data()
|
||||
if base_df.empty:
|
||||
return base_df
|
||||
if self._graph_cache is not None:
|
||||
return self._graph_cache.copy()
|
||||
try:
|
||||
graph_df = base_df.copy()
|
||||
# Expect date stored in mm/dd/YYYY format
|
||||
graph_df["date"] = pd.to_datetime(
|
||||
graph_df["date"], format="%m/%d/%Y", errors="coerce"
|
||||
)
|
||||
graph_df = graph_df.dropna(subset=["date"]).sort_values("date")
|
||||
graph_df.set_index("date", inplace=True)
|
||||
self._graph_cache = graph_df.copy()
|
||||
return graph_df
|
||||
except Exception:
|
||||
# Fallback: return original (unindexed) data
|
||||
return base_df
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Export subsystem public API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .export_manager import ExportManager # noqa: F401
|
||||
|
||||
__all__ = ["ExportManager"]
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Export Manager for TheChart Application (canonical implementation).
|
||||
|
||||
Handles exporting data and graphs to various formats:
|
||||
- CSV data to JSON, XML
|
||||
- Graphs to PDF (with data tables)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.dom import minidom
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
# Third-party
|
||||
import pandas as pd
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import (
|
||||
Image,
|
||||
PageBreak,
|
||||
Paragraph,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
|
||||
# Local canonical imports
|
||||
from thechart.analytics import GraphManager
|
||||
from thechart.data import DataManager
|
||||
from thechart.managers import MedicineManager, PathologyManager
|
||||
|
||||
|
||||
class ExportManager:
|
||||
"""Handle data and graph export operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_manager: DataManager,
|
||||
graph_manager: GraphManager,
|
||||
medicine_manager: MedicineManager,
|
||||
pathology_manager: PathologyManager,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self.data_manager = data_manager
|
||||
self.graph_manager = graph_manager
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
self.logger = logger
|
||||
|
||||
def export_data_to_json(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to JSON format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
|
||||
# Convert DataFrame to dictionary with better structure
|
||||
export_data = {
|
||||
"metadata": {
|
||||
"export_date": datetime.now().isoformat(),
|
||||
"total_entries": len(df),
|
||||
"date_range": {
|
||||
"start": df["date"].min() if not df.empty else None,
|
||||
"end": df["date"].max() if not df.empty else None,
|
||||
},
|
||||
"pathologies": list(self.pathology_manager.get_pathology_keys()),
|
||||
"medicines": list(self.medicine_manager.get_medicine_keys()),
|
||||
},
|
||||
"entries": df.to_dict(orient="records"),
|
||||
}
|
||||
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.info(f"Data exported to JSON: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to JSON: {str(e)}")
|
||||
return False
|
||||
|
||||
def export_data_to_xml(
|
||||
self, export_path: str, df: pd.DataFrame | None = None
|
||||
) -> bool:
|
||||
"""Export CSV data to XML format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
if df.empty:
|
||||
self.logger.warning("No data to export")
|
||||
return False
|
||||
|
||||
# Create root element
|
||||
root = Element("thechart_data")
|
||||
|
||||
# Add metadata
|
||||
metadata = SubElement(root, "metadata")
|
||||
SubElement(metadata, "export_date").text = datetime.now().isoformat()
|
||||
SubElement(metadata, "total_entries").text = str(len(df))
|
||||
|
||||
# Date range
|
||||
date_range = SubElement(metadata, "date_range")
|
||||
SubElement(date_range, "start").text = (
|
||||
df["date"].min() if not df.empty else ""
|
||||
)
|
||||
SubElement(date_range, "end").text = (
|
||||
df["date"].max() if not df.empty else ""
|
||||
)
|
||||
|
||||
# Pathologies
|
||||
pathologies = SubElement(metadata, "pathologies")
|
||||
for pathology in self.pathology_manager.get_pathology_keys():
|
||||
SubElement(pathologies, "pathology").text = pathology
|
||||
|
||||
# Medicines
|
||||
medicines = SubElement(metadata, "medicines")
|
||||
for medicine in self.medicine_manager.get_medicine_keys():
|
||||
SubElement(medicines, "medicine").text = medicine
|
||||
|
||||
# Add entries
|
||||
entries = SubElement(root, "entries")
|
||||
for _, row in df.iterrows():
|
||||
entry = SubElement(entries, "entry")
|
||||
for column, value in row.items():
|
||||
elem = SubElement(entry, column.replace(" ", "_"))
|
||||
elem.text = str(value) if pd.notna(value) else ""
|
||||
|
||||
# Pretty print XML
|
||||
rough_string = tostring(root, "utf-8")
|
||||
reparsed = minidom.parseString(rough_string)
|
||||
pretty_xml = reparsed.toprettyxml(indent=" ")
|
||||
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
f.write(pretty_xml)
|
||||
|
||||
self.logger.info(f"Data exported to XML: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to XML: {str(e)}")
|
||||
return False
|
||||
|
||||
def _save_graph_as_image(self, temp_dir: Path) -> str | None:
|
||||
"""Save current graph as temporary image for PDF inclusion."""
|
||||
try:
|
||||
# Check if graph manager exists
|
||||
if self.graph_manager is None:
|
||||
self.logger.warning("No graph manager available for export")
|
||||
return None
|
||||
|
||||
# Check if graph manager and figure exist
|
||||
if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None:
|
||||
self.logger.warning("No graph figure available for export")
|
||||
return None
|
||||
|
||||
# Ensure graph is up to date with current data
|
||||
df = self.data_manager.load_data()
|
||||
if not df.empty:
|
||||
self.graph_manager.update_graph(df)
|
||||
else:
|
||||
self.logger.warning("No data available to update graph for export")
|
||||
return None
|
||||
|
||||
# Ensure temp directory exists
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_image_path = temp_dir / "graph.png"
|
||||
|
||||
# Save the current figure
|
||||
self.graph_manager.fig.savefig(
|
||||
str(temp_image_path),
|
||||
dpi=150,
|
||||
bbox_inches="tight",
|
||||
facecolor="white",
|
||||
edgecolor="none",
|
||||
)
|
||||
|
||||
# Ensure the figure data is properly flushed to disk
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
plt.draw()
|
||||
plt.pause(0.01) # Small pause to ensure file is written
|
||||
|
||||
# Verify the file was actually created and has content
|
||||
if not temp_image_path.exists():
|
||||
self.logger.error(
|
||||
f"Graph image file was not created: {temp_image_path}"
|
||||
)
|
||||
return None
|
||||
|
||||
if temp_image_path.stat().st_size == 0:
|
||||
self.logger.error(f"Graph image file is empty: {temp_image_path}")
|
||||
return None
|
||||
|
||||
self.logger.info(f"Graph image saved successfully: {temp_image_path}")
|
||||
return str(temp_image_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving graph image: {str(e)}")
|
||||
return None
|
||||
|
||||
def export_to_pdf(
|
||||
self,
|
||||
export_path: str,
|
||||
include_graph: bool = True,
|
||||
df: pd.DataFrame | None = None,
|
||||
) -> bool:
|
||||
"""Export data and optionally graph to PDF format."""
|
||||
try:
|
||||
df = df if df is not None else self.data_manager.load_data()
|
||||
|
||||
# Create PDF document in landscape format for better table/graph display
|
||||
doc = SimpleDocTemplate(
|
||||
export_path,
|
||||
pagesize=landscape(A4),
|
||||
rightMargin=72,
|
||||
leftMargin=72,
|
||||
topMargin=72,
|
||||
bottomMargin=18,
|
||||
)
|
||||
|
||||
# Get styles
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"CustomTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=18,
|
||||
spaceAfter=30,
|
||||
textColor=colors.darkblue,
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Title
|
||||
story.append(Paragraph("TheChart - Medication Tracker Export", title_style))
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Export metadata
|
||||
export_info = [
|
||||
f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f"Total Entries: {len(df) if not df.empty else 0}",
|
||||
]
|
||||
|
||||
if not df.empty:
|
||||
export_info.extend(
|
||||
[
|
||||
f"Date Range: {df['date'].min()} to {df['date'].max()}",
|
||||
(
|
||||
"Pathologies: "
|
||||
+ ", ".join(self.pathology_manager.get_pathology_keys())
|
||||
),
|
||||
(
|
||||
"Medicines: "
|
||||
+ ", ".join(self.medicine_manager.get_medicine_keys())
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
for info in export_info:
|
||||
story.append(Paragraph(info, styles["Normal"]))
|
||||
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Include graph if requested and available
|
||||
if include_graph:
|
||||
temp_dir = Path(export_path).parent / "temp_export"
|
||||
graph_path = None
|
||||
|
||||
try:
|
||||
graph_path = self._save_graph_as_image(temp_dir)
|
||||
if graph_path and os.path.exists(graph_path):
|
||||
# Add page break before graph for full page display
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(
|
||||
Paragraph("Data Visualization", styles["Heading2"])
|
||||
)
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Full page graph - maintain proportions while maximizing size
|
||||
# Let ReportLab scale proportionally to fit landscape page
|
||||
img = Image(graph_path, width=9 * inch, height=5.4 * inch)
|
||||
story.append(img)
|
||||
else:
|
||||
# Graph not available, add a note instead
|
||||
story.append(PageBreak())
|
||||
story.append(
|
||||
Paragraph(
|
||||
"Data Visualization (Graph not available)",
|
||||
styles["Heading2"],
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(
|
||||
Paragraph(
|
||||
(
|
||||
"Graph image could not be generated. "
|
||||
"Continuing with data export only."
|
||||
),
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
finally:
|
||||
# Clean up temporary image file
|
||||
if graph_path and os.path.exists(graph_path):
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(graph_path)
|
||||
with contextlib.suppress(Exception):
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Remove directory if empty
|
||||
if not any(temp_dir.iterdir()):
|
||||
temp_dir.rmdir()
|
||||
|
||||
# Add data table if there is data
|
||||
if df.empty:
|
||||
story.append(
|
||||
Paragraph("No data available to export.", styles["Normal"])
|
||||
)
|
||||
else:
|
||||
# Prepare table data
|
||||
columns = list(df.columns)
|
||||
data: list[list[Any]] = [columns]
|
||||
|
||||
# Format rows
|
||||
for _, row in df.iterrows():
|
||||
formatted_row = []
|
||||
for col in columns:
|
||||
value = row[col]
|
||||
if pd.isna(value):
|
||||
formatted_row.append("")
|
||||
elif isinstance(value, int | float):
|
||||
formatted_row.append(f"{value}")
|
||||
else:
|
||||
formatted_row.append(str(value))
|
||||
data.append(formatted_row)
|
||||
|
||||
# Create table with improved formatting for readability
|
||||
table = Table(data, repeatRows=1)
|
||||
|
||||
# Define table styles
|
||||
style = TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.black),
|
||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 11),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
|
||||
("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
||||
]
|
||||
)
|
||||
|
||||
# Add alternating row colors for better readability
|
||||
for i in range(1, len(data)):
|
||||
if i % 2 == 0:
|
||||
style.add("BACKGROUND", (0, i), (-1, i), colors.beige)
|
||||
|
||||
table.setStyle(style)
|
||||
|
||||
story.append(Paragraph("Data Table", styles["Heading2"]))
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(table)
|
||||
|
||||
# Build the PDF
|
||||
doc.build(story)
|
||||
|
||||
self.logger.info(f"Exported to PDF: {export_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting to PDF: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["ExportManager"]
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Aggregate re-exports for thechart managers.
|
||||
"""Aggregate re-exports for TheChart managers.
|
||||
|
||||
Keeps external imports simple (e.g., `from thechart.managers import DataManager`) while
|
||||
preserving the current src layout.
|
||||
External imports can use `from thechart.managers import ...`.
|
||||
Gradually we migrate canonical implementations here, with legacy shims left in
|
||||
`src/` for backward-compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# ruff: noqa: I001
|
||||
|
||||
from src.data_manager import DataManager # noqa: F401
|
||||
from src.medicine_manager import Medicine, MedicineManager # noqa: F401
|
||||
from src.pathology_manager import PathologyManager # noqa: F401
|
||||
from src.ui_manager import UIManager # noqa: F401
|
||||
# First-party re-exports
|
||||
from thechart.data import DataManager # noqa: F401
|
||||
from .managers import ( # noqa: F401
|
||||
Medicine,
|
||||
MedicineManager,
|
||||
Pathology,
|
||||
PathologyManager,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Canonical manager implementations for TheChart.
|
||||
|
||||
Exports:
|
||||
- Medicine, MedicineManager
|
||||
- Pathology, PathologyManager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .medicine_manager import Medicine, MedicineManager # noqa: F401
|
||||
from .pathology_manager import Pathology, PathologyManager # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"Medicine",
|
||||
"MedicineManager",
|
||||
"Pathology",
|
||||
"PathologyManager",
|
||||
]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Medicine configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of medicine configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Medicine:
|
||||
"""Data class representing a medicine."""
|
||||
|
||||
key: str # Internal key (e.g., "bupropion")
|
||||
display_name: str # Display name (e.g., "Bupropion")
|
||||
dosage_info: str # Dosage information (e.g., "150/300 mg")
|
||||
quick_doses: list[str] # Common dose amounts for quick selection
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = False # Whether to show in graph by default
|
||||
|
||||
|
||||
class MedicineManager:
|
||||
"""Manages medicine configurations and provides access to medicine data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "medicines.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.medicines: dict[str, Medicine] = {}
|
||||
self._load_medicines()
|
||||
|
||||
def _get_default_medicines(self) -> list[Medicine]:
|
||||
"""Get the default medicine configuration."""
|
||||
return [
|
||||
Medicine(
|
||||
key="bupropion",
|
||||
display_name="Bupropion",
|
||||
dosage_info="150/300 mg",
|
||||
quick_doses=["150", "300"],
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="hydroxyzine",
|
||||
display_name="Hydroxyzine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50"],
|
||||
color="#4ECDC4",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="gabapentin",
|
||||
display_name="Gabapentin",
|
||||
dosage_info="100 mg",
|
||||
quick_doses=["100", "300", "600"],
|
||||
color="#45B7D1",
|
||||
default_enabled=False,
|
||||
),
|
||||
Medicine(
|
||||
key="propranolol",
|
||||
display_name="Propranolol",
|
||||
dosage_info="10 mg",
|
||||
quick_doses=["10", "20", "40"],
|
||||
color="#96CEB4",
|
||||
default_enabled=True,
|
||||
),
|
||||
Medicine(
|
||||
key="quetiapine",
|
||||
display_name="Quetiapine",
|
||||
dosage_info="25 mg",
|
||||
quick_doses=["25", "50", "100"],
|
||||
color="#FFEAA7",
|
||||
default_enabled=False,
|
||||
),
|
||||
]
|
||||
|
||||
def _load_medicines(self) -> None:
|
||||
"""Load medicines from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.medicines = {}
|
||||
for medicine_data in data.get("medicines", []):
|
||||
medicine = Medicine(**medicine_data)
|
||||
self.medicines[medicine.key] = medicine
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.medicines)} medicines from {self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading medicines config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default medicine configuration."""
|
||||
default_medicines = self._get_default_medicines()
|
||||
self.medicines = {med.key: med for med in default_medicines}
|
||||
self.save_medicines()
|
||||
self.logger.info("Created default medicine configuration")
|
||||
|
||||
def save_medicines(self) -> bool:
|
||||
"""Save current medicines to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"medicines": [asdict(medicine) for medicine in self.medicines.values()]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.medicines)} medicines to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving medicines config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_medicines(self) -> dict[str, Medicine]:
|
||||
"""Get all medicines."""
|
||||
return self.medicines.copy()
|
||||
|
||||
def get_medicine(self, key: str) -> Medicine | None:
|
||||
"""Get a specific medicine by key."""
|
||||
return self.medicines.get(key)
|
||||
|
||||
def add_medicine(self, medicine: Medicine) -> bool:
|
||||
"""Add a new medicine."""
|
||||
if medicine.key in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{medicine.key}' already exists")
|
||||
return False
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def update_medicine(self, key: str, medicine: Medicine) -> bool:
|
||||
"""Update an existing medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != medicine.key:
|
||||
del self.medicines[key]
|
||||
|
||||
self.medicines[medicine.key] = medicine
|
||||
return self.save_medicines()
|
||||
|
||||
def remove_medicine(self, key: str) -> bool:
|
||||
"""Remove a medicine."""
|
||||
if key not in self.medicines:
|
||||
self.logger.warning(f"Medicine with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.medicines[key]
|
||||
return self.save_medicines()
|
||||
|
||||
def get_medicine_keys(self) -> list[str]:
|
||||
"""Get list of all medicine keys."""
|
||||
return list(self.medicines.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: med.display_name for key, med in self.medicines.items()}
|
||||
|
||||
def get_quick_doses(self, key: str) -> list[str]:
|
||||
"""Get quick dose options for a medicine."""
|
||||
medicine = self.medicines.get(key)
|
||||
return medicine.quick_doses if medicine else ["25", "50"]
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of medicine keys to graph colors."""
|
||||
return {key: med.color for key, med in self.medicines.items()}
|
||||
|
||||
def get_default_enabled_medicines(self) -> list[str]:
|
||||
"""Get list of medicines that should be enabled by default in graphs."""
|
||||
return [key for key, med in self.medicines.items() if med.default_enabled]
|
||||
|
||||
def get_medicine_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get medicine variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), f"{med.display_name} {med.dosage_info}")
|
||||
for key, med in self.medicines.items()
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Pathology configuration manager for the MedTracker application.
|
||||
Handles dynamic loading and saving of pathology/symptom configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pathology:
|
||||
"""Data class representing a pathology/symptom."""
|
||||
|
||||
key: str # Internal key (e.g., "depression")
|
||||
display_name: str # Display name (e.g., "Depression")
|
||||
scale_info: str # Scale information (e.g., "0:good, 10:bad")
|
||||
color: str # Color for graph display
|
||||
default_enabled: bool = True # Whether to show in graph by default
|
||||
scale_min: int = 0 # Minimum scale value
|
||||
scale_max: int = 10 # Maximum scale value
|
||||
scale_orientation: str = "normal" # "normal" (0=good) or "inverted" (0=bad)
|
||||
|
||||
|
||||
class PathologyManager:
|
||||
"""Manages pathology configurations and provides access to pathology data."""
|
||||
|
||||
def __init__(
|
||||
self, config_file: str = "pathologies.json", logger: logging.Logger = None
|
||||
):
|
||||
self.config_file = config_file
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.pathologies: dict[str, Pathology] = {}
|
||||
self._load_pathologies()
|
||||
|
||||
def _get_default_pathologies(self) -> list[Pathology]:
|
||||
"""Get the default pathology configuration."""
|
||||
return [
|
||||
Pathology(
|
||||
key="depression",
|
||||
display_name="Depression",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FF6B6B",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="anxiety",
|
||||
display_name="Anxiety",
|
||||
scale_info="0:good, 10:bad",
|
||||
color="#FFA726",
|
||||
default_enabled=True,
|
||||
scale_orientation="normal",
|
||||
),
|
||||
Pathology(
|
||||
key="sleep",
|
||||
display_name="Sleep Quality",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#66BB6A",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
Pathology(
|
||||
key="appetite",
|
||||
display_name="Appetite",
|
||||
scale_info="0:bad, 10:good",
|
||||
color="#42A5F5",
|
||||
default_enabled=True,
|
||||
scale_orientation="inverted",
|
||||
),
|
||||
]
|
||||
|
||||
def _load_pathologies(self) -> None:
|
||||
"""Load pathologies from configuration file."""
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.pathologies = {}
|
||||
for pathology_data in data.get("pathologies", []):
|
||||
pathology = Pathology(**pathology_data)
|
||||
self.pathologies[pathology.key] = pathology
|
||||
|
||||
self.logger.info(
|
||||
f"Loaded {len(self.pathologies)} pathologies from "
|
||||
f"{self.config_file}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading pathologies config: {e}")
|
||||
self._create_default_config()
|
||||
else:
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default pathology configuration."""
|
||||
default_pathologies = self._get_default_pathologies()
|
||||
self.pathologies = {path.key: path for path in default_pathologies}
|
||||
self.save_pathologies()
|
||||
self.logger.info("Created default pathology configuration")
|
||||
|
||||
def save_pathologies(self) -> bool:
|
||||
"""Save current pathologies to configuration file."""
|
||||
try:
|
||||
data = {
|
||||
"pathologies": [
|
||||
asdict(pathology) for pathology in self.pathologies.values()
|
||||
]
|
||||
}
|
||||
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
self.logger.info(
|
||||
f"Saved {len(self.pathologies)} pathologies to {self.config_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving pathologies config: {e}")
|
||||
return False
|
||||
|
||||
def get_all_pathologies(self) -> dict[str, Pathology]:
|
||||
"""Get all pathologies."""
|
||||
return self.pathologies.copy()
|
||||
|
||||
def get_pathology(self, key: str) -> Pathology | None:
|
||||
"""Get a specific pathology by key."""
|
||||
return self.pathologies.get(key)
|
||||
|
||||
def add_pathology(self, pathology: Pathology) -> bool:
|
||||
"""Add a new pathology."""
|
||||
if pathology.key in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{pathology.key}' already exists")
|
||||
return False
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def update_pathology(self, key: str, pathology: Pathology) -> bool:
|
||||
"""Update an existing pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
# If key is changing, remove old entry
|
||||
if key != pathology.key:
|
||||
del self.pathologies[key]
|
||||
|
||||
self.pathologies[pathology.key] = pathology
|
||||
return self.save_pathologies()
|
||||
|
||||
def remove_pathology(self, key: str) -> bool:
|
||||
"""Remove a pathology."""
|
||||
if key not in self.pathologies:
|
||||
self.logger.warning(f"Pathology with key '{key}' does not exist")
|
||||
return False
|
||||
|
||||
del self.pathologies[key]
|
||||
return self.save_pathologies()
|
||||
|
||||
def get_pathology_keys(self) -> list[str]:
|
||||
"""Get list of all pathology keys."""
|
||||
return list(self.pathologies.keys())
|
||||
|
||||
def get_display_names(self) -> dict[str, str]:
|
||||
"""Get mapping of keys to display names."""
|
||||
return {key: path.display_name for key, path in self.pathologies.items()}
|
||||
|
||||
def get_graph_colors(self) -> dict[str, str]:
|
||||
"""Get mapping of pathology keys to graph colors."""
|
||||
return {key: path.color for key, path in self.pathologies.items()}
|
||||
|
||||
def get_default_enabled_pathologies(self) -> list[str]:
|
||||
"""Get list of pathologies that should be enabled by default in graphs."""
|
||||
return [key for key, path in self.pathologies.items() if path.default_enabled]
|
||||
|
||||
def get_pathology_vars_dict(self) -> dict[str, tuple[Any, str]]:
|
||||
"""Get pathology variables dictionary for UI compatibility."""
|
||||
# This maintains compatibility with existing UI code
|
||||
import tkinter as tk
|
||||
|
||||
return {
|
||||
key: (tk.IntVar(value=0), path.display_name)
|
||||
for key, path in self.pathologies.items()
|
||||
}
|
||||
|
||||
def get_scale_info(self, key: str) -> tuple[int, int, str, str]:
|
||||
"""Get scale information for a pathology."""
|
||||
pathology = self.get_pathology(key)
|
||||
if pathology:
|
||||
return (
|
||||
pathology.scale_min,
|
||||
pathology.scale_max,
|
||||
pathology.scale_info,
|
||||
pathology.scale_orientation,
|
||||
)
|
||||
return (0, 10, "0-10", "normal")
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Search and filtering utilities for TheChart.
|
||||
|
||||
Public API:
|
||||
- DataFilter: core filtering logic over DataFrames
|
||||
- QuickFilters: convenience presets
|
||||
- SearchHistory: recent search terms manager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .search_filter import DataFilter, QuickFilters, SearchHistory # noqa: F401
|
||||
|
||||
__all__ = ["DataFilter", "QuickFilters", "SearchHistory"]
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Search and filter functionality for TheChart application (canonical).
|
||||
|
||||
This module implements the data filtering logic and related helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class DataFilter:
|
||||
"""Handles filtering and searching of medical data."""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""
|
||||
Initialize data filter.
|
||||
|
||||
Args:
|
||||
logger: Logger instance for debugging
|
||||
"""
|
||||
self.logger = logger
|
||||
self.active_filters: dict[str, Any] = {}
|
||||
self.search_term = ""
|
||||
|
||||
def set_date_range_filter(
|
||||
self, start_date: str | None = None, end_date: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Set date range filter.
|
||||
|
||||
Args:
|
||||
start_date: Start date string (inclusive)
|
||||
end_date: End date string (inclusive)
|
||||
"""
|
||||
if start_date or end_date:
|
||||
self.active_filters["date_range"] = {"start": start_date, "end": end_date}
|
||||
elif "date_range" in self.active_filters:
|
||||
del self.active_filters["date_range"]
|
||||
|
||||
def set_medicine_filter(self, medicine_key: str, taken: bool) -> None:
|
||||
"""
|
||||
Filter by medicine taken status.
|
||||
|
||||
Args:
|
||||
medicine_key: Medicine identifier
|
||||
taken: Whether medicine was taken (True) or not taken (False)
|
||||
"""
|
||||
if "medicines" not in self.active_filters:
|
||||
self.active_filters["medicines"] = {}
|
||||
|
||||
self.active_filters["medicines"][medicine_key] = taken
|
||||
|
||||
def set_pathology_range_filter(
|
||||
self,
|
||||
pathology_key: str,
|
||||
min_score: int | None = None,
|
||||
max_score: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Filter by pathology score range.
|
||||
|
||||
Args:
|
||||
pathology_key: Pathology identifier
|
||||
min_score: Minimum score (inclusive)
|
||||
max_score: Maximum score (inclusive)
|
||||
"""
|
||||
if min_score is not None or max_score is not None:
|
||||
if "pathologies" not in self.active_filters:
|
||||
self.active_filters["pathologies"] = {}
|
||||
|
||||
self.active_filters["pathologies"][pathology_key] = {
|
||||
"min": min_score,
|
||||
"max": max_score,
|
||||
}
|
||||
|
||||
def set_search_term(self, search_term: str) -> None:
|
||||
"""
|
||||
Set text search term for notes and other text fields.
|
||||
|
||||
Args:
|
||||
search_term: Text to search for
|
||||
"""
|
||||
self.search_term = search_term.strip()
|
||||
|
||||
def clear_all_filters(self) -> None:
|
||||
"""Clear all active filters and search terms."""
|
||||
self.active_filters.clear()
|
||||
self.search_term = ""
|
||||
|
||||
def clear_filter(self, filter_type: str, filter_key: str | None = None) -> None:
|
||||
"""
|
||||
Clear specific filter.
|
||||
|
||||
Args:
|
||||
filter_type: Type of filter ("date_range", "medicines", "pathologies")
|
||||
filter_key: Specific key within filter type (optional)
|
||||
"""
|
||||
if filter_type in self.active_filters:
|
||||
if filter_key and isinstance(self.active_filters[filter_type], dict):
|
||||
if filter_key in self.active_filters[filter_type]:
|
||||
del self.active_filters[filter_type][filter_key]
|
||||
# Remove parent filter if empty
|
||||
if not self.active_filters[filter_type]:
|
||||
del self.active_filters[filter_type]
|
||||
else:
|
||||
del self.active_filters[filter_type]
|
||||
|
||||
def apply_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Apply all active filters to the dataframe.
|
||||
|
||||
Args:
|
||||
df: Input dataframe
|
||||
|
||||
Returns:
|
||||
Filtered dataframe
|
||||
"""
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
filtered_df = df.copy()
|
||||
|
||||
try:
|
||||
# Apply date range filter
|
||||
filtered_df = self._apply_date_filter(filtered_df)
|
||||
|
||||
# Apply medicine filters
|
||||
filtered_df = self._apply_medicine_filters(filtered_df)
|
||||
|
||||
# Apply pathology filters
|
||||
filtered_df = self._apply_pathology_filters(filtered_df)
|
||||
|
||||
# Apply text search
|
||||
filtered_df = self._apply_text_search(filtered_df)
|
||||
|
||||
if self.logger:
|
||||
original_count = len(df)
|
||||
filtered_count = len(filtered_df)
|
||||
self.logger.debug(
|
||||
f"Applied filters: {original_count} -> {filtered_count} entries"
|
||||
)
|
||||
|
||||
return filtered_df
|
||||
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.error(f"Error applying filters: {e}")
|
||||
return df # Return original data if filtering fails
|
||||
|
||||
def _apply_date_filter(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply date range filter."""
|
||||
if "date_range" not in self.active_filters:
|
||||
return df
|
||||
|
||||
date_filter = self.active_filters["date_range"]
|
||||
start_date = date_filter.get("start")
|
||||
end_date = date_filter.get("end")
|
||||
|
||||
if not start_date and not end_date:
|
||||
return df
|
||||
|
||||
# Support both legacy lowercase 'date' and capitalized 'Date'
|
||||
date_col = (
|
||||
"date" if "date" in df.columns else "Date" if "Date" in df.columns else None
|
||||
)
|
||||
if not date_col:
|
||||
return df
|
||||
|
||||
try:
|
||||
# Convert date column to datetime – attempt multiple formats safely
|
||||
df_dates = pd.to_datetime(df[date_col], errors="coerce")
|
||||
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
if start_date:
|
||||
mask &= df_dates >= pd.to_datetime(start_date, errors="coerce")
|
||||
if end_date:
|
||||
mask &= df_dates <= pd.to_datetime(end_date, errors="coerce")
|
||||
|
||||
return df[mask]
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
if self.logger:
|
||||
self.logger.warning(f"Date filter failed: {e}")
|
||||
return df
|
||||
|
||||
def _apply_medicine_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply medicine filters."""
|
||||
if "medicines" not in self.active_filters:
|
||||
return df
|
||||
|
||||
medicine_filters = self.active_filters["medicines"]
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
for medicine_key, should_be_taken in medicine_filters.items():
|
||||
if medicine_key in df.columns:
|
||||
col = df[medicine_key]
|
||||
# Heuristic:
|
||||
# - If object dtype and values look like time:dose strings,
|
||||
# use string presence
|
||||
# - Else if numeric (or numeric-like), use non-zero for taken,
|
||||
# zero for not taken
|
||||
# - Else fallback to string presence
|
||||
if col.dtype == object:
|
||||
s = col.astype(str)
|
||||
looks_time_dose = s.str.contains(
|
||||
r":|\|", regex=True, na=False
|
||||
).any()
|
||||
if looks_time_dose:
|
||||
if should_be_taken:
|
||||
mask &= s.str.len() > 0
|
||||
else:
|
||||
mask &= s.str.len() == 0
|
||||
continue
|
||||
# Try numeric-like strings
|
||||
numeric = pd.to_numeric(col, errors="coerce")
|
||||
if numeric.notna().any():
|
||||
if should_be_taken:
|
||||
mask &= numeric.fillna(0) != 0
|
||||
else:
|
||||
mask &= numeric.fillna(0) == 0
|
||||
else:
|
||||
if should_be_taken:
|
||||
mask &= s.str.len() > 0
|
||||
else:
|
||||
mask &= s.str.len() == 0
|
||||
else:
|
||||
# Numeric dtype
|
||||
if should_be_taken:
|
||||
mask &= col.fillna(0) != 0
|
||||
else:
|
||||
mask &= col.fillna(0) == 0
|
||||
|
||||
return df[mask]
|
||||
|
||||
def _apply_pathology_filters(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply pathology score range filters."""
|
||||
if "pathologies" not in self.active_filters:
|
||||
return df
|
||||
|
||||
pathology_filters = self.active_filters["pathologies"]
|
||||
mask = pd.Series(True, index=df.index)
|
||||
|
||||
for pathology_key, score_range in pathology_filters.items():
|
||||
if pathology_key in df.columns:
|
||||
# Coerce to numeric; non-numeric -> NaN (excluded by comparisons)
|
||||
col = pd.to_numeric(df[pathology_key], errors="coerce")
|
||||
min_score = score_range.get("min")
|
||||
max_score = score_range.get("max")
|
||||
if min_score is not None:
|
||||
mask &= col >= min_score
|
||||
if max_score is not None:
|
||||
mask &= col <= max_score
|
||||
|
||||
return df[mask]
|
||||
|
||||
def _apply_text_search(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Apply text search to notes and other text fields."""
|
||||
if not self.search_term:
|
||||
return df
|
||||
|
||||
# Create regex pattern for case-insensitive search
|
||||
try:
|
||||
pattern = re.compile(re.escape(self.search_term), re.IGNORECASE)
|
||||
except re.error: # pragma: no cover - defensive
|
||||
pattern = self.search_term.lower()
|
||||
|
||||
mask = pd.Series(False, index=df.index)
|
||||
|
||||
# Support both Notes/note and Date/date columns
|
||||
note_cols = [c for c in ("Notes", "Note", "note", "notes") if c in df.columns]
|
||||
date_cols = [c for c in ("Date", "date") if c in df.columns]
|
||||
|
||||
for col in note_cols + date_cols:
|
||||
if isinstance(pattern, re.Pattern):
|
||||
mask |= df[col].astype(str).str.contains(pattern, na=False)
|
||||
else:
|
||||
mask |= df[col].astype(str).str.lower().str.contains(pattern, na=False)
|
||||
|
||||
return df[mask]
|
||||
|
||||
def get_filter_summary(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get summary of active filters.
|
||||
|
||||
Returns:
|
||||
Dictionary describing active filters
|
||||
"""
|
||||
summary = {
|
||||
"has_filters": bool(self.active_filters or self.search_term),
|
||||
"filter_count": len(self.active_filters),
|
||||
"search_term": self.search_term,
|
||||
"filters": {},
|
||||
}
|
||||
|
||||
# Date range summary
|
||||
if "date_range" in self.active_filters:
|
||||
date_range = self.active_filters["date_range"]
|
||||
summary["filters"]["date_range"] = {
|
||||
"start": date_range.get("start", "Any"),
|
||||
"end": date_range.get("end", "Any"),
|
||||
}
|
||||
|
||||
# Medicine filters summary
|
||||
if "medicines" in self.active_filters:
|
||||
medicine_filters = self.active_filters["medicines"]
|
||||
summary["filters"]["medicines"] = {
|
||||
"taken": [k for k, v in medicine_filters.items() if v],
|
||||
"not_taken": [k for k, v in medicine_filters.items() if not v],
|
||||
}
|
||||
|
||||
# Pathology filters summary
|
||||
if "pathologies" in self.active_filters:
|
||||
pathology_filters = self.active_filters["pathologies"]
|
||||
summary["filters"]["pathologies"] = {}
|
||||
for key, range_filter in pathology_filters.items():
|
||||
min_val = range_filter.get("min", "Any")
|
||||
max_val = range_filter.get("max", "Any")
|
||||
summary["filters"]["pathologies"][key] = f"{min_val} - {max_val}"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
class QuickFilters:
|
||||
"""Predefined quick filters mirroring test expectations."""
|
||||
|
||||
@staticmethod
|
||||
def last_week(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=6) # inclusive 7 days
|
||||
data_filter.set_date_range_filter(str(start_date), str(end_date))
|
||||
|
||||
@staticmethod
|
||||
def last_month(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=29) # inclusive 30 days
|
||||
data_filter.set_date_range_filter(str(start_date), str(end_date))
|
||||
|
||||
@staticmethod
|
||||
def this_month(data_filter: DataFilter) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now().date()
|
||||
start_date = now.replace(day=1)
|
||||
data_filter.set_date_range_filter(str(start_date), str(now))
|
||||
|
||||
@staticmethod
|
||||
def high_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None:
|
||||
for pathology_key in pathology_keys:
|
||||
data_filter.set_pathology_range_filter(pathology_key, min_score=8)
|
||||
|
||||
@staticmethod
|
||||
def low_symptoms(data_filter: DataFilter, pathology_keys: list[str]) -> None:
|
||||
for pathology_key in pathology_keys:
|
||||
data_filter.set_pathology_range_filter(pathology_key, max_score=3)
|
||||
|
||||
@staticmethod
|
||||
def no_medication(data_filter: DataFilter, medicine_keys: list[str]) -> None:
|
||||
for medicine_key in medicine_keys:
|
||||
data_filter.set_medicine_filter(medicine_key, taken=False)
|
||||
|
||||
|
||||
class SearchHistory:
|
||||
"""Manages search history (tests assume <=15 retained)."""
|
||||
|
||||
def __init__(self, max_history: int = 15):
|
||||
self.max_history = max_history
|
||||
self.history: list[str] = []
|
||||
|
||||
def add_search(self, search_term: str) -> None:
|
||||
"""
|
||||
Add a search term to history.
|
||||
|
||||
Args:
|
||||
search_term: Search term to add
|
||||
"""
|
||||
search_term = search_term.strip()
|
||||
if not search_term:
|
||||
return
|
||||
|
||||
# Remove if already exists
|
||||
if search_term in self.history:
|
||||
self.history.remove(search_term)
|
||||
|
||||
# Add to beginning
|
||||
self.history.insert(0, search_term)
|
||||
|
||||
# Trim to max size
|
||||
if len(self.history) > self.max_history:
|
||||
self.history = self.history[: self.max_history]
|
||||
|
||||
def get_history(self) -> list[str]:
|
||||
"""Get search history."""
|
||||
return self.history.copy()
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all search history."""
|
||||
self.history.clear()
|
||||
|
||||
# Small helper used by tests for UI suggestions
|
||||
def get_suggestions(self, partial_term: str) -> list[str]:
|
||||
"""Return up to 5 recent searches starting with the given prefix.
|
||||
|
||||
Case-insensitive prefix match against the stored history, preserving
|
||||
recency order.
|
||||
"""
|
||||
if not partial_term:
|
||||
return self.history[:5]
|
||||
|
||||
pfx = partial_term.lower()
|
||||
out: list[str] = []
|
||||
for term in self.history:
|
||||
if term.lower().startswith(pfx):
|
||||
out.append(term)
|
||||
if len(out) >= 5:
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,27 @@
|
||||
"""UI layer re-exports for TheChart.
|
||||
|
||||
Canonical UI utilities live here. Windows are provided canonically as well.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
# ruff: noqa: I001
|
||||
|
||||
from .search_filter_ui import SearchFilterWidget # noqa: F401
|
||||
from .theme_manager import ThemeManager # noqa: F401
|
||||
from .tooltip_system import ToolTip, TooltipManager # noqa: F401
|
||||
from .ui_manager import UIManager # noqa: F401
|
||||
|
||||
# Window proxies (import-all for backward compatibility with existing names)
|
||||
from .export_window import * # noqa: F401,F403
|
||||
from .medicine_management_window import * # noqa: F401,F403
|
||||
from .pathology_management_window import * # noqa: F401,F403
|
||||
from .settings_window import * # noqa: F401,F403
|
||||
|
||||
__all__ = [
|
||||
"SearchFilterWidget",
|
||||
# window proxies
|
||||
"ThemeManager",
|
||||
"UIManager",
|
||||
"ToolTip",
|
||||
"TooltipManager",
|
||||
]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Export Window (canonical UI implementation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from thechart.export import ExportManager
|
||||
|
||||
|
||||
class ExportWindow:
|
||||
"""Export window for data and graph export functionality."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Tk,
|
||||
export_manager: ExportManager,
|
||||
get_current_filtered_df: Callable[[], object] | None = None,
|
||||
) -> None:
|
||||
self.parent = parent
|
||||
self.export_manager = export_manager
|
||||
self._get_current_filtered_df = get_current_filtered_df
|
||||
|
||||
# Create the export window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Export Data")
|
||||
self.window.geometry("500x450") # Taller to ensure buttons visible
|
||||
self.window.resizable(False, False)
|
||||
|
||||
# Center the window
|
||||
self._center_window()
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
# Setup the UI
|
||||
self._setup_ui()
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the export window on the parent window."""
|
||||
self.window.update_idletasks()
|
||||
width = self.window.winfo_width()
|
||||
height = self.window.winfo_height()
|
||||
parent_x = self.parent.winfo_rootx()
|
||||
parent_y = self.parent.winfo_rooty()
|
||||
parent_width = self.parent.winfo_width()
|
||||
parent_height = self.parent.winfo_height()
|
||||
x = parent_x + (parent_width // 2) - (width // 2)
|
||||
y = parent_y + (parent_height // 2) - (height // 2)
|
||||
self.window.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Setup the export window UI."""
|
||||
main_frame = ttk.Frame(self.window, padding="15")
|
||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
||||
title_label = ttk.Label(
|
||||
main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold")
|
||||
)
|
||||
title_label.pack(pady=(0, 15))
|
||||
content_frame = ttk.Frame(main_frame)
|
||||
content_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self._create_info_section(content_frame)
|
||||
self._create_options_section(content_frame)
|
||||
self._create_buttons_section(main_frame)
|
||||
|
||||
def _create_info_section(self, parent: ttk.Frame) -> None:
|
||||
info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10")
|
||||
info_frame.pack(fill=tk.X, pady=(0, 20))
|
||||
export_info = self.export_manager.get_export_info()
|
||||
if export_info["has_data"]:
|
||||
info_text = (
|
||||
f"Total Entries: {export_info['total_entries']}\n"
|
||||
f"Date Range: {export_info['date_range']['start']} to "
|
||||
f"{export_info['date_range']['end']}\n"
|
||||
f"Pathologies: {', '.join(export_info['pathologies'])}\n"
|
||||
f"Medicines: {', '.join(export_info['medicines'])}"
|
||||
)
|
||||
else:
|
||||
info_text = "No data available for export."
|
||||
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT)
|
||||
info_label.pack(anchor=tk.W)
|
||||
|
||||
def _create_options_section(self, parent: ttk.Frame) -> None:
|
||||
options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10")
|
||||
options_frame.pack(fill=tk.X, pady=(0, 20))
|
||||
self.include_graph_var = tk.BooleanVar(value=True)
|
||||
graph_check = ttk.Checkbutton(
|
||||
options_frame,
|
||||
text="Include graph in PDF export",
|
||||
variable=self.include_graph_var,
|
||||
)
|
||||
graph_check.pack(anchor=tk.W, pady=(0, 10))
|
||||
self.scope_var = tk.StringVar(value="all")
|
||||
scope_frame = ttk.Frame(options_frame)
|
||||
scope_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT)
|
||||
ttk.Radiobutton(
|
||||
scope_frame, text="All data", variable=self.scope_var, value="all"
|
||||
).pack(side=tk.LEFT, padx=10)
|
||||
ttk.Radiobutton(
|
||||
scope_frame,
|
||||
text="Current (filtered) view",
|
||||
variable=self.scope_var,
|
||||
value="filtered",
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Label(options_frame, text="Export Format:").pack(anchor=tk.W)
|
||||
self.format_var = tk.StringVar(value="JSON")
|
||||
for fmt in ("JSON", "XML", "PDF"):
|
||||
ttk.Radiobutton(
|
||||
options_frame, text=fmt, variable=self.format_var, value=fmt
|
||||
).pack(anchor=tk.W, padx=(20, 0))
|
||||
|
||||
def _create_buttons_section(self, parent: ttk.Frame) -> None:
|
||||
ttk.Separator(parent, orient="horizontal").pack(fill=tk.X, pady=(10, 10))
|
||||
button_frame = ttk.Frame(parent)
|
||||
button_frame.pack(fill=tk.X, pady=(0, 10))
|
||||
ttk.Button(button_frame, text="Export...", command=self._handle_export).pack(
|
||||
side=tk.LEFT, padx=(10, 10), pady=5
|
||||
)
|
||||
ttk.Button(button_frame, text="Cancel", command=self.window.destroy).pack(
|
||||
side=tk.RIGHT, padx=(10, 10), pady=5
|
||||
)
|
||||
|
||||
def _handle_export(self) -> None:
|
||||
export_info = self.export_manager.get_export_info()
|
||||
if not export_info["has_data"]:
|
||||
messagebox.showwarning(
|
||||
"No Data", "There is no data available to export.", parent=self.window
|
||||
)
|
||||
return
|
||||
selected_format = self.format_var.get()
|
||||
file_types = {
|
||||
"JSON": [("JSON files", "*.json"), ("All files", "*.*")],
|
||||
"XML": [("XML files", "*.xml"), ("All files", "*.*")],
|
||||
"PDF": [("PDF files", "*.pdf"), ("All files", "*.*")],
|
||||
}
|
||||
default_name = f"thechart_export.{selected_format.lower()}"
|
||||
filename = filedialog.asksaveasfilename(
|
||||
parent=self.window,
|
||||
title=f"Export as {selected_format}",
|
||||
defaultextension=f".{selected_format.lower()}",
|
||||
filetypes=file_types[selected_format],
|
||||
initialfile=default_name,
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
scoped_df = None
|
||||
if self.scope_var.get() == "filtered" and self._get_current_filtered_df:
|
||||
with contextlib.suppress(Exception):
|
||||
scoped_df = self._get_current_filtered_df()
|
||||
success = False
|
||||
try:
|
||||
if selected_format == "JSON":
|
||||
success = self.export_manager.export_data_to_json(
|
||||
filename, df=scoped_df
|
||||
)
|
||||
elif selected_format == "XML":
|
||||
success = self.export_manager.export_data_to_xml(filename, df=scoped_df)
|
||||
elif selected_format == "PDF":
|
||||
include_graph = self.include_graph_var.get()
|
||||
success = self.export_manager.export_to_pdf(
|
||||
filename, include_graph=include_graph, df=scoped_df
|
||||
)
|
||||
if success:
|
||||
messagebox.showinfo(
|
||||
"Export Successful",
|
||||
f"Data exported successfully to:\n{filename}",
|
||||
parent=self.window,
|
||||
)
|
||||
if messagebox.askyesno(
|
||||
"Open Location",
|
||||
"Would you like to open the file location?",
|
||||
parent=self.window,
|
||||
):
|
||||
self._open_file_location(filename)
|
||||
self.window.destroy()
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"Export Failed",
|
||||
(
|
||||
f"Failed to export data as {selected_format}. "
|
||||
"Please check the logs for more details."
|
||||
),
|
||||
parent=self.window,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive UX
|
||||
messagebox.showerror(
|
||||
"Export Error",
|
||||
f"An error occurred during export:\n{str(e)}",
|
||||
parent=self.window,
|
||||
)
|
||||
|
||||
def _open_file_location(self, filepath: str) -> None:
|
||||
try:
|
||||
file_path = Path(filepath)
|
||||
directory = file_path.parent
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["explorer", str(directory)], check=False)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", str(directory)], check=False)
|
||||
else:
|
||||
subprocess.run(["xdg-open", str(directory)], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["ExportWindow"]
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Medicine management window (canonical)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from thechart.managers import Medicine, MedicineManager
|
||||
|
||||
|
||||
class MedicineManagementWindow:
|
||||
"""Window for managing medicine configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, medicine_manager: MedicineManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Medicines")
|
||||
self.window.geometry("600x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_medicine_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (600 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"600x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the user interface."""
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
main_frame.grid_rowconfigure(1, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame, text="Medicine Management", font=("Arial", 14, "bold")
|
||||
)
|
||||
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10))
|
||||
|
||||
# Medicine list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Current Medicines")
|
||||
list_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(0, 10))
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for medicines
|
||||
columns = ("key", "name", "dosage", "quick_doses", "color", "default")
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Column headings
|
||||
self.tree.heading("key", text="Key")
|
||||
self.tree.heading("name", text="Name")
|
||||
self.tree.heading("dosage", text="Dosage Info")
|
||||
self.tree.heading("quick_doses", text="Quick Doses")
|
||||
self.tree.heading("color", text="Color")
|
||||
self.tree.heading("default", text="Default Enabled")
|
||||
|
||||
# Column widths
|
||||
self.tree.column("key", width=80)
|
||||
self.tree.column("name", width=100)
|
||||
self.tree.column("dosage", width=100)
|
||||
self.tree.column("quick_doses", width=120)
|
||||
self.tree.column("color", width=70)
|
||||
self.tree.column("default", width=100)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Add Medicine", command=self._add_medicine).grid(
|
||||
row=0, column=0, padx=(0, 5)
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Medicine", command=self._edit_medicine
|
||||
).grid(row=0, column=1, padx=5)
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Medicine", command=self._remove_medicine
|
||||
).grid(row=0, column=2, padx=5)
|
||||
|
||||
ttk.Button(button_frame, text="Close", command=self._close_window).grid(
|
||||
row=0, column=3, padx=(5, 0)
|
||||
)
|
||||
|
||||
def _populate_medicine_list(self):
|
||||
"""Populate the medicine list."""
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
for medicine in self.medicine_manager.get_all_medicines().values():
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
medicine.key,
|
||||
medicine.display_name,
|
||||
medicine.dosage_info,
|
||||
", ".join(medicine.quick_doses),
|
||||
medicine.color,
|
||||
"Yes" if medicine.default_enabled else "No",
|
||||
),
|
||||
)
|
||||
|
||||
def _add_medicine(self):
|
||||
"""Add a new medicine."""
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, None, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _edit_medicine(self):
|
||||
"""Edit selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a medicine to edit.")
|
||||
return
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
if medicine:
|
||||
MedicineEditDialog(
|
||||
self.window, self.medicine_manager, medicine, self._on_medicine_changed
|
||||
)
|
||||
|
||||
def _remove_medicine(self):
|
||||
"""Remove selected medicine."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a medicine to remove."
|
||||
)
|
||||
return
|
||||
item = self.tree.item(selection[0])
|
||||
medicine_key = item["values"][0]
|
||||
medicine_name = item["values"][1]
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
(
|
||||
f"Are you sure you want to remove '{medicine_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!"
|
||||
),
|
||||
):
|
||||
if self.medicine_manager.remove_medicine(medicine_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{medicine_name}' removed successfully!"
|
||||
)
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{medicine_name}'.")
|
||||
|
||||
def _on_medicine_changed(self):
|
||||
"""Called when a medicine is added or edited."""
|
||||
self._populate_medicine_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application after medicine changes."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
def _close_window(self):
|
||||
"""Close the window."""
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
class MedicineEditDialog:
|
||||
"""Dialog for adding/editing a medicine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
medicine_manager: MedicineManager,
|
||||
medicine: Medicine | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.medicine_manager = medicine_manager
|
||||
self.medicine = medicine
|
||||
self.callback = callback
|
||||
self.is_edit = medicine is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Medicine" if self.is_edit else "Add Medicine")
|
||||
self.dialog.geometry("400x350")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (400 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (350 // 2)
|
||||
self.dialog.geometry(f"400x350+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Fields
|
||||
fields_frame = ttk.Frame(main_frame)
|
||||
fields_frame.grid(row=0, column=0, sticky="ew", pady=(0, 15))
|
||||
fields_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
row = 0
|
||||
|
||||
# Key
|
||||
ttk.Label(fields_frame, text="Key:").grid(row=row, column=0, sticky="w", pady=5)
|
||||
self.key_var = tk.StringVar()
|
||||
key_entry = ttk.Entry(fields_frame, textvariable=self.key_var)
|
||||
key_entry.grid(row=row, column=1, sticky="ew", padx=(10, 0), pady=5)
|
||||
if self.is_edit:
|
||||
key_entry.configure(state="readonly")
|
||||
row += 1
|
||||
|
||||
# Display Name
|
||||
ttk.Label(fields_frame, text="Display Name:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.name_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Dosage Info
|
||||
ttk.Label(fields_frame, text="Dosage Info:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.dosage_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.dosage_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
row += 1
|
||||
|
||||
# Quick Doses
|
||||
ttk.Label(fields_frame, text="Quick Doses:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.doses_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.doses_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(comma-separated, e.g. 25,50,100)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Color
|
||||
ttk.Label(fields_frame, text="Graph Color:").grid(
|
||||
row=row, column=0, sticky="w", pady=5
|
||||
)
|
||||
self.color_var = tk.StringVar()
|
||||
ttk.Entry(fields_frame, textvariable=self.color_var).grid(
|
||||
row=row, column=1, sticky="ew", padx=(10, 0), pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
fields_frame, text="(hex color, e.g. #FF6B6B)", font=("Arial", 8)
|
||||
).grid(row=row + 1, column=1, sticky="w", padx=(10, 0))
|
||||
row += 2
|
||||
|
||||
# Default Enabled
|
||||
self.default_var = tk.BooleanVar()
|
||||
ttk.Checkbutton(
|
||||
fields_frame,
|
||||
text="Show in graph by default",
|
||||
variable=self.default_var,
|
||||
).grid(row=row, column=0, columnspan=2, sticky="w", pady=5)
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0)
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_medicine).grid(
|
||||
row=0, column=0, padx=(0, 10)
|
||||
)
|
||||
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).grid(
|
||||
row=0, column=1
|
||||
)
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.medicine:
|
||||
self.key_var.set(self.medicine.key)
|
||||
self.name_var.set(self.medicine.display_name)
|
||||
self.dosage_var.set(self.medicine.dosage_info)
|
||||
self.doses_var.set(",".join(self.medicine.quick_doses))
|
||||
self.color_var.set(self.medicine.color)
|
||||
self.default_var.set(self.medicine.default_enabled)
|
||||
|
||||
def _save_medicine(self):
|
||||
"""Save the medicine."""
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
dosage = self.dosage_var.get().strip()
|
||||
doses_str = self.doses_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
|
||||
if not all([key, name, dosage, doses_str, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores/hyphens only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Parse quick doses
|
||||
try:
|
||||
quick_doses = [dose.strip() for dose in doses_str.split(",")]
|
||||
quick_doses = [dose for dose in quick_doses if dose]
|
||||
if not quick_doses:
|
||||
raise ValueError("At least one quick dose is required.")
|
||||
except Exception:
|
||||
messagebox.showerror("Error", "Quick doses must be comma-separated values.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
try:
|
||||
int(color[1:], 16)
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create medicine object
|
||||
new_medicine = Medicine(
|
||||
key=key,
|
||||
display_name=name,
|
||||
dosage_info=dosage,
|
||||
quick_doses=quick_doses,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
)
|
||||
|
||||
# Save medicine
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.medicine_manager.update_medicine(
|
||||
self.medicine.key, new_medicine
|
||||
)
|
||||
else:
|
||||
success = self.medicine_manager.add_medicine(new_medicine)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Medicine {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} medicine.")
|
||||
|
||||
|
||||
__all__ = ["MedicineManagementWindow", "MedicineEditDialog"]
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Pathology management window (canonical)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from thechart.managers import Pathology, PathologyManager
|
||||
|
||||
|
||||
class PathologyManagementWindow:
|
||||
"""Window for managing pathology configurations."""
|
||||
|
||||
def __init__(
|
||||
self, parent: tk.Tk, pathology_manager: PathologyManager, refresh_callback
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.refresh_callback = refresh_callback
|
||||
|
||||
# Create the window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Manage Pathologies")
|
||||
self.window.geometry("800x500")
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
self._setup_ui()
|
||||
self._populate_pathology_list()
|
||||
|
||||
# Center window
|
||||
self.window.update_idletasks()
|
||||
x = (self.window.winfo_screenwidth() // 2) - (800 // 2)
|
||||
y = (self.window.winfo_screenheight() // 2) - (500 // 2)
|
||||
self.window.geometry(f"800x500+{x}+{y}")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI components."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.window, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.window.grid_rowconfigure(0, weight=1)
|
||||
self.window.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Pathology list
|
||||
list_frame = ttk.LabelFrame(main_frame, text="Pathologies", padding="5")
|
||||
list_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
|
||||
main_frame.grid_rowconfigure(0, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Treeview for pathology list
|
||||
columns = (
|
||||
"Key",
|
||||
"Display Name",
|
||||
"Scale Info",
|
||||
"Color",
|
||||
"Default Enabled",
|
||||
"Scale Range",
|
||||
)
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings")
|
||||
|
||||
# Configure columns
|
||||
self.tree.heading("Key", text="Key")
|
||||
self.tree.heading("Display Name", text="Display Name")
|
||||
self.tree.heading("Scale Info", text="Scale Info")
|
||||
self.tree.heading("Color", text="Color")
|
||||
self.tree.heading("Default Enabled", text="Default Enabled")
|
||||
self.tree.heading("Scale Range", text="Scale Range")
|
||||
|
||||
self.tree.column("Key", width=120)
|
||||
self.tree.column("Display Name", width=150)
|
||||
self.tree.column("Scale Info", width=150)
|
||||
self.tree.column("Color", width=80)
|
||||
self.tree.column("Default Enabled", width=100)
|
||||
self.tree.column("Scale Range", width=100)
|
||||
|
||||
# Scrollbar for treeview
|
||||
scrollbar = ttk.Scrollbar(
|
||||
list_frame, orient="vertical", command=self.tree.yview
|
||||
)
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Buttons frame
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=1, column=0, sticky="ew")
|
||||
|
||||
ttk.Button(
|
||||
button_frame, text="Add Pathology", command=self._add_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Edit Pathology", command=self._edit_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(
|
||||
button_frame, text="Remove Pathology", command=self._remove_pathology
|
||||
).pack(side="left", padx=(0, 5))
|
||||
ttk.Button(button_frame, text="Close", command=self.window.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
def _populate_pathology_list(self):
|
||||
"""Populate the pathology list."""
|
||||
# Clear existing items
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Add pathologies
|
||||
for pathology in self.pathology_manager.get_all_pathologies().values():
|
||||
scale_range = f"{pathology.scale_min}-{pathology.scale_max}"
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
pathology.key,
|
||||
pathology.display_name,
|
||||
pathology.scale_info,
|
||||
pathology.color,
|
||||
"Yes" if pathology.default_enabled else "No",
|
||||
scale_range,
|
||||
),
|
||||
)
|
||||
|
||||
def _add_pathology(self):
|
||||
"""Add a new pathology."""
|
||||
PathologyEditDialog(
|
||||
self.window, self.pathology_manager, None, self._on_pathology_changed
|
||||
)
|
||||
|
||||
def _edit_pathology(self):
|
||||
"""Edit selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning("No Selection", "Please select a pathology to edit.")
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
|
||||
if pathology:
|
||||
PathologyEditDialog(
|
||||
self.window,
|
||||
self.pathology_manager,
|
||||
pathology,
|
||||
self._on_pathology_changed,
|
||||
)
|
||||
|
||||
def _remove_pathology(self):
|
||||
"""Remove selected pathology."""
|
||||
selection = self.tree.selection()
|
||||
if not selection:
|
||||
messagebox.showwarning(
|
||||
"No Selection", "Please select a pathology to remove."
|
||||
)
|
||||
return
|
||||
|
||||
item = self.tree.item(selection[0])
|
||||
pathology_key = item["values"][0]
|
||||
pathology_name = item["values"][1]
|
||||
|
||||
if messagebox.askyesno(
|
||||
"Confirm Removal",
|
||||
f"Are you sure you want to remove '{pathology_name}'?\n\n"
|
||||
"This will also remove all associated data from your records!",
|
||||
):
|
||||
if self.pathology_manager.remove_pathology(pathology_key):
|
||||
messagebox.showinfo(
|
||||
"Success", f"'{pathology_name}' removed successfully!"
|
||||
)
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Failed to remove '{pathology_name}'.")
|
||||
|
||||
def _on_pathology_changed(self):
|
||||
"""Handle pathology changes."""
|
||||
self._populate_pathology_list()
|
||||
self._refresh_main_app()
|
||||
|
||||
def _refresh_main_app(self):
|
||||
"""Refresh the main application."""
|
||||
if self.refresh_callback:
|
||||
self.refresh_callback()
|
||||
|
||||
|
||||
class PathologyEditDialog:
|
||||
"""Dialog for adding/editing a pathology."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Toplevel,
|
||||
pathology_manager: PathologyManager,
|
||||
pathology: Pathology | None,
|
||||
callback,
|
||||
):
|
||||
self.parent = parent
|
||||
self.pathology_manager = pathology_manager
|
||||
self.pathology = pathology
|
||||
self.callback = callback
|
||||
self.is_edit = pathology is not None
|
||||
|
||||
# Create dialog
|
||||
self.dialog = tk.Toplevel(parent)
|
||||
self.dialog.title("Edit Pathology" if self.is_edit else "Add Pathology")
|
||||
self.dialog.geometry("450x400")
|
||||
self.dialog.resizable(False, False)
|
||||
|
||||
# Make modal
|
||||
self.dialog.transient(parent)
|
||||
self.dialog.grab_set()
|
||||
|
||||
self._setup_dialog()
|
||||
self._populate_fields()
|
||||
|
||||
# Center dialog
|
||||
self.dialog.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() // 2) - (450 // 2)
|
||||
y = parent.winfo_y() + (parent.winfo_height() // 2) - (400 // 2)
|
||||
self.dialog.geometry(f"450x400+{x}+{y}")
|
||||
|
||||
def _setup_dialog(self):
|
||||
"""Set up the dialog UI."""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.dialog, padding="15")
|
||||
main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.dialog.grid_rowconfigure(0, weight=1)
|
||||
self.dialog.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Form fields
|
||||
self.key_var = tk.StringVar()
|
||||
self.name_var = tk.StringVar()
|
||||
self.scale_info_var = tk.StringVar()
|
||||
self.color_var = tk.StringVar()
|
||||
self.default_var = tk.BooleanVar()
|
||||
self.scale_min_var = tk.IntVar(value=0)
|
||||
self.scale_max_var = tk.IntVar(value=10)
|
||||
self.orientation_var = tk.StringVar(value="normal")
|
||||
|
||||
# Key field
|
||||
ttk.Label(main_frame, text="Key:").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
key_entry = ttk.Entry(main_frame, textvariable=self.key_var, width=40)
|
||||
key_entry.grid(row=0, column=1, sticky="ew", pady=(0, 5))
|
||||
ttk.Label(main_frame, text="(alphanumeric, underscores, hyphens only)").grid(
|
||||
row=0, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Display name field
|
||||
ttk.Label(main_frame, text="Display Name:").grid(
|
||||
row=1, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.name_var, width=40).grid(
|
||||
row=1, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale info field
|
||||
ttk.Label(main_frame, text="Scale Info:").grid(
|
||||
row=2, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.scale_info_var, width=40).grid(
|
||||
row=2, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text='(e.g., "0:good, 10:bad")').grid(
|
||||
row=2, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Scale range
|
||||
scale_frame = ttk.Frame(main_frame)
|
||||
scale_frame.grid(row=3, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Label(main_frame, text="Scale Range:").grid(
|
||||
row=3, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Min:").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_min_var, width=5).grid(
|
||||
row=0, column=1, padx=(5, 10)
|
||||
)
|
||||
ttk.Label(scale_frame, text="Max:").grid(row=0, column=2, sticky="w")
|
||||
ttk.Entry(scale_frame, textvariable=self.scale_max_var, width=5).grid(
|
||||
row=0, column=3, padx=5
|
||||
)
|
||||
|
||||
# Scale orientation
|
||||
ttk.Label(main_frame, text="Scale Orientation:").grid(
|
||||
row=4, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
orientation_frame = ttk.Frame(main_frame)
|
||||
orientation_frame.grid(row=4, column=1, sticky="ew", pady=(0, 5))
|
||||
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Normal (0=good)",
|
||||
variable=self.orientation_var,
|
||||
value="normal",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
ttk.Radiobutton(
|
||||
orientation_frame,
|
||||
text="Inverted (0=bad)",
|
||||
variable=self.orientation_var,
|
||||
value="inverted",
|
||||
).grid(row=0, column=1, sticky="w", padx=(20, 0))
|
||||
|
||||
# Color field
|
||||
ttk.Label(main_frame, text="Color:").grid(
|
||||
row=5, column=0, sticky="w", pady=(0, 5)
|
||||
)
|
||||
ttk.Entry(main_frame, textvariable=self.color_var, width=40).grid(
|
||||
row=5, column=1, sticky="ew", pady=(0, 5)
|
||||
)
|
||||
ttk.Label(main_frame, text="(hex format, e.g., #FF6B6B)").grid(
|
||||
row=5, column=2, sticky="w", padx=(5, 0), pady=(0, 5)
|
||||
)
|
||||
|
||||
# Default enabled checkbox
|
||||
ttk.Checkbutton(
|
||||
main_frame, text="Show in graph by default", variable=self.default_var
|
||||
).grid(row=6, column=1, sticky="w", pady=(10, 15))
|
||||
|
||||
# Buttons
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(10, 0))
|
||||
|
||||
ttk.Button(button_frame, text="Save", command=self._save_pathology).pack(
|
||||
side="right", padx=(5, 0)
|
||||
)
|
||||
ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
# Configure column weights
|
||||
main_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Focus on first field
|
||||
key_entry.focus()
|
||||
|
||||
def _populate_fields(self):
|
||||
"""Populate fields if editing."""
|
||||
if self.pathology:
|
||||
self.key_var.set(self.pathology.key)
|
||||
self.name_var.set(self.pathology.display_name)
|
||||
self.scale_info_var.set(self.pathology.scale_info)
|
||||
self.color_var.set(self.pathology.color)
|
||||
self.default_var.set(self.pathology.default_enabled)
|
||||
self.scale_min_var.set(self.pathology.scale_min)
|
||||
self.scale_max_var.set(self.pathology.scale_max)
|
||||
self.orientation_var.set(self.pathology.scale_orientation)
|
||||
|
||||
def _save_pathology(self):
|
||||
"""Save the pathology."""
|
||||
# Validate fields
|
||||
key = self.key_var.get().strip()
|
||||
name = self.name_var.get().strip()
|
||||
scale_info = self.scale_info_var.get().strip()
|
||||
color = self.color_var.get().strip()
|
||||
scale_min = self.scale_min_var.get()
|
||||
scale_max = self.scale_max_var.get()
|
||||
|
||||
if not all([key, name, scale_info, color]):
|
||||
messagebox.showerror("Error", "All fields are required.")
|
||||
return
|
||||
|
||||
# Validate key format (alphanumeric and underscores only)
|
||||
if not key.replace("_", "").replace("-", "").isalnum():
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Key must contain only letters, numbers, underscores, and hyphens.",
|
||||
)
|
||||
return
|
||||
|
||||
# Validate scale range
|
||||
if scale_min >= scale_max:
|
||||
messagebox.showerror("Error", "Scale minimum must be less than maximum.")
|
||||
return
|
||||
|
||||
# Validate color format
|
||||
if not color.startswith("#") or len(color) != 7:
|
||||
messagebox.showerror(
|
||||
"Error", "Color must be in hex format (e.g., #FF6B6B)."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
int(color[1:], 16) # Validate hex color
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid hex color format.")
|
||||
return
|
||||
|
||||
# Create pathology object
|
||||
new_pathology = Pathology(
|
||||
key=key,
|
||||
display_name=name,
|
||||
scale_info=scale_info,
|
||||
color=color,
|
||||
default_enabled=self.default_var.get(),
|
||||
scale_min=scale_min,
|
||||
scale_max=scale_max,
|
||||
scale_orientation=self.orientation_var.get(),
|
||||
)
|
||||
|
||||
# Save pathology
|
||||
success = False
|
||||
if self.is_edit:
|
||||
success = self.pathology_manager.update_pathology(
|
||||
self.pathology.key, new_pathology
|
||||
)
|
||||
else:
|
||||
success = self.pathology_manager.add_pathology(new_pathology)
|
||||
|
||||
if success:
|
||||
action = "updated" if self.is_edit else "added"
|
||||
messagebox.showinfo("Success", f"Pathology {action} successfully!")
|
||||
self.callback()
|
||||
self.dialog.destroy()
|
||||
else:
|
||||
action = "update" if self.is_edit else "add"
|
||||
messagebox.showerror("Error", f"Failed to {action} pathology.")
|
||||
|
||||
|
||||
__all__ = ["PathologyManagementWindow", "PathologyEditDialog"]
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Search and filter UI components for TheChart (canonical).
|
||||
|
||||
This mirrors the existing src/search_filter_ui.SearchFilterWidget implementation,
|
||||
kept here to enable `from thechart.ui import SearchFilterWidget`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import ttk
|
||||
|
||||
from ..search import DataFilter, QuickFilters, SearchHistory
|
||||
|
||||
|
||||
class SearchFilterWidget:
|
||||
"""Widget providing search and filter UI controls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Widget,
|
||||
data_filter: DataFilter,
|
||||
update_callback: Callable,
|
||||
medicine_manager,
|
||||
pathology_manager,
|
||||
logger=None,
|
||||
):
|
||||
"""Initialize search and filter widget."""
|
||||
self.parent = parent
|
||||
self.data_filter = data_filter
|
||||
self.update_callback = update_callback
|
||||
self.medicine_manager = medicine_manager
|
||||
self.pathology_manager = pathology_manager
|
||||
self.logger = logger
|
||||
|
||||
# Visibility and UI init state
|
||||
self.is_visible = False
|
||||
self._ui_initialized = False
|
||||
self.frame = None
|
||||
# May be created in _setup_ui; keep defined for headless/test usage
|
||||
self.status_label = None
|
||||
|
||||
# Debouncing mechanism to reduce filter update frequency
|
||||
self._update_timer = None
|
||||
# 0 for immediate updates in tests/headless
|
||||
self._debounce_delay = 0
|
||||
# Internal flag to temporarily suppress trace-driven updates
|
||||
self._suspend_traces = False
|
||||
|
||||
# History and UI state variables
|
||||
self.search_history = SearchHistory()
|
||||
self.search_var = tk.StringVar()
|
||||
self.start_date_var = tk.StringVar()
|
||||
self.end_date_var = tk.StringVar()
|
||||
|
||||
# Presets state
|
||||
self.preset_var = tk.StringVar()
|
||||
|
||||
# Medicine and pathology filter variables
|
||||
self.medicine_vars: dict[str, tk.StringVar] = {}
|
||||
self.pathology_min_vars: dict[str, tk.StringVar] = {}
|
||||
self.pathology_max_vars: dict[str, tk.StringVar] = {}
|
||||
|
||||
# Build UI immediately so tests can access widgets/vars without calling show()
|
||||
self._setup_ui()
|
||||
self._bind_events()
|
||||
self._ui_initialized = True
|
||||
|
||||
# --- UI construction helpers (trimmed to essentials; behavior parity with src) ---
|
||||
def _setup_ui(self) -> None:
|
||||
self.frame = ttk.LabelFrame(self.parent, text="Search & Filter", padding="5")
|
||||
|
||||
content_frame = ttk.Frame(self.frame)
|
||||
content_frame.pack(fill="both", expand=True)
|
||||
|
||||
top_row = ttk.Frame(content_frame)
|
||||
top_row.pack(fill="x", pady=(0, 5))
|
||||
|
||||
# Presets section
|
||||
presets_frame = ttk.Frame(top_row)
|
||||
presets_frame.pack(side="left", padx=(0, 10))
|
||||
ttk.Label(presets_frame, text="Preset:").pack(side="left")
|
||||
self.preset_combo = ttk.Combobox(
|
||||
presets_frame, textvariable=self.preset_var, state="readonly", width=18
|
||||
)
|
||||
self._refresh_presets_combo()
|
||||
self.preset_combo.pack(side="left", padx=(5, 5))
|
||||
ttk.Button(presets_frame, text="Load", command=self._load_preset).pack(
|
||||
side="left", padx=(0, 2)
|
||||
)
|
||||
ttk.Button(presets_frame, text="Save", command=self._save_preset).pack(
|
||||
side="left", padx=(0, 2)
|
||||
)
|
||||
ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
# Search section
|
||||
search_frame = ttk.Frame(top_row)
|
||||
search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
ttk.Label(search_frame, text="Search:").pack(side="left")
|
||||
search_entry = ttk.Entry(search_frame, textvariable=self.search_var)
|
||||
search_entry.pack(side="left", padx=(5, 5), fill="x", expand=True)
|
||||
ttk.Button(search_frame, text="Clear", command=self._clear_search).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
# Quick filters
|
||||
quick_frame = ttk.Frame(top_row)
|
||||
quick_frame.pack(side="right")
|
||||
ttk.Label(quick_frame, text="Quick:").pack(side="left", padx=(0, 5))
|
||||
quick_buttons = [
|
||||
("Week", self._filter_last_week),
|
||||
("Month", self._filter_last_month),
|
||||
("High", self._filter_high_symptoms),
|
||||
("Low", self._filter_low_symptoms),
|
||||
("None", self._filter_no_medication),
|
||||
("This Month", self._filter_this_month),
|
||||
]
|
||||
for text, cmd in quick_buttons:
|
||||
ttk.Button(quick_frame, text=text, command=cmd).pack(side="left", padx=2)
|
||||
|
||||
# Second row: date range
|
||||
date_frame = ttk.Frame(content_frame)
|
||||
date_frame.pack(fill="x", pady=(0, 5))
|
||||
ttk.Label(date_frame, text="Start Date (YYYY-MM-DD):").pack(side="left")
|
||||
ttk.Entry(date_frame, textvariable=self.start_date_var, width=12).pack(
|
||||
side="left", padx=(5, 10)
|
||||
)
|
||||
ttk.Label(date_frame, text="End Date (YYYY-MM-DD):").pack(side="left")
|
||||
ttk.Entry(date_frame, textvariable=self.end_date_var, width=12).pack(
|
||||
side="left", padx=(5, 10)
|
||||
)
|
||||
ttk.Button(date_frame, text="Apply", command=self._apply_date_filter).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
# Third row: medicines and pathologies
|
||||
middle_row = ttk.Frame(content_frame)
|
||||
middle_row.pack(fill="x", pady=(0, 5))
|
||||
|
||||
# Medicines section
|
||||
meds_frame = ttk.LabelFrame(middle_row, text="Medicines", padding="5")
|
||||
meds_frame.pack(side="left", fill="y", padx=(0, 10))
|
||||
for key in self.medicine_manager.get_medicine_keys():
|
||||
med = self.medicine_manager.get_medicine(key)
|
||||
var = tk.StringVar(value="any")
|
||||
self.medicine_vars[key] = var
|
||||
frame = ttk.Frame(meds_frame)
|
||||
frame.pack(fill="x", padx=2, pady=1)
|
||||
ttk.Label(frame, text=med.display_name).pack(side="left")
|
||||
ttk.Radiobutton(frame, text="Any", variable=var, value="any").pack(
|
||||
side="left", padx=2
|
||||
)
|
||||
ttk.Radiobutton(frame, text="Taken", variable=var, value="taken").pack(
|
||||
side="left", padx=2
|
||||
)
|
||||
ttk.Radiobutton(
|
||||
frame, text="Not taken", variable=var, value="not_taken"
|
||||
).pack(side="left", padx=2)
|
||||
|
||||
# Pathologies section
|
||||
path_frame = ttk.LabelFrame(middle_row, text="Pathologies", padding="5")
|
||||
path_frame.pack(side="left", fill="y")
|
||||
for key in self.pathology_manager.get_pathology_keys():
|
||||
path = self.pathology_manager.get_pathology(key)
|
||||
min_var = tk.StringVar(value="")
|
||||
max_var = tk.StringVar(value="")
|
||||
self.pathology_min_vars[key] = min_var
|
||||
self.pathology_max_vars[key] = max_var
|
||||
row = ttk.Frame(path_frame)
|
||||
row.pack(fill="x", padx=2, pady=1)
|
||||
ttk.Label(row, text=path.display_name).pack(side="left")
|
||||
ttk.Label(row, text="Min:").pack(side="left", padx=(6, 2))
|
||||
ttk.Entry(row, textvariable=min_var, width=4).pack(side="left")
|
||||
ttk.Label(row, text="Max:").pack(side="left", padx=(6, 2))
|
||||
ttk.Entry(row, textvariable=max_var, width=4).pack(side="left")
|
||||
|
||||
# Bottom row: status and actions
|
||||
bottom_row = ttk.Frame(content_frame)
|
||||
bottom_row.pack(fill="x")
|
||||
ttk.Button(bottom_row, text="Clear All", command=self._clear_all_filters).pack(
|
||||
side="left"
|
||||
)
|
||||
self.status_label = ttk.Label(bottom_row, text="No filters active")
|
||||
self.status_label.pack(side="right")
|
||||
|
||||
def _bind_events(self) -> None:
|
||||
# Search term changes
|
||||
self.search_var.trace_add("write", lambda *_: self._on_search_change())
|
||||
# Date range changes
|
||||
self.start_date_var.trace_add("write", lambda *_: self._on_date_change())
|
||||
self.end_date_var.trace_add("write", lambda *_: self._on_date_change())
|
||||
|
||||
# --- Event handlers and actions ---
|
||||
def _on_search_change(self) -> None:
|
||||
if self._suspend_traces:
|
||||
return
|
||||
self.data_filter.set_search_term(self.search_var.get())
|
||||
self.search_history.add_search(self.search_var.get())
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _on_date_change(self) -> None:
|
||||
if self._suspend_traces:
|
||||
return
|
||||
self.data_filter.set_date_range_filter(
|
||||
self.start_date_var.get() or None, self.end_date_var.get() or None
|
||||
)
|
||||
self._update_status()
|
||||
|
||||
def _apply_date_filter(self) -> None:
|
||||
self.data_filter.set_date_range_filter(
|
||||
self.start_date_var.get() or None, self.end_date_var.get() or None
|
||||
)
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
self.search_var.set("")
|
||||
|
||||
def _clear_all_filters(self) -> None:
|
||||
self.search_var.set("")
|
||||
self.start_date_var.set("")
|
||||
self.end_date_var.set("")
|
||||
for var in self.medicine_vars.values():
|
||||
var.set("any")
|
||||
for var in self.pathology_min_vars.values():
|
||||
var.set("")
|
||||
for var in self.pathology_max_vars.values():
|
||||
var.set("")
|
||||
self.data_filter.clear_all_filters()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_last_week(self) -> None:
|
||||
# Import from package-level to support canonical path
|
||||
QuickFilters.last_week(self.data_filter)
|
||||
self._update_date_ui()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_last_month(self) -> None:
|
||||
QuickFilters.last_month(self.data_filter)
|
||||
self._update_date_ui()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_this_month(self) -> None:
|
||||
QuickFilters.this_month(self.data_filter)
|
||||
self._update_date_ui()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_high_symptoms(self) -> None:
|
||||
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||
QuickFilters.high_symptoms(self.data_filter, pathology_keys)
|
||||
self._update_pathology_ui()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_low_symptoms(self) -> None:
|
||||
pathology_keys = self.pathology_manager.get_pathology_keys()
|
||||
QuickFilters.low_symptoms(self.data_filter, pathology_keys)
|
||||
self._update_pathology_ui()
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _filter_no_medication(self) -> None:
|
||||
medicine_keys = self.medicine_manager.get_medicine_keys()
|
||||
QuickFilters.no_medication(self.data_filter, medicine_keys)
|
||||
self._update_status()
|
||||
self.update_callback()
|
||||
|
||||
def _update_date_ui(self) -> None:
|
||||
active = getattr(self.data_filter, "active_filters", {}) or {}
|
||||
if "date_range" in active:
|
||||
date_filter = active["date_range"]
|
||||
self.start_date_var.set(date_filter.get("start", ""))
|
||||
self.end_date_var.set(date_filter.get("end", ""))
|
||||
|
||||
def _update_pathology_ui(self) -> None:
|
||||
active = getattr(self.data_filter, "active_filters", {}) or {}
|
||||
if "pathologies" in active:
|
||||
pathology_filters = active["pathologies"]
|
||||
for pathology_key, score_range in pathology_filters.items():
|
||||
if pathology_key in self.pathology_min_vars:
|
||||
min_score = score_range.get("min")
|
||||
max_score = score_range.get("max")
|
||||
if min_score is not None:
|
||||
self.pathology_min_vars[pathology_key].set(str(min_score))
|
||||
if max_score is not None:
|
||||
self.pathology_max_vars[pathology_key].set(str(max_score))
|
||||
|
||||
def _update_status(self) -> None:
|
||||
if not getattr(self, "status_label", None):
|
||||
return
|
||||
summary = self.data_filter.get_filter_summary()
|
||||
if not summary["has_filters"]:
|
||||
self.status_label.config(text="No filters active")
|
||||
else:
|
||||
parts: list[str] = []
|
||||
if summary["search_term"]:
|
||||
parts.append(f"Search: '{summary['search_term']}'")
|
||||
f = summary["filters"]
|
||||
if "date_range" in f:
|
||||
d = f["date_range"]
|
||||
parts.append(f"Date: {d['start']} to {d['end']}")
|
||||
if "medicines" in f:
|
||||
m = f["medicines"]
|
||||
if m["taken"]:
|
||||
parts.append("Taken: " + ", ".join(m["taken"]))
|
||||
if m["not_taken"]:
|
||||
parts.append("Not taken: " + ", ".join(m["not_taken"]))
|
||||
if "pathologies" in f:
|
||||
p = f["pathologies"]
|
||||
parts.extend([f"{k}: {v}" for k, v in p.items()])
|
||||
self.status_label.config(text=" | ".join(parts))
|
||||
|
||||
# --- Public methods ---
|
||||
def get_widget(self) -> ttk.LabelFrame:
|
||||
return self.frame
|
||||
|
||||
def show(self) -> None:
|
||||
if self.is_visible:
|
||||
return
|
||||
self.is_visible = True
|
||||
self.frame.pack(fill="x", padx=5, pady=5)
|
||||
# Ensure parent layout is updated for tests
|
||||
parent = self.frame.master
|
||||
if hasattr(parent, "rowconfigure"):
|
||||
with contextlib.suppress(Exception):
|
||||
parent.rowconfigure(0, weight=1)
|
||||
|
||||
def hide(self) -> None:
|
||||
if not self.is_visible:
|
||||
return
|
||||
self.is_visible = False
|
||||
self.frame.pack_forget()
|
||||
parent = self.frame.master
|
||||
if hasattr(parent, "rowconfigure"):
|
||||
with contextlib.suppress(Exception):
|
||||
parent.rowconfigure(0, weight=0)
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Settings window (canonical)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from thechart.core.constants import BACKUP_PATH
|
||||
from thechart.core.preferences import (
|
||||
get_config_dir,
|
||||
get_pref,
|
||||
reset_preferences,
|
||||
save_preferences,
|
||||
set_pref,
|
||||
)
|
||||
|
||||
|
||||
class SettingsWindow:
|
||||
"""Settings window for application preferences."""
|
||||
|
||||
def __init__(self, parent: tk.Tk, theme_manager, ui_manager) -> None:
|
||||
self.parent = parent
|
||||
self.theme_manager = theme_manager
|
||||
self.ui_manager = ui_manager
|
||||
|
||||
# Create window
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window.title("Settings - TheChart")
|
||||
# Larger default size; allow user to resize
|
||||
self.window.geometry("760x560")
|
||||
self.window.minsize(640, 480)
|
||||
self.window.resizable(True, True)
|
||||
|
||||
# Make window modal
|
||||
self.window.transient(parent)
|
||||
self.window.grab_set()
|
||||
|
||||
# Center the window
|
||||
self._center_window()
|
||||
|
||||
# Setup UI
|
||||
self._setup_ui()
|
||||
|
||||
# Set initial values
|
||||
self._load_current_settings()
|
||||
|
||||
def _center_window(self) -> None:
|
||||
"""Center the settings window on the parent."""
|
||||
self.window.update_idletasks()
|
||||
|
||||
# Get window dimensions
|
||||
window_width = self.window.winfo_reqwidth()
|
||||
window_height = self.window.winfo_reqheight()
|
||||
|
||||
# Get parent window position and size
|
||||
parent_x = self.parent.winfo_x()
|
||||
parent_y = self.parent.winfo_y()
|
||||
parent_width = self.parent.winfo_width()
|
||||
parent_height = self.parent.winfo_height()
|
||||
|
||||
# Calculate centered position
|
||||
x = parent_x + (parent_width // 2) - (window_width // 2)
|
||||
y = parent_y + (parent_height // 2) - (window_height // 2)
|
||||
|
||||
self.window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Setup the settings UI."""
|
||||
# Main container
|
||||
main_frame = ttk.Frame(self.window, padding="20", style="Card.TFrame")
|
||||
main_frame.pack(fill="both", expand=True)
|
||||
|
||||
# Title
|
||||
title_label = ttk.Label(
|
||||
main_frame,
|
||||
text="Application Settings",
|
||||
font=("TkDefaultFont", 16, "bold"),
|
||||
)
|
||||
title_label.pack(pady=(0, 20))
|
||||
|
||||
# Create notebook for different setting categories
|
||||
notebook = ttk.Notebook(main_frame, style="Modern.TNotebook")
|
||||
notebook.pack(fill="both", expand=True, pady=(0, 20))
|
||||
|
||||
# Theme settings tab
|
||||
self._create_theme_tab(notebook)
|
||||
|
||||
# UI settings tab
|
||||
self._create_ui_tab(notebook)
|
||||
|
||||
# About tab
|
||||
self._create_about_tab(notebook)
|
||||
|
||||
# Button frame
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.pack(fill="x", pady=(10, 0))
|
||||
|
||||
# Buttons
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Apply",
|
||||
command=self._apply_settings,
|
||||
style="Action.TButton",
|
||||
).pack(side="right", padx=(5, 0))
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Cancel",
|
||||
command=self._cancel,
|
||||
style="Action.TButton",
|
||||
).pack(side="right")
|
||||
|
||||
def _reset_all() -> None:
|
||||
if messagebox.askyesno(
|
||||
"Reset All Settings",
|
||||
(
|
||||
"This will restore all settings to defaults and clear saved"
|
||||
" window geometry. Continue?"
|
||||
),
|
||||
parent=self.window,
|
||||
):
|
||||
try:
|
||||
reset_preferences()
|
||||
# Reflect defaults in UI state
|
||||
self.remember_size_var.set(
|
||||
bool(get_pref("remember_window_geometry", True))
|
||||
)
|
||||
self.always_on_top_var.set(bool(get_pref("always_on_top", False)))
|
||||
self.prompt_open_folder_after_restore_var.set(
|
||||
bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
# Apply always-on-top immediately using default
|
||||
with contextlib.suppress(Exception):
|
||||
self.parent.wm_attributes(
|
||||
"-topmost", bool(self.always_on_top_var.get())
|
||||
)
|
||||
if hasattr(self.ui_manager, "update_status"):
|
||||
self.ui_manager.update_status(
|
||||
"Settings reset to defaults", "info"
|
||||
)
|
||||
except Exception:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
"Failed to reset settings.",
|
||||
parent=self.window,
|
||||
)
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="Reset All Settings…",
|
||||
command=_reset_all,
|
||||
style="Action.TButton",
|
||||
).pack(side="left")
|
||||
|
||||
ttk.Button(
|
||||
button_frame,
|
||||
text="OK",
|
||||
command=self._ok,
|
||||
style="Action.TButton",
|
||||
).pack(side="right", padx=(0, 5))
|
||||
|
||||
def _create_theme_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the theme settings tab."""
|
||||
theme_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(theme_frame, text="Theme")
|
||||
|
||||
# Theme selection
|
||||
theme_label_frame = ttk.LabelFrame(
|
||||
theme_frame, text="Theme Selection", style="Card.TLabelframe"
|
||||
)
|
||||
theme_label_frame.pack(fill="x", padx=10, pady=10)
|
||||
|
||||
ttk.Label(
|
||||
theme_label_frame,
|
||||
text="Choose your preferred theme:",
|
||||
font=("TkDefaultFont", 10),
|
||||
).pack(anchor="w", padx=10, pady=(10, 5))
|
||||
|
||||
# Theme radio buttons
|
||||
self.theme_var = tk.StringVar()
|
||||
themes = self.theme_manager.get_available_themes()
|
||||
|
||||
theme_buttons_frame = ttk.Frame(theme_label_frame)
|
||||
theme_buttons_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Create radio buttons in a grid
|
||||
for i, theme in enumerate(themes):
|
||||
row = i // 3
|
||||
col = i % 3
|
||||
|
||||
ttk.Radiobutton(
|
||||
theme_buttons_frame,
|
||||
text=theme.title(),
|
||||
variable=self.theme_var,
|
||||
value=theme,
|
||||
style="Modern.TCheckbutton",
|
||||
).grid(row=row, column=col, sticky="w", padx=5, pady=2)
|
||||
|
||||
# Theme preview info
|
||||
preview_frame = ttk.LabelFrame(
|
||||
theme_frame, text="Theme Preview", style="Card.TLabelframe"
|
||||
)
|
||||
preview_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||||
|
||||
preview_text = tk.Text(
|
||||
preview_frame,
|
||||
height=6,
|
||||
wrap="word",
|
||||
font=("TkDefaultFont", 9),
|
||||
state="disabled",
|
||||
)
|
||||
preview_text.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Theme change callback
|
||||
def on_theme_change():
|
||||
selected_theme = self.theme_var.get()
|
||||
preview_text.config(state="normal")
|
||||
preview_text.delete("1.0", "end")
|
||||
preview_text.insert(
|
||||
"1.0",
|
||||
f"Selected theme: {selected_theme.title()}\n\n"
|
||||
"Theme changes will be applied when you click 'Apply' or 'OK'. "
|
||||
"The new theme will affect all windows and UI elements "
|
||||
"in the application.",
|
||||
)
|
||||
preview_text.config(state="disabled")
|
||||
|
||||
self.theme_var.trace("w", lambda *args: on_theme_change())
|
||||
|
||||
def _create_ui_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the UI settings tab."""
|
||||
ui_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(ui_frame, text="Interface")
|
||||
|
||||
# Font settings
|
||||
font_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Font Settings", style="Card.TLabelframe"
|
||||
)
|
||||
font_frame.pack(fill="x", padx=10, pady=10)
|
||||
|
||||
ttk.Label(
|
||||
font_frame,
|
||||
text="Font size adjustments (requires restart):",
|
||||
font=("TkDefaultFont", 10),
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Font size scale
|
||||
self.font_scale_var = tk.DoubleVar(value=1.0)
|
||||
font_scale = ttk.Scale(
|
||||
font_frame,
|
||||
from_=0.8,
|
||||
to=1.5,
|
||||
variable=self.font_scale_var,
|
||||
orient="horizontal",
|
||||
style="Modern.Horizontal.TScale",
|
||||
)
|
||||
font_scale.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Scale labels
|
||||
scale_labels_frame = ttk.Frame(font_frame)
|
||||
scale_labels_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(scale_labels_frame, text="Small").pack(side="left")
|
||||
ttk.Label(scale_labels_frame, text="Large").pack(side="right")
|
||||
ttk.Label(scale_labels_frame, text="Normal").pack()
|
||||
|
||||
# Window settings
|
||||
window_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Window Settings", style="Card.TLabelframe"
|
||||
)
|
||||
window_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
# Remember window size
|
||||
from thechart.core.preferences import get_pref as _getp
|
||||
|
||||
self.remember_size_var = tk.BooleanVar(
|
||||
value=bool(_getp("remember_window_geometry", True))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
window_frame,
|
||||
text="Remember window size and position",
|
||||
variable=self.remember_size_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Always on top
|
||||
self.always_on_top_var = tk.BooleanVar(
|
||||
value=bool(_getp("always_on_top", False))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
window_frame,
|
||||
text="Keep window always on top",
|
||||
variable=self.always_on_top_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=(0, 10))
|
||||
|
||||
# Reset window position button
|
||||
def _reset_window_position() -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
# Clear saved geometry preference and persist
|
||||
set_pref("last_window_geometry", "")
|
||||
save_preferences()
|
||||
|
||||
# Center the main window on the screen
|
||||
try:
|
||||
self.parent.update_idletasks()
|
||||
width = self.parent.winfo_width() or self.parent.winfo_reqwidth()
|
||||
height = self.parent.winfo_height() or self.parent.winfo_reqheight()
|
||||
sw = self.parent.winfo_screenwidth()
|
||||
sh = self.parent.winfo_screenheight()
|
||||
x = (sw // 2) - (width // 2)
|
||||
y = (sh // 2) - (height // 2)
|
||||
self.parent.geometry(f"{width}x{height}+{x}+{y}")
|
||||
if hasattr(self.ui_manager, "update_status"):
|
||||
self.ui_manager.update_status("Window position reset", "info")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reset_btn = ttk.Button(
|
||||
window_frame,
|
||||
text="Reset Window Position",
|
||||
command=_reset_window_position,
|
||||
style="Action.TButton",
|
||||
)
|
||||
reset_btn.pack(anchor="w", padx=10, pady=(0, 10))
|
||||
|
||||
# Tooltip for reset action
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
reset_btn,
|
||||
"Clear saved window size/position and center the app",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restore settings
|
||||
restore_frame = ttk.LabelFrame(
|
||||
ui_frame, text="Backup & Restore", style="Card.TLabelframe"
|
||||
)
|
||||
restore_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
self.prompt_open_folder_after_restore_var = tk.BooleanVar(
|
||||
value=bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
ttk.Checkbutton(
|
||||
restore_frame,
|
||||
text="Offer to open backups folder after successful restore",
|
||||
variable=self.prompt_open_folder_after_restore_var,
|
||||
style="Modern.TCheckbutton",
|
||||
).pack(anchor="w", padx=10, pady=10)
|
||||
|
||||
# Backups folder path and open button
|
||||
bkp_frame = ttk.Frame(restore_frame)
|
||||
bkp_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(bkp_frame, text="Backups folder:").pack(side="left", padx=(0, 8))
|
||||
# Resolve backup path from constants (env-aware)
|
||||
self._bkp_path_var = tk.StringVar(value=BACKUP_PATH)
|
||||
bkp_entry = ttk.Entry(
|
||||
bkp_frame,
|
||||
textvariable=self._bkp_path_var,
|
||||
width=44,
|
||||
state="readonly",
|
||||
)
|
||||
bkp_entry.pack(side="left", fill="x", expand=True)
|
||||
|
||||
def _open_bkp() -> None:
|
||||
path = self._bkp_path_var.get()
|
||||
with contextlib.suppress(Exception):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
|
||||
bkp_open_btn = ttk.Button(
|
||||
bkp_frame,
|
||||
text="Open",
|
||||
command=_open_bkp,
|
||||
style="Action.TButton",
|
||||
width=8,
|
||||
)
|
||||
bkp_open_btn.pack(side="left", padx=(8, 0))
|
||||
|
||||
# Brief description for backups folder
|
||||
ttk.Label(
|
||||
restore_frame,
|
||||
text=(
|
||||
"Automatic CSV backups are saved in this folder. "
|
||||
"It will be created if it doesn't exist."
|
||||
),
|
||||
justify="left",
|
||||
wraplength=680,
|
||||
).pack(anchor="w", padx=10, pady=(2, 10))
|
||||
|
||||
# Tooltip for Open (backups)
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
bkp_open_btn,
|
||||
"Open the backups folder in your file manager",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Config folder path and open button
|
||||
cfg_frame = ttk.Frame(restore_frame)
|
||||
cfg_frame.pack(fill="x", padx=10, pady=(0, 10))
|
||||
|
||||
ttk.Label(cfg_frame, text="Config folder:").pack(side="left", padx=(0, 8))
|
||||
self._cfg_path_var = tk.StringVar(value=get_config_dir())
|
||||
cfg_entry = ttk.Entry(
|
||||
cfg_frame,
|
||||
textvariable=self._cfg_path_var,
|
||||
width=44,
|
||||
state="readonly",
|
||||
)
|
||||
cfg_entry.pack(side="left", fill="x", expand=True)
|
||||
|
||||
def _open_cfg() -> None:
|
||||
path = self._cfg_path_var.get()
|
||||
with contextlib.suppress(Exception):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if sys.platform.startswith("darwin"):
|
||||
os.system(f'open "{path}"')
|
||||
elif os.name == "nt":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
os.system(f'xdg-open "{path}" >/dev/null 2>&1 &')
|
||||
|
||||
cfg_open_btn = ttk.Button(
|
||||
cfg_frame,
|
||||
text="Open",
|
||||
command=_open_cfg,
|
||||
style="Action.TButton",
|
||||
width=8,
|
||||
)
|
||||
cfg_open_btn.pack(side="left", padx=(8, 0))
|
||||
|
||||
# Tooltip for Open (config)
|
||||
try:
|
||||
if (
|
||||
hasattr(self.ui_manager, "tooltip_manager")
|
||||
and self.ui_manager.tooltip_manager
|
||||
):
|
||||
self.ui_manager.tooltip_manager.add_tooltip(
|
||||
cfg_open_btn,
|
||||
"Open the configuration folder (preferences.json)",
|
||||
delay=500,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _create_about_tab(self, notebook: ttk.Notebook) -> None:
|
||||
"""Create the about tab."""
|
||||
about_frame = ttk.Frame(notebook, style="Card.TFrame")
|
||||
notebook.add(about_frame, text="About")
|
||||
|
||||
# App info
|
||||
info_frame = ttk.LabelFrame(
|
||||
about_frame, text="Application Information", style="Card.TLabelframe"
|
||||
)
|
||||
info_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
about_text = tk.Text(
|
||||
info_frame,
|
||||
wrap="word",
|
||||
font=("TkDefaultFont", 10),
|
||||
state="disabled",
|
||||
bg=self.theme_manager.get_theme_colors()["bg"],
|
||||
fg=self.theme_manager.get_theme_colors()["fg"],
|
||||
)
|
||||
about_text.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
about_content = """TheChart - Medication Tracker
|
||||
|
||||
Version: 1.9.5
|
||||
Built with: Python, Tkinter, ttkthemes
|
||||
|
||||
Features:
|
||||
• Modern themed interface with multiple themes
|
||||
• Medication and pathology tracking
|
||||
• Visual graphs and charts
|
||||
• Data export capabilities
|
||||
• Keyboard shortcuts for efficiency
|
||||
• Customizable UI settings
|
||||
|
||||
This application helps you track your daily medications and health
|
||||
conditions with an intuitive, modern interface.
|
||||
|
||||
Enhanced with ttkthemes for better visual appeal and user experience."""
|
||||
|
||||
about_text.config(state="normal")
|
||||
about_text.insert("1.0", about_content)
|
||||
about_text.config(state="disabled")
|
||||
|
||||
def _load_current_settings(self) -> None:
|
||||
"""Load current application settings."""
|
||||
# Set current theme
|
||||
current_theme = self.theme_manager.get_current_theme()
|
||||
self.theme_var.set(current_theme)
|
||||
|
||||
# Trigger theme change to update preview
|
||||
if hasattr(self, "theme_var"):
|
||||
self.theme_var.set(current_theme)
|
||||
# Ensure UI checkboxes reflect preferences
|
||||
if hasattr(self, "prompt_open_folder_after_restore_var"):
|
||||
self.prompt_open_folder_after_restore_var.set(
|
||||
bool(get_pref("prompt_open_folder_after_restore", False))
|
||||
)
|
||||
|
||||
def _apply_settings(self) -> None:
|
||||
"""Apply the selected settings."""
|
||||
# Apply theme if changed
|
||||
selected_theme = self.theme_var.get()
|
||||
current_theme = self.theme_manager.get_current_theme()
|
||||
|
||||
if selected_theme != current_theme:
|
||||
if self.theme_manager.apply_theme(selected_theme):
|
||||
self.ui_manager.update_status(
|
||||
f"Theme changed to: {selected_theme.title()}", "info"
|
||||
)
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"Error",
|
||||
f"Failed to apply theme: {selected_theme}",
|
||||
parent=self.window,
|
||||
)
|
||||
return
|
||||
|
||||
# Save preferences
|
||||
set_pref(
|
||||
"prompt_open_folder_after_restore",
|
||||
bool(self.prompt_open_folder_after_restore_var.get()),
|
||||
)
|
||||
set_pref("remember_window_geometry", bool(self.remember_size_var.get()))
|
||||
set_pref("always_on_top", bool(self.always_on_top_var.get()))
|
||||
|
||||
# Apply always-on-top immediately
|
||||
import contextlib as _ctx
|
||||
|
||||
with _ctx.suppress(Exception):
|
||||
self.parent.wm_attributes("-topmost", bool(self.always_on_top_var.get()))
|
||||
|
||||
messagebox.showinfo(
|
||||
"Settings Applied",
|
||||
"Settings have been applied successfully!",
|
||||
parent=self.window,
|
||||
)
|
||||
# Persist settings at the end
|
||||
with contextlib.suppress(Exception):
|
||||
save_preferences()
|
||||
|
||||
def _ok(self) -> None:
|
||||
"""Apply settings and close window."""
|
||||
self._apply_settings()
|
||||
self.window.destroy()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
"""Close window without applying settings."""
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
__all__ = ["SettingsWindow"]
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Theme manager for the application using ttkthemes (canonical)."""
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from ttkthemes import ThemedStyle
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
"""Manages application themes and styling."""
|
||||
|
||||
def __init__(self, root: tk.Tk, logger: logging.Logger) -> None:
|
||||
self.root = root
|
||||
self.logger = logger
|
||||
self.style: ThemedStyle | None = None
|
||||
self.current_theme: str = "arc" # Default theme
|
||||
|
||||
# Available themes - these are some of the best looking ones
|
||||
self.available_themes = [
|
||||
"arc",
|
||||
"equilux",
|
||||
"adapta",
|
||||
"yaru",
|
||||
"ubuntu",
|
||||
"plastik",
|
||||
"breeze",
|
||||
"elegance",
|
||||
]
|
||||
|
||||
self.initialize_theme()
|
||||
|
||||
def initialize_theme(self) -> None:
|
||||
"""Initialize the themed style."""
|
||||
try:
|
||||
self.style = ThemedStyle(self.root)
|
||||
self.apply_theme(self.current_theme)
|
||||
self._configure_custom_styles()
|
||||
self.logger.info(
|
||||
f"Theme manager initialized with theme: {self.current_theme}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize theme manager: {e}")
|
||||
# Fallback to default ttk styling
|
||||
self.style = ttk.Style()
|
||||
|
||||
def apply_theme(self, theme_name: str) -> bool:
|
||||
"""Apply a specific theme."""
|
||||
try:
|
||||
if self.style and theme_name in self.get_available_themes():
|
||||
self.style.set_theme(theme_name)
|
||||
self.current_theme = theme_name
|
||||
self._configure_custom_styles()
|
||||
self.logger.info(f"Applied theme: {theme_name}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Theme '{theme_name}' not available")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to apply theme '{theme_name}': {e}")
|
||||
return False
|
||||
|
||||
def get_available_themes(self) -> list[str]:
|
||||
"""Get list of available themes."""
|
||||
if self.style:
|
||||
try:
|
||||
# Get all available themes from ttkthemes
|
||||
all_themes = self.style.theme_names()
|
||||
# Filter to only include our curated list
|
||||
return [theme for theme in self.available_themes if theme in all_themes]
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get available themes: {e}")
|
||||
return self.available_themes
|
||||
return self.available_themes
|
||||
|
||||
def get_current_theme(self) -> str:
|
||||
"""Get the currently active theme."""
|
||||
return self.current_theme
|
||||
|
||||
def _get_contrasting_colors(self, colors: dict[str, str]) -> dict[str, str]:
|
||||
"""Get contrasting colors for headers with improved visibility."""
|
||||
|
||||
def get_luminance(color_str: str) -> float:
|
||||
"""Calculate relative luminance of a color."""
|
||||
if not color_str or not color_str.startswith("#"):
|
||||
return 0.5
|
||||
try:
|
||||
rgb = tuple(int(color_str[i : i + 2], 16) for i in (1, 3, 5))
|
||||
# Calculate relative luminance
|
||||
return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
|
||||
except (ValueError, IndexError):
|
||||
return 0.5
|
||||
|
||||
def get_contrast_ratio(bg: str, fg: str) -> float:
|
||||
"""Calculate contrast ratio between two colors."""
|
||||
bg_lum = get_luminance(bg)
|
||||
fg_lum = get_luminance(fg)
|
||||
lighter = max(bg_lum, fg_lum)
|
||||
darker = min(bg_lum, fg_lum)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
# Start with the provided select colors
|
||||
header_bg = colors["select_bg"]
|
||||
header_fg = colors["select_fg"]
|
||||
|
||||
# Calculate contrast ratio
|
||||
contrast = get_contrast_ratio(header_bg, header_fg)
|
||||
|
||||
# If contrast is poor (less than 3:1), use high-contrast alternatives
|
||||
if contrast < 3.0:
|
||||
bg_luminance = get_luminance(colors["bg"])
|
||||
|
||||
if bg_luminance > 0.5: # Light theme
|
||||
header_bg = "#1e1e1e" # Very dark gray background for maximum contrast
|
||||
header_fg = "#ffffff" # Pure white for maximum contrast
|
||||
else: # Dark theme - use dark background with light text
|
||||
header_bg = "#1e1e1e" # Very dark gray for consistency
|
||||
header_fg = "#ffffff" # Pure white for maximum contrast
|
||||
|
||||
self.logger.debug(
|
||||
f"Poor header contrast ({contrast:.2f}), using fallback colors: "
|
||||
f"bg={header_bg}, fg={header_fg}"
|
||||
)
|
||||
|
||||
return {
|
||||
"header_bg": header_bg,
|
||||
"header_fg": header_fg,
|
||||
}
|
||||
|
||||
def _configure_custom_styles(self) -> None:
|
||||
"""Configure custom styles for better appearance."""
|
||||
if not self.style:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current theme colors for consistent styling
|
||||
colors = self.get_theme_colors()
|
||||
|
||||
# Get improved header colors with better contrast
|
||||
header_colors = self._get_contrasting_colors(colors)
|
||||
|
||||
# Configure frame styles with better padding and borders
|
||||
self.style.configure(
|
||||
"Card.TFrame",
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
background=colors["bg"],
|
||||
)
|
||||
|
||||
# Configure label frame styles with modern appearance
|
||||
self.style.configure(
|
||||
"Card.TLabelframe",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
padding=(10, 5, 10, 10),
|
||||
)
|
||||
|
||||
self.style.configure(
|
||||
"Card.TLabelframe.Label",
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
font=("TkDefaultFont", 10, "bold"),
|
||||
)
|
||||
|
||||
# Configure button styles for better appearance
|
||||
self.style.configure(
|
||||
"Action.TButton",
|
||||
padding=(15, 8),
|
||||
font=("TkDefaultFont", 9, "normal"),
|
||||
)
|
||||
|
||||
# Configure entry styles with modern look
|
||||
self.style.configure(
|
||||
"Modern.TEntry",
|
||||
padding=(8, 5),
|
||||
borderwidth=1,
|
||||
relief="solid",
|
||||
)
|
||||
|
||||
# Configure scale styles for pathology inputs
|
||||
self.style.configure(
|
||||
"Modern.Horizontal.TScale",
|
||||
borderwidth=0,
|
||||
background=colors["bg"],
|
||||
troughcolor="#e0e0e0",
|
||||
lightcolor=colors["select_bg"],
|
||||
darkcolor=colors["select_bg"],
|
||||
focuscolor=colors["select_bg"],
|
||||
)
|
||||
|
||||
# Configure treeview for better data display
|
||||
self.style.configure(
|
||||
"Modern.Treeview",
|
||||
rowheight=28,
|
||||
borderwidth=1,
|
||||
relief="solid",
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
fieldbackground=colors["bg"],
|
||||
selectbackground=colors["select_bg"],
|
||||
selectforeground=colors["select_fg"],
|
||||
)
|
||||
|
||||
self.style.configure(
|
||||
"Modern.Treeview.Heading",
|
||||
padding=(8, 6),
|
||||
relief="flat",
|
||||
borderwidth=1,
|
||||
background=header_colors["header_bg"],
|
||||
foreground=header_colors["header_fg"],
|
||||
font=("TkDefaultFont", 9, "bold"),
|
||||
)
|
||||
|
||||
# Ensure header style mapping to override theme defaults
|
||||
self.style.map(
|
||||
"Modern.Treeview.Heading",
|
||||
background=[
|
||||
("active", header_colors["header_bg"]),
|
||||
("pressed", header_colors["header_bg"]),
|
||||
("", header_colors["header_bg"]),
|
||||
],
|
||||
foreground=[
|
||||
("active", header_colors["header_fg"]),
|
||||
("pressed", header_colors["header_fg"]),
|
||||
("", header_colors["header_fg"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Configure comprehensive row selection colors for better visibility
|
||||
self.style.map(
|
||||
"Modern.Treeview",
|
||||
background=[
|
||||
("selected", colors["select_bg"]),
|
||||
("active", colors["select_bg"]),
|
||||
("focus", colors["select_bg"]),
|
||||
("", colors["bg"]),
|
||||
],
|
||||
foreground=[
|
||||
("selected", colors["select_fg"]),
|
||||
("active", colors["select_fg"]),
|
||||
("focus", colors["select_fg"]),
|
||||
("", colors["fg"]),
|
||||
],
|
||||
selectbackground=[
|
||||
("focus", colors["select_bg"]),
|
||||
("", colors["select_bg"]),
|
||||
],
|
||||
selectforeground=[
|
||||
("focus", colors["select_fg"]),
|
||||
("", colors["select_fg"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Configure notebook tabs with modern styling
|
||||
self.style.configure(
|
||||
"Modern.TNotebook.Tab",
|
||||
padding=(15, 8),
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
)
|
||||
|
||||
self.style.map(
|
||||
"Modern.TNotebook.Tab",
|
||||
background=[("selected", colors["select_bg"])],
|
||||
foreground=[("selected", colors["select_fg"])],
|
||||
)
|
||||
|
||||
# Configure checkbutton for medicine selection
|
||||
self.style.configure(
|
||||
"Modern.TCheckbutton",
|
||||
padding=(8, 4),
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
focuscolor=colors["select_bg"],
|
||||
)
|
||||
|
||||
self.logger.debug("Enhanced custom styles configured")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure custom styles: {e}")
|
||||
|
||||
def get_menu_colors(self) -> dict[str, str]:
|
||||
"""Get colors specifically for menu theming."""
|
||||
colors = self.get_theme_colors()
|
||||
|
||||
# Use slightly different colors for menus to make them stand out
|
||||
try:
|
||||
# For menu background, use a slightly darker/lighter shade
|
||||
if colors["bg"].startswith("#"):
|
||||
rgb = tuple(int(colors["bg"][i : i + 2], 16) for i in (1, 3, 5))
|
||||
if sum(rgb) > 384: # Light theme - make menu slightly darker
|
||||
menu_bg = (
|
||||
f"#{max(0, rgb[0] - 8):02x}"
|
||||
f"{max(0, rgb[1] - 8):02x}"
|
||||
f"{max(0, rgb[2] - 8):02x}"
|
||||
)
|
||||
else: # Dark theme - make menu slightly lighter
|
||||
menu_bg = (
|
||||
f"#{min(255, rgb[0] + 15):02x}"
|
||||
f"{min(255, rgb[1] + 15):02x}"
|
||||
f"{min(255, rgb[2] + 15):02x}"
|
||||
)
|
||||
else:
|
||||
menu_bg = colors["bg"]
|
||||
except (ValueError, IndexError):
|
||||
menu_bg = colors["bg"]
|
||||
|
||||
return {
|
||||
"bg": menu_bg,
|
||||
"fg": colors["fg"],
|
||||
"active_bg": colors["select_bg"],
|
||||
"active_fg": colors["select_fg"],
|
||||
"disabled_fg": colors.get("disabled_fg", "#888888"),
|
||||
}
|
||||
|
||||
def configure_menu(self, menu: "tk.Menu") -> None:
|
||||
"""Apply theme colors to a menu widget."""
|
||||
try:
|
||||
menu_colors = self.get_menu_colors()
|
||||
|
||||
menu.configure(
|
||||
background=menu_colors["bg"],
|
||||
foreground=menu_colors["fg"],
|
||||
activebackground=menu_colors["active_bg"],
|
||||
activeforeground=menu_colors["active_fg"],
|
||||
disabledforeground=menu_colors["disabled_fg"],
|
||||
relief="flat",
|
||||
borderwidth=1,
|
||||
)
|
||||
|
||||
self.logger.debug(f"Applied theme to menu: {menu_colors}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure menu theme: {e}")
|
||||
|
||||
def create_themed_menu(self, parent: "tk.Widget", **kwargs) -> "tk.Menu":
|
||||
"""Create a new menu with theme colors already applied."""
|
||||
try:
|
||||
menu = tk.Menu(parent, **kwargs)
|
||||
self.configure_menu(menu)
|
||||
return menu
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create themed menu: {e}")
|
||||
# Fallback to a minimally constructed menu without theming
|
||||
try:
|
||||
return tk.Menu(parent)
|
||||
except Exception:
|
||||
# As a last resort, return a dummy object that quacks like a Menu
|
||||
class _DummyMenu:
|
||||
def __init__(self) -> None:
|
||||
self._options = {}
|
||||
|
||||
def __getitem__(self, key): # support menu['tearoff'] tests
|
||||
return self._options.get(key, 0)
|
||||
|
||||
def configure(self, **_kw):
|
||||
self._options.update(_kw)
|
||||
|
||||
return _DummyMenu()
|
||||
|
||||
def configure_widget_style(self, widget: tk.Widget, style_name: str) -> None:
|
||||
"""Apply a specific style to a widget."""
|
||||
try:
|
||||
if hasattr(widget, "configure") and self.style:
|
||||
widget.configure(style=style_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure widget style '{style_name}': {e}")
|
||||
|
||||
def get_theme_colors(self) -> dict[str, str]:
|
||||
"""Get current theme colors for custom widgets."""
|
||||
if not self.style:
|
||||
return {
|
||||
"bg": "#ffffff",
|
||||
"fg": "#000000",
|
||||
"select_bg": "#3584e4",
|
||||
"select_fg": "#ffffff",
|
||||
"alt_bg": "#f5f5f5",
|
||||
}
|
||||
|
||||
try:
|
||||
# Get colors from current theme and convert to strings
|
||||
bg = str(self.style.lookup("TFrame", "background") or "#ffffff")
|
||||
fg = str(self.style.lookup("TLabel", "foreground") or "#000000")
|
||||
|
||||
# Try to get better selection colors from different widget states
|
||||
select_bg = str(
|
||||
self.style.lookup("TButton", "background", ["pressed"])
|
||||
or self.style.lookup("TButton", "background", ["active"])
|
||||
or self.style.lookup("Treeview", "selectbackground")
|
||||
or "#0078d4" # Modern blue fallback
|
||||
)
|
||||
select_fg = str(
|
||||
self.style.lookup("TButton", "foreground", ["pressed"])
|
||||
or self.style.lookup("TButton", "foreground", ["active"])
|
||||
or self.style.lookup("Treeview", "selectforeground")
|
||||
or "#ffffff" # White fallback
|
||||
)
|
||||
|
||||
return {
|
||||
"bg": bg,
|
||||
"fg": fg,
|
||||
"select_bg": select_bg,
|
||||
"select_fg": select_fg,
|
||||
"alt_bg": "#f5f5f5",
|
||||
}
|
||||
except Exception:
|
||||
# Fallback colors on error
|
||||
return {
|
||||
"bg": "#ffffff",
|
||||
"fg": "#000000",
|
||||
"select_bg": "#3584e4",
|
||||
"select_fg": "#ffffff",
|
||||
"alt_bg": "#f5f5f5",
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tooltip system for enhanced user experience (canonical)."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
class ToolTip:
|
||||
"""Create a tooltip for a given widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: tk.Widget,
|
||||
text: str,
|
||||
delay: int = 500,
|
||||
wrap_length: int = 250,
|
||||
) -> None:
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay = delay
|
||||
self.wrap_length = wrap_length
|
||||
self.tooltip: tk.Toplevel | None = None
|
||||
self.id_after: str | None = None
|
||||
|
||||
# Bind events
|
||||
self.widget.bind("<Enter>", self._on_enter)
|
||||
self.widget.bind("<Leave>", self._on_leave)
|
||||
self.widget.bind("<ButtonPress>", self._on_leave)
|
||||
|
||||
def _on_enter(self, event: tk.Event | None = None) -> None:
|
||||
"""Mouse entered widget - schedule tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self.id_after = self.widget.after(self.delay, self._show_tooltip)
|
||||
|
||||
def _on_leave(self, event: tk.Event | None = None) -> None:
|
||||
"""Mouse left widget - hide tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
def _cancel_scheduled(self) -> None:
|
||||
"""Cancel any scheduled tooltip."""
|
||||
if self.id_after:
|
||||
self.widget.after_cancel(self.id_after)
|
||||
self.id_after = None
|
||||
|
||||
def _show_tooltip(self) -> None:
|
||||
"""Display the tooltip."""
|
||||
if self.tooltip:
|
||||
return
|
||||
|
||||
# Get widget position
|
||||
x = self.widget.winfo_rootx() + 25
|
||||
y = self.widget.winfo_rooty() + 25
|
||||
|
||||
# Create tooltip window
|
||||
self.tooltip = tk.Toplevel(self.widget)
|
||||
self.tooltip.wm_overrideredirect(True)
|
||||
self.tooltip.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
# Create tooltip content
|
||||
label = tk.Label(
|
||||
self.tooltip,
|
||||
text=self.text,
|
||||
justify="left",
|
||||
background="#ffffe0",
|
||||
foreground="#000000",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
font=("TkDefaultFont", "9", "normal"),
|
||||
wraplength=self.wrap_length,
|
||||
padx=8,
|
||||
pady=6,
|
||||
)
|
||||
label.pack()
|
||||
|
||||
# Make sure tooltip appears above other windows
|
||||
self.tooltip.lift()
|
||||
|
||||
def _hide_tooltip(self) -> None:
|
||||
"""Hide the tooltip."""
|
||||
if self.tooltip:
|
||||
self.tooltip.destroy()
|
||||
self.tooltip = None
|
||||
|
||||
def update_text(self, new_text: str) -> None:
|
||||
"""Update the tooltip text."""
|
||||
self.text = new_text
|
||||
|
||||
|
||||
class TooltipManager:
|
||||
"""Manages tooltips for UI elements."""
|
||||
|
||||
def __init__(self, theme_manager) -> None:
|
||||
self.theme_manager = theme_manager
|
||||
self.tooltips: list[ToolTip] = []
|
||||
|
||||
def add_tooltip(
|
||||
self,
|
||||
widget: tk.Widget,
|
||||
text: str,
|
||||
delay: int = 500,
|
||||
wrap_length: int = 250,
|
||||
) -> ToolTip:
|
||||
"""Add a tooltip to a widget."""
|
||||
tooltip = ToolTip(widget, text, delay, wrap_length)
|
||||
self.tooltips.append(tooltip)
|
||||
return tooltip
|
||||
|
||||
def add_scale_tooltip(self, scale_widget: tk.Widget, pathology_name: str) -> None:
|
||||
"""Add a specialized tooltip for pathology scales."""
|
||||
text = (
|
||||
f"Adjust your {pathology_name} level\n"
|
||||
"• Drag the slider to set your current level\n"
|
||||
"• Higher values typically indicate worse symptoms\n"
|
||||
"• Use the full range for accurate tracking"
|
||||
)
|
||||
self.add_tooltip(scale_widget, text, delay=800)
|
||||
|
||||
def add_medicine_tooltip(self, widget: tk.Widget, medicine_name: str) -> None:
|
||||
"""Add a specialized tooltip for medicine checkboxes."""
|
||||
text = (
|
||||
f"Mark if you took {medicine_name} today\n"
|
||||
"• Check the box when you've taken this medication\n"
|
||||
"• This helps track your medication adherence\n"
|
||||
"• You can add dose details when editing entries"
|
||||
)
|
||||
self.add_tooltip(widget, text, delay=600)
|
||||
|
||||
def add_button_tooltip(self, widget: tk.Widget, action: str) -> None:
|
||||
"""Add a tooltip for action buttons."""
|
||||
tooltips_map = {
|
||||
"save": (
|
||||
"Save your current entry (Ctrl+S)\nThis will add a new daily record"
|
||||
),
|
||||
"export": (
|
||||
"Export your data to various formats\n"
|
||||
"Supports CSV, PDF, and image exports"
|
||||
),
|
||||
"refresh": (
|
||||
"Reload data from file (F5)\nUpdates the display with latest changes"
|
||||
),
|
||||
"settings": (
|
||||
"Open application settings (F2)\nCustomize themes and preferences"
|
||||
),
|
||||
"quit": (
|
||||
"Exit the application (Ctrl+Q)\nYour data will be automatically saved"
|
||||
),
|
||||
}
|
||||
|
||||
text = tooltips_map.get(action, f"Perform {action} action")
|
||||
self.add_tooltip(widget, text, delay=400)
|
||||
|
||||
def add_menu_tooltip(self, widget: tk.Widget, menu_type: str) -> None:
|
||||
"""Add tooltips for menu items."""
|
||||
tooltips_map = {
|
||||
"theme": (
|
||||
"Quick theme selection\nClick to instantly change the app's appearance"
|
||||
),
|
||||
"file": "File operations\nExport data and manage files",
|
||||
"tools": ("Data management tools\nConfigure medicines and pathologies"),
|
||||
"help": ("Get help and information\nKeyboard shortcuts and about dialog"),
|
||||
}
|
||||
|
||||
text = tooltips_map.get(menu_type, "Menu options")
|
||||
self.add_tooltip(widget, text, delay=600)
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Canonical UI Manager for TheChart.
|
||||
|
||||
Responsible for creating and managing UI widgets and interactions.
|
||||
|
||||
Notes:
|
||||
- Migrated from legacy src/ui_manager.py.
|
||||
- Imports now use canonical thechart.* packages.
|
||||
- Public API and behavior remain compatible with the existing UI/data model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from tkinter import messagebox, ttk
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from thechart.managers import MedicineManager, PathologyManager
|
||||
from thechart.ui.tooltip_system import TooltipManager
|
||||
|
||||
|
||||
class UIManager:
|
||||
"""Handle UI creation and management for the application.
|
||||
|
||||
Other dependencies are optional and have lightweight fallbacks so
|
||||
widget construction still works without full managers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
logger: logging.Logger,
|
||||
medicine_manager: MedicineManager | None = None,
|
||||
pathology_manager: PathologyManager | None = None,
|
||||
theme_manager: Any | None = None,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.logger = logger
|
||||
|
||||
# Provide lightweight fallback managers if not provided
|
||||
class _FallbackMedicineMgr:
|
||||
def get_medicine_keys(self):
|
||||
return [
|
||||
"bupropion",
|
||||
"hydroxyzine",
|
||||
"gabapentin",
|
||||
"propranolol",
|
||||
"quetiapine",
|
||||
]
|
||||
|
||||
def get_medicine(self, key):
|
||||
class M:
|
||||
def __init__(self, k):
|
||||
self.key = k
|
||||
self.display_name = k.capitalize()
|
||||
self.dosage_info = ""
|
||||
self.color = "#CCCCCC"
|
||||
|
||||
return M(key)
|
||||
|
||||
def get_all_medicines(self):
|
||||
return {k: self.get_medicine(k) for k in self.get_medicine_keys()}
|
||||
|
||||
def get_quick_doses(self, _key):
|
||||
return []
|
||||
|
||||
class _FallbackPathologyMgr:
|
||||
def get_pathology_keys(self):
|
||||
return ["depression", "anxiety", "sleep", "appetite"]
|
||||
|
||||
def get_pathology(self, key):
|
||||
class P:
|
||||
def __init__(self, k):
|
||||
self.key = k
|
||||
self.display_name = k.capitalize()
|
||||
self.scale_info = "0-10"
|
||||
self.scale_min = 0
|
||||
self.scale_max = 10
|
||||
self.scale_orientation = (
|
||||
"inverted" if k in ("sleep", "appetite") else "normal"
|
||||
)
|
||||
|
||||
return P(key)
|
||||
|
||||
def get_all_pathologies(self):
|
||||
return {k: self.get_pathology(k) for k in self.get_pathology_keys()}
|
||||
|
||||
class _FallbackThemeMgr:
|
||||
def get_theme_colors(self):
|
||||
return {
|
||||
"bg": "#FFFFFF",
|
||||
"alt_bg": "#F5F5F5",
|
||||
"select_bg": "#2E86AB",
|
||||
"select_fg": "#FFFFFF",
|
||||
"fg": "#000000",
|
||||
}
|
||||
|
||||
self.medicine_manager = medicine_manager or _FallbackMedicineMgr()
|
||||
self.pathology_manager = pathology_manager or _FallbackPathologyMgr()
|
||||
self.theme_manager = theme_manager or _FallbackThemeMgr()
|
||||
|
||||
self.status_bar: tk.Frame | None = None
|
||||
self.status_label: tk.Label | None = None
|
||||
self.file_info_label: tk.Label | None = None
|
||||
self.last_backup_label: tk.Label | None = None
|
||||
|
||||
self.tooltip_manager = TooltipManager(self.theme_manager)
|
||||
|
||||
def setup_application_icon(self, img_path: str) -> bool:
|
||||
try:
|
||||
self.logger.info(f"Trying to load icon from: {img_path}")
|
||||
if not os.path.exists(img_path) and hasattr(sys, "_MEIPASS"):
|
||||
base_path: str = sys._MEIPASS # type: ignore[attr-defined]
|
||||
potential_paths: list[str] = [
|
||||
os.path.join(base_path, os.path.basename(img_path)),
|
||||
os.path.join(base_path, "chart-671.png"),
|
||||
]
|
||||
for path in potential_paths:
|
||||
if os.path.exists(path):
|
||||
self.logger.info(f"Found icon in PyInstaller bundle: {path}")
|
||||
img_path = path
|
||||
break
|
||||
|
||||
icon_image = Image.open(img_path)
|
||||
icon_image = icon_image.resize(
|
||||
size=(32, 32), resample=Image.Resampling.NEAREST
|
||||
)
|
||||
icon_photo = ImageTk.PhotoImage(image=icon_image)
|
||||
self.root.iconphoto(True, icon_photo)
|
||||
self.root.wm_iconphoto(True, icon_photo)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
self.logger.warning(f"Icon file not found at {img_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting icon: {str(e)}")
|
||||
return False
|
||||
|
||||
def create_input_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]:
|
||||
main_container = ttk.LabelFrame(
|
||||
parent_frame, text="New Entry", style="Card.TLabelframe"
|
||||
)
|
||||
main_container.grid(row=2, column=0, padx=10, pady=10, sticky="nsew")
|
||||
main_container.grid_rowconfigure(0, weight=1)
|
||||
main_container.grid_columnconfigure(0, weight=1)
|
||||
|
||||
theme_colors = self.theme_manager.get_theme_colors()
|
||||
canvas = tk.Canvas(main_container, highlightthickness=0, bg=theme_colors["bg"])
|
||||
scrollbar = ttk.Scrollbar(
|
||||
main_container, orient="vertical", command=canvas.yview
|
||||
)
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
input_frame = ttk.Frame(canvas)
|
||||
input_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
canvas.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
canvas_window = canvas.create_window((0, 0), window=input_frame, anchor="nw")
|
||||
|
||||
def configure_canvas_width(_event=None):
|
||||
canvas_width = canvas.winfo_width()
|
||||
canvas.itemconfig(canvas_window, width=canvas_width)
|
||||
|
||||
def configure_scroll_region(_event=None):
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
|
||||
def on_mousewheel(event):
|
||||
if canvas.cget("scrollregion"):
|
||||
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
|
||||
def on_mousewheel_linux_up(_event):
|
||||
if canvas.cget("scrollregion"):
|
||||
canvas.yview_scroll(-1, "units")
|
||||
|
||||
def on_mousewheel_linux_down(_event):
|
||||
if canvas.cget("scrollregion"):
|
||||
canvas.yview_scroll(1, "units")
|
||||
|
||||
input_frame.bind("<Configure>", configure_scroll_region)
|
||||
canvas.bind("<Configure>", configure_canvas_width)
|
||||
canvas.bind("<MouseWheel>", on_mousewheel)
|
||||
canvas.bind("<Button-4>", on_mousewheel_linux_up)
|
||||
canvas.bind("<Button-5>", on_mousewheel_linux_down)
|
||||
main_container.bind("<MouseWheel>", on_mousewheel)
|
||||
main_container.bind("<Button-4>", on_mousewheel_linux_up)
|
||||
main_container.bind("<Button-5>", on_mousewheel_linux_down)
|
||||
|
||||
self._bind_mousewheel_to_widget_tree(input_frame, canvas)
|
||||
canvas.focus_set()
|
||||
|
||||
def on_mouse_enter(_event):
|
||||
canvas.focus_set()
|
||||
|
||||
main_container.bind("<Enter>", on_mouse_enter)
|
||||
canvas.bind("<Enter>", on_mouse_enter)
|
||||
|
||||
pathology_vars: dict[str, tk.IntVar] = {}
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
pathology_vars[pathology_key] = tk.IntVar(value=0)
|
||||
|
||||
pathology_configs = []
|
||||
for pathology in self.pathology_manager.get_all_pathologies().values():
|
||||
pathology_configs.append((pathology.display_name, pathology.key))
|
||||
|
||||
input_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
for idx, (label, var_name) in enumerate(pathology_configs):
|
||||
self._create_enhanced_pathology_scale(
|
||||
input_frame, idx, label, var_name, 0, pathology_vars
|
||||
)
|
||||
|
||||
medicine_row = len(pathology_configs)
|
||||
ttk.Label(input_frame, text="Treatment:").grid(
|
||||
row=medicine_row, column=0, sticky="w", padx=5, pady=2
|
||||
)
|
||||
medicine_frame = ttk.LabelFrame(
|
||||
input_frame, text="Medicine", style="Card.TLabelframe"
|
||||
)
|
||||
medicine_frame.grid(row=medicine_row, column=1, padx=0, pady=10, sticky="nsew")
|
||||
medicine_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
medicine_vars: dict[str, tuple[tk.IntVar, str]] = {}
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
if medicine:
|
||||
var = tk.IntVar(value=0)
|
||||
text = f"{medicine.display_name} {medicine.dosage_info}"
|
||||
medicine_vars[medicine_key] = (var, text)
|
||||
|
||||
for idx, (med_key, (var, text)) in enumerate(medicine_vars.items()):
|
||||
checkbox = ttk.Checkbutton(
|
||||
medicine_frame,
|
||||
text=text,
|
||||
variable=var,
|
||||
style="Modern.TCheckbutton",
|
||||
)
|
||||
checkbox.grid(row=idx, column=0, sticky="w", padx=5, pady=2)
|
||||
medicine = self.medicine_manager.get_medicine(med_key)
|
||||
if medicine:
|
||||
self.tooltip_manager.add_medicine_tooltip(
|
||||
checkbox, medicine.display_name
|
||||
)
|
||||
|
||||
note_row = medicine_row + 1
|
||||
date_row = medicine_row + 2
|
||||
|
||||
note_var: tk.StringVar = tk.StringVar()
|
||||
date_var: tk.StringVar = tk.StringVar()
|
||||
|
||||
ttk.Label(input_frame, text="Note:").grid(
|
||||
row=note_row, column=0, sticky="w", padx=5, pady=2
|
||||
)
|
||||
ttk.Entry(input_frame, textvariable=note_var, style="Modern.TEntry").grid(
|
||||
row=note_row, column=1, sticky="ew", padx=5, pady=2
|
||||
)
|
||||
|
||||
ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid(
|
||||
row=date_row, column=0, sticky="w", padx=5, pady=2
|
||||
)
|
||||
ttk.Entry(
|
||||
input_frame,
|
||||
textvariable=date_var,
|
||||
justify="center",
|
||||
style="Modern.TEntry",
|
||||
).grid(row=date_row, column=1, sticky="ew", padx=5, pady=2)
|
||||
|
||||
date_var.set(datetime.now().strftime("%m/%d/%Y"))
|
||||
|
||||
main_container.update_idletasks()
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
self._bind_mousewheel_to_widget_tree(input_frame, canvas)
|
||||
|
||||
return {
|
||||
"frame": main_container,
|
||||
"pathology_vars": pathology_vars,
|
||||
"symptom_vars": pathology_vars,
|
||||
"medicine_vars": medicine_vars,
|
||||
"note_var": note_var,
|
||||
"date_var": date_var,
|
||||
}
|
||||
|
||||
def _bind_mousewheel_to_widget_tree(
|
||||
self, root_widget: tk.Widget, canvas: tk.Canvas
|
||||
) -> None:
|
||||
widgets = [root_widget]
|
||||
widgets.extend(root_widget.winfo_children())
|
||||
for w in widgets:
|
||||
try:
|
||||
w.bind(
|
||||
"<MouseWheel>",
|
||||
lambda e: canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _create_enhanced_pathology_scale(
|
||||
self,
|
||||
parent: ttk.Frame,
|
||||
row: int,
|
||||
label: str,
|
||||
var_name: str,
|
||||
default: int,
|
||||
pathology_vars: dict[str, tk.IntVar],
|
||||
) -> None:
|
||||
ttk.Label(parent, text=label + ":").grid(row=row, column=0, sticky="w", padx=5)
|
||||
_ = pathology_vars[var_name]
|
||||
scale = ttk.Scale(parent, from_=0, to=10, orient=tk.HORIZONTAL)
|
||||
scale.grid(row=row, column=1, sticky="ew", padx=5)
|
||||
with suppress(Exception):
|
||||
scale.set(default)
|
||||
|
||||
def create_table_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]:
|
||||
table_frame: ttk.LabelFrame = ttk.LabelFrame(
|
||||
parent_frame, text="Log (Double-click to edit)", style="Card.TLabelframe"
|
||||
)
|
||||
table_frame.grid(row=2, column=1, padx=10, pady=10, sticky="nsew")
|
||||
table_frame.grid_rowconfigure(0, weight=1)
|
||||
table_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
columns: list[str] = ["Date"]
|
||||
col_settings: list[tuple[str, int, str]] = [("Date", 80, "center")]
|
||||
|
||||
for pathology_key in self.pathology_manager.get_pathology_keys():
|
||||
pathology = self.pathology_manager.get_pathology(pathology_key)
|
||||
if pathology:
|
||||
columns.append(pathology.display_name)
|
||||
col_settings.append((pathology.display_name, 80, "center"))
|
||||
|
||||
for medicine_key in self.medicine_manager.get_medicine_keys():
|
||||
medicine = self.medicine_manager.get_medicine(medicine_key)
|
||||
if medicine:
|
||||
columns.append(medicine.display_name)
|
||||
col_settings.append((medicine.display_name, 120, "center"))
|
||||
|
||||
columns.append("Note")
|
||||
col_settings.append(("Note", 300, "w"))
|
||||
|
||||
tree: ttk.Treeview = ttk.Treeview(
|
||||
table_frame, columns=columns, show="headings", style="Modern.Treeview"
|
||||
)
|
||||
tree.configure(selectmode="browse")
|
||||
|
||||
theme_colors = self.theme_manager.get_theme_colors()
|
||||
tree.tag_configure("evenrow", background=theme_colors["bg"])
|
||||
tree.tag_configure("oddrow", background=theme_colors["alt_bg"])
|
||||
tree.tag_configure(
|
||||
"selected",
|
||||
background=theme_colors["select_bg"],
|
||||
foreground=theme_colors["select_fg"],
|
||||
)
|
||||
|
||||
def on_selection_change(_event):
|
||||
selection = tree.selection()
|
||||
if selection:
|
||||
tree.focus(selection[0])
|
||||
|
||||
tree.bind("<<TreeviewSelect>>", on_selection_change)
|
||||
|
||||
self._tree_sort_directions: dict[str, bool] = {}
|
||||
self._last_sorted_column: str | None = None
|
||||
self._last_sorted_ascending: bool | None = None
|
||||
|
||||
def make_sort_callback(col_name: str):
|
||||
def _callback():
|
||||
ascending = self._tree_sort_directions.get(col_name, True)
|
||||
self._sort_treeview(tree, col_name, ascending)
|
||||
self._tree_sort_directions[col_name] = not ascending
|
||||
|
||||
return _callback
|
||||
|
||||
tree.grid(row=0, column=0, sticky="nsew")
|
||||
y_scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
|
||||
y_scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
tree.configure(yscrollcommand=y_scrollbar.set)
|
||||
|
||||
for label, width, anchor in col_settings:
|
||||
tree.heading(label, text=label, command=make_sort_callback(label))
|
||||
tree.column(label, width=width, anchor=anchor)
|
||||
|
||||
return {"frame": table_frame, "tree": tree, "columns": columns}
|
||||
|
||||
def _sort_treeview(self, tree: ttk.Treeview, column: str, ascending: bool) -> None:
|
||||
try:
|
||||
items = list(tree.get_children(""))
|
||||
data_items = []
|
||||
for item in items:
|
||||
values = tree.item(item, "values")
|
||||
try:
|
||||
key = values[tree["columns"].index(column)]
|
||||
except Exception:
|
||||
key = ""
|
||||
data_items.append((key, item))
|
||||
data_items.sort(key=lambda x: x[0], reverse=not ascending)
|
||||
for index, (_key, iid) in enumerate(data_items):
|
||||
tree.move(iid, "", index)
|
||||
# Track last sort info and normalize stripes
|
||||
self._last_sorted_column = column
|
||||
self._last_sorted_ascending = ascending
|
||||
self.normalize_tree_stripes(tree)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_status(self, message: str, level: str = "info") -> None:
|
||||
if not self.status_bar:
|
||||
return
|
||||
with suppress(Exception):
|
||||
if self.status_label:
|
||||
self.status_label.config(text=message)
|
||||
if level == "error":
|
||||
with suppress(Exception):
|
||||
messagebox.showerror("Error", message)
|
||||
|
||||
def update_last_backup(self, when: str) -> None:
|
||||
if self.last_backup_label:
|
||||
with suppress(Exception):
|
||||
self.last_backup_label.config(text=f"Last backup: {when}")
|
||||
|
||||
# --- Newly added methods to match main.py expectations ---
|
||||
def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame:
|
||||
graph_frame: ttk.LabelFrame = ttk.LabelFrame(
|
||||
parent_frame, text="Evolution", style="Card.TLabelframe"
|
||||
)
|
||||
graph_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=10, sticky="nsew")
|
||||
return graph_frame
|
||||
|
||||
def add_action_buttons(
|
||||
self, frame: ttk.Frame, buttons_config: list[dict[str, Any]]
|
||||
) -> ttk.Frame:
|
||||
button_frame: ttk.Frame = ttk.Frame(frame)
|
||||
button_frame.grid(row=7, column=0, columnspan=2, pady=10)
|
||||
for btn in buttons_config:
|
||||
btn_widget = ttk.Button(
|
||||
button_frame,
|
||||
text=btn.get("text", "Button"),
|
||||
command=btn.get("command"),
|
||||
style="Action.TButton",
|
||||
)
|
||||
btn_widget.pack(
|
||||
side="left",
|
||||
padx=5,
|
||||
fill=btn.get("fill"),
|
||||
expand=bool(btn.get("expand", False)),
|
||||
)
|
||||
return button_frame
|
||||
|
||||
# Back-compat alias
|
||||
def add_buttons(
|
||||
self, frame: ttk.Frame, buttons_config: list[dict[str, Any]]
|
||||
): # pragma: no cover - delegate
|
||||
return self.add_action_buttons(frame, buttons_config)
|
||||
|
||||
def create_status_bar(self, parent_frame: tk.Widget) -> tk.Frame:
|
||||
colors = self.theme_manager.get_theme_colors()
|
||||
self.status_bar = tk.Frame(
|
||||
parent_frame, relief=tk.SUNKEN, bd=1, bg=colors["bg"]
|
||||
)
|
||||
self.status_bar.grid(row=3, column=0, columnspan=2, sticky="ew", padx=5, pady=2)
|
||||
|
||||
parent_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.status_label = tk.Label(
|
||||
self.status_bar,
|
||||
text="Ready",
|
||||
anchor=tk.W,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=10,
|
||||
pady=2,
|
||||
bg=colors["bg"],
|
||||
fg=colors["fg"],
|
||||
)
|
||||
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
|
||||
self.file_info_label = tk.Label(
|
||||
self.status_bar,
|
||||
text="",
|
||||
anchor=tk.E,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=10,
|
||||
pady=2,
|
||||
bg=colors["bg"],
|
||||
fg=colors["fg"],
|
||||
)
|
||||
self.file_info_label.pack(side=tk.RIGHT)
|
||||
|
||||
self.last_backup_label = tk.Label(
|
||||
self.status_bar,
|
||||
text="Last backup: —",
|
||||
anchor=tk.E,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=10,
|
||||
pady=2,
|
||||
bg=colors["bg"],
|
||||
fg=colors["fg"],
|
||||
)
|
||||
self.last_backup_label.pack(side=tk.RIGHT)
|
||||
|
||||
self.filter_hint_label = tk.Label(
|
||||
self.status_bar,
|
||||
text="",
|
||||
anchor=tk.E,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=8,
|
||||
pady=2,
|
||||
bg=colors["bg"],
|
||||
fg="#6c757d",
|
||||
)
|
||||
self.filter_hint_label.pack(side=tk.RIGHT)
|
||||
|
||||
return self.status_bar
|
||||
|
||||
def update_file_info(
|
||||
self, filename: str, entry_count: int = 0, filter_status: str | None = None
|
||||
) -> None:
|
||||
if not self.file_info_label:
|
||||
return
|
||||
file_display = os.path.basename(filename) if filename else "No file"
|
||||
info = f"{file_display}"
|
||||
if entry_count:
|
||||
info += f" ({entry_count} entries"
|
||||
if filter_status:
|
||||
info += f", {filter_status}"
|
||||
info += ")"
|
||||
self.file_info_label.config(text=info)
|
||||
|
||||
def show_toast(self, message: str, duration_ms: int = 3000) -> None:
|
||||
try:
|
||||
toast = tk.Toplevel(self.root)
|
||||
toast.overrideredirect(True)
|
||||
toast.attributes("-topmost", True)
|
||||
|
||||
colors = self.theme_manager.get_theme_colors()
|
||||
bg = colors.get("alt_bg", "#333333")
|
||||
fg = colors.get("fg", "#000000")
|
||||
|
||||
frame = tk.Frame(toast, bg=bg, bd=1, relief=tk.SOLID)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
label = tk.Label(
|
||||
frame,
|
||||
text=message,
|
||||
bg=bg,
|
||||
fg=fg,
|
||||
padx=12,
|
||||
pady=8,
|
||||
font=("TkDefaultFont", 9),
|
||||
)
|
||||
label.pack()
|
||||
|
||||
self.root.update_idletasks()
|
||||
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
|
||||
rw, rh = self.root.winfo_width(), self.root.winfo_height()
|
||||
toast.update_idletasks()
|
||||
tw = toast.winfo_width() or 240
|
||||
th = toast.winfo_height() or 48
|
||||
x = rx + rw - tw - 20
|
||||
y = ry + rh - th - 20
|
||||
toast.geometry(f"{tw}x{th}+{max(0, x)}+{max(0, y)}")
|
||||
toast.after(duration_ms, toast.destroy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_filter_hint(self, active: bool, text: str | None = None) -> None:
|
||||
if not getattr(self, "filter_hint_label", None):
|
||||
return
|
||||
self.filter_hint_label.config(text=(text or "Filters active") if active else "")
|
||||
|
||||
def normalize_tree_stripes(self, tree: ttk.Treeview) -> None:
|
||||
try:
|
||||
for idx, item in enumerate(tree.get_children("")):
|
||||
tag = "evenrow" if idx % 2 == 0 else "oddrow"
|
||||
tree.item(item, tags=(tag,))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def reapply_last_sort(self, tree: ttk.Treeview) -> None:
|
||||
try:
|
||||
if (
|
||||
getattr(self, "_last_sorted_column", None) is None
|
||||
or getattr(self, "_last_sorted_ascending", None) is None
|
||||
):
|
||||
return
|
||||
self._sort_treeview(
|
||||
tree, self._last_sorted_column, bool(self._last_sorted_ascending)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_edit_window(
|
||||
self, values: tuple[str, ...], callbacks: dict[str, Callable]
|
||||
) -> tk.Toplevel:
|
||||
"""Minimal edit window allowing date and note changes.
|
||||
|
||||
This simplified version passes missing pathology/medicine values as zeros
|
||||
and an empty dose mapping to the caller's save callback for compatibility.
|
||||
"""
|
||||
win = tk.Toplevel(master=self.root)
|
||||
win.title("Edit Entry")
|
||||
win.transient(self.root)
|
||||
win.minsize(400, 240)
|
||||
|
||||
container = ttk.Frame(win, padding=12)
|
||||
container.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
ttk.Label(container, text="Date (mm/dd/yyyy):").grid(
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
date_var = tk.StringVar(value=values[0] if values else "")
|
||||
ttk.Entry(
|
||||
container, textvariable=date_var, justify="center", style="Modern.TEntry"
|
||||
).grid(row=0, column=1, sticky="ew", padx=8, pady=4)
|
||||
|
||||
ttk.Label(container, text="Note:").grid(row=1, column=0, sticky="w")
|
||||
note_val = values[-1] if values else ""
|
||||
note_var = tk.StringVar(value=str(note_val))
|
||||
ttk.Entry(container, textvariable=note_var, style="Modern.TEntry").grid(
|
||||
row=1, column=1, sticky="ew", padx=8, pady=4
|
||||
)
|
||||
|
||||
container.grid_columnconfigure(1, weight=1)
|
||||
|
||||
buttons = ttk.Frame(container)
|
||||
buttons.grid(row=2, column=0, columnspan=2, pady=10)
|
||||
|
||||
def _on_save():
|
||||
# Only provide date and note; caller will default others.
|
||||
with suppress(Exception):
|
||||
callbacks.get("save")(win, date_var.get(), note_var.get(), {})
|
||||
|
||||
def _on_delete():
|
||||
with suppress(Exception):
|
||||
callbacks.get("delete")(win)
|
||||
|
||||
ttk.Button(buttons, text="Save", command=_on_save, style="Action.TButton").pack(
|
||||
side="left", padx=5
|
||||
)
|
||||
ttk.Button(buttons, text="Delete", command=_on_delete).pack(side="left", padx=5)
|
||||
|
||||
return win
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Validation utilities public API for the thechart package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .input_validator import InputValidator # re-export
|
||||
|
||||
__all__ = ["InputValidator"]
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Input validation utilities for TheChart application.
|
||||
|
||||
This is the canonical implementation, migrated under the thechart package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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]]:
|
||||
"""
|
||||
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
|
||||
+7
-441
@@ -1,445 +1,11 @@
|
||||
"""Theme manager for the application using ttkthemes."""
|
||||
"""Legacy shim for ThemeManager.
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
This preserves backward compatibility for imports like:
|
||||
from theme_manager import ThemeManager
|
||||
|
||||
from ttkthemes import ThemedStyle
|
||||
Canonical implementation lives in: thechart.ui.theme_manager
|
||||
"""
|
||||
|
||||
from thechart.ui.theme_manager import ThemeManager # noqa: F401
|
||||
|
||||
class ThemeManager:
|
||||
"""Manages application themes and styling."""
|
||||
|
||||
def __init__(self, root: tk.Tk, logger: logging.Logger) -> None:
|
||||
self.root = root
|
||||
self.logger = logger
|
||||
self.style: ThemedStyle | None = None
|
||||
self.current_theme: str = "arc" # Default theme
|
||||
|
||||
# Available themes - these are some of the best looking ones
|
||||
self.available_themes = [
|
||||
"arc",
|
||||
"equilux",
|
||||
"adapta",
|
||||
"yaru",
|
||||
"ubuntu",
|
||||
"plastik",
|
||||
"breeze",
|
||||
"elegance",
|
||||
]
|
||||
|
||||
self.initialize_theme()
|
||||
|
||||
def initialize_theme(self) -> None:
|
||||
"""Initialize the themed style."""
|
||||
try:
|
||||
self.style = ThemedStyle(self.root)
|
||||
self.apply_theme(self.current_theme)
|
||||
self._configure_custom_styles()
|
||||
self.logger.info(
|
||||
f"Theme manager initialized with theme: {self.current_theme}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to initialize theme manager: {e}")
|
||||
# Fallback to default ttk styling
|
||||
self.style = ttk.Style()
|
||||
|
||||
def apply_theme(self, theme_name: str) -> bool:
|
||||
"""Apply a specific theme."""
|
||||
try:
|
||||
if self.style and theme_name in self.get_available_themes():
|
||||
self.style.set_theme(theme_name)
|
||||
self.current_theme = theme_name
|
||||
self._configure_custom_styles()
|
||||
self.logger.info(f"Applied theme: {theme_name}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"Theme '{theme_name}' not available")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to apply theme '{theme_name}': {e}")
|
||||
return False
|
||||
|
||||
def get_available_themes(self) -> list[str]:
|
||||
"""Get list of available themes."""
|
||||
if self.style:
|
||||
try:
|
||||
# Get all available themes from ttkthemes
|
||||
all_themes = self.style.theme_names()
|
||||
# Filter to only include our curated list
|
||||
return [theme for theme in self.available_themes if theme in all_themes]
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get available themes: {e}")
|
||||
return self.available_themes
|
||||
return self.available_themes
|
||||
|
||||
def get_current_theme(self) -> str:
|
||||
"""Get the currently active theme."""
|
||||
return self.current_theme
|
||||
|
||||
def _get_contrasting_colors(self, colors: dict[str, str]) -> dict[str, str]:
|
||||
"""Get contrasting colors for headers with improved visibility."""
|
||||
|
||||
def get_luminance(color_str: str) -> float:
|
||||
"""Calculate relative luminance of a color."""
|
||||
if not color_str or not color_str.startswith("#"):
|
||||
return 0.5
|
||||
try:
|
||||
rgb = tuple(int(color_str[i : i + 2], 16) for i in (1, 3, 5))
|
||||
# Calculate relative luminance
|
||||
return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
|
||||
except (ValueError, IndexError):
|
||||
return 0.5
|
||||
|
||||
def get_contrast_ratio(bg: str, fg: str) -> float:
|
||||
"""Calculate contrast ratio between two colors."""
|
||||
bg_lum = get_luminance(bg)
|
||||
fg_lum = get_luminance(fg)
|
||||
lighter = max(bg_lum, fg_lum)
|
||||
darker = min(bg_lum, fg_lum)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
# Start with the provided select colors
|
||||
header_bg = colors["select_bg"]
|
||||
header_fg = colors["select_fg"]
|
||||
|
||||
# Calculate contrast ratio
|
||||
contrast = get_contrast_ratio(header_bg, header_fg)
|
||||
|
||||
# If contrast is poor (less than 3:1), use high-contrast alternatives
|
||||
if contrast < 3.0:
|
||||
bg_luminance = get_luminance(colors["bg"])
|
||||
|
||||
if bg_luminance > 0.5: # Light theme
|
||||
header_bg = "#1e1e1e" # Very dark gray background for maximum contrast
|
||||
header_fg = "#ffffff" # Pure white for maximum contrast
|
||||
else: # Dark theme - use dark background with light text
|
||||
header_bg = "#1e1e1e" # Very dark gray for consistency
|
||||
header_fg = "#ffffff" # Pure white for maximum contrast
|
||||
|
||||
self.logger.debug(
|
||||
f"Poor header contrast ({contrast:.2f}), using fallback colors: "
|
||||
f"bg={header_bg}, fg={header_fg}"
|
||||
)
|
||||
|
||||
return {
|
||||
"header_bg": header_bg,
|
||||
"header_fg": header_fg,
|
||||
}
|
||||
|
||||
def _configure_custom_styles(self) -> None:
|
||||
"""Configure custom styles for better appearance."""
|
||||
if not self.style:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current theme colors for consistent styling
|
||||
colors = self.get_theme_colors()
|
||||
|
||||
# Get improved header colors with better contrast
|
||||
header_colors = self._get_contrasting_colors(colors)
|
||||
|
||||
# Configure frame styles with better padding and borders
|
||||
self.style.configure(
|
||||
"Card.TFrame",
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
background=colors["bg"],
|
||||
)
|
||||
|
||||
# Configure label frame styles with modern appearance
|
||||
self.style.configure(
|
||||
"Card.TLabelframe",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
padding=(10, 5, 10, 10),
|
||||
)
|
||||
|
||||
self.style.configure(
|
||||
"Card.TLabelframe.Label",
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
font=("TkDefaultFont", 10, "bold"),
|
||||
)
|
||||
|
||||
# Configure button styles for better appearance
|
||||
self.style.configure(
|
||||
"Action.TButton",
|
||||
padding=(15, 8),
|
||||
font=("TkDefaultFont", 9, "normal"),
|
||||
)
|
||||
|
||||
# Configure entry styles with modern look
|
||||
self.style.configure(
|
||||
"Modern.TEntry",
|
||||
padding=(8, 5),
|
||||
borderwidth=1,
|
||||
relief="solid",
|
||||
)
|
||||
|
||||
# Configure scale styles for pathology inputs
|
||||
self.style.configure(
|
||||
"Modern.Horizontal.TScale",
|
||||
borderwidth=0,
|
||||
background=colors["bg"],
|
||||
troughcolor="#e0e0e0",
|
||||
lightcolor=colors["select_bg"],
|
||||
darkcolor=colors["select_bg"],
|
||||
focuscolor=colors["select_bg"],
|
||||
)
|
||||
|
||||
# Configure treeview for better data display
|
||||
self.style.configure(
|
||||
"Modern.Treeview",
|
||||
rowheight=28,
|
||||
borderwidth=1,
|
||||
relief="solid",
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
fieldbackground=colors["bg"],
|
||||
selectbackground=colors["select_bg"],
|
||||
selectforeground=colors["select_fg"],
|
||||
)
|
||||
|
||||
self.style.configure(
|
||||
"Modern.Treeview.Heading",
|
||||
padding=(8, 6),
|
||||
relief="flat",
|
||||
borderwidth=1,
|
||||
background=header_colors["header_bg"],
|
||||
foreground=header_colors["header_fg"],
|
||||
font=("TkDefaultFont", 9, "bold"),
|
||||
)
|
||||
|
||||
# Ensure header style mapping to override theme defaults
|
||||
self.style.map(
|
||||
"Modern.Treeview.Heading",
|
||||
background=[
|
||||
("active", header_colors["header_bg"]),
|
||||
("pressed", header_colors["header_bg"]),
|
||||
("", header_colors["header_bg"]),
|
||||
],
|
||||
foreground=[
|
||||
("active", header_colors["header_fg"]),
|
||||
("pressed", header_colors["header_fg"]),
|
||||
("", header_colors["header_fg"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Configure comprehensive row selection colors for better visibility
|
||||
self.style.map(
|
||||
"Modern.Treeview",
|
||||
background=[
|
||||
("selected", colors["select_bg"]),
|
||||
("active", colors["select_bg"]),
|
||||
("focus", colors["select_bg"]),
|
||||
("", colors["bg"]),
|
||||
],
|
||||
foreground=[
|
||||
("selected", colors["select_fg"]),
|
||||
("active", colors["select_fg"]),
|
||||
("focus", colors["select_fg"]),
|
||||
("", colors["fg"]),
|
||||
],
|
||||
selectbackground=[
|
||||
("focus", colors["select_bg"]),
|
||||
("", colors["select_bg"]),
|
||||
],
|
||||
selectforeground=[
|
||||
("focus", colors["select_fg"]),
|
||||
("", colors["select_fg"]),
|
||||
],
|
||||
)
|
||||
|
||||
# Configure notebook tabs with modern styling
|
||||
self.style.configure(
|
||||
"Modern.TNotebook.Tab",
|
||||
padding=(15, 8),
|
||||
borderwidth=1,
|
||||
relief="flat",
|
||||
)
|
||||
|
||||
self.style.map(
|
||||
"Modern.TNotebook.Tab",
|
||||
background=[("selected", colors["select_bg"])],
|
||||
foreground=[("selected", colors["select_fg"])],
|
||||
)
|
||||
|
||||
# Configure checkbutton for medicine selection
|
||||
self.style.configure(
|
||||
"Modern.TCheckbutton",
|
||||
padding=(8, 4),
|
||||
background=colors["bg"],
|
||||
foreground=colors["fg"],
|
||||
focuscolor=colors["select_bg"],
|
||||
)
|
||||
|
||||
self.logger.debug("Enhanced custom styles configured")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure custom styles: {e}")
|
||||
|
||||
def get_menu_colors(self) -> dict[str, str]:
|
||||
"""Get colors specifically for menu theming."""
|
||||
colors = self.get_theme_colors()
|
||||
|
||||
# Use slightly different colors for menus to make them stand out
|
||||
try:
|
||||
# For menu background, use a slightly darker/lighter shade
|
||||
if colors["bg"].startswith("#"):
|
||||
rgb = tuple(int(colors["bg"][i : i + 2], 16) for i in (1, 3, 5))
|
||||
if sum(rgb) > 384: # Light theme - make menu slightly darker
|
||||
menu_bg = (
|
||||
f"#{max(0, rgb[0] - 8):02x}"
|
||||
f"{max(0, rgb[1] - 8):02x}"
|
||||
f"{max(0, rgb[2] - 8):02x}"
|
||||
)
|
||||
else: # Dark theme - make menu slightly lighter
|
||||
menu_bg = (
|
||||
f"#{min(255, rgb[0] + 15):02x}"
|
||||
f"{min(255, rgb[1] + 15):02x}"
|
||||
f"{min(255, rgb[2] + 15):02x}"
|
||||
)
|
||||
else:
|
||||
menu_bg = colors["bg"]
|
||||
except (ValueError, IndexError):
|
||||
menu_bg = colors["bg"]
|
||||
|
||||
return {
|
||||
"bg": menu_bg,
|
||||
"fg": colors["fg"],
|
||||
"active_bg": colors["select_bg"],
|
||||
"active_fg": colors["select_fg"],
|
||||
"disabled_fg": colors.get("disabled_fg", "#888888"),
|
||||
}
|
||||
|
||||
def configure_menu(self, menu: "tk.Menu") -> None:
|
||||
"""Apply theme colors to a menu widget."""
|
||||
try:
|
||||
menu_colors = self.get_menu_colors()
|
||||
|
||||
menu.configure(
|
||||
background=menu_colors["bg"],
|
||||
foreground=menu_colors["fg"],
|
||||
activebackground=menu_colors["active_bg"],
|
||||
activeforeground=menu_colors["active_fg"],
|
||||
disabledforeground=menu_colors["disabled_fg"],
|
||||
relief="flat",
|
||||
borderwidth=1,
|
||||
)
|
||||
|
||||
self.logger.debug(f"Applied theme to menu: {menu_colors}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure menu theme: {e}")
|
||||
|
||||
def create_themed_menu(self, parent: "tk.Widget", **kwargs) -> "tk.Menu":
|
||||
"""Create a new menu with theme colors already applied."""
|
||||
try:
|
||||
menu = tk.Menu(parent, **kwargs)
|
||||
self.configure_menu(menu)
|
||||
return menu
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create themed menu: {e}")
|
||||
# Fallback to a minimally constructed menu without theming
|
||||
try:
|
||||
return tk.Menu(parent)
|
||||
except Exception:
|
||||
# As a last resort, return a dummy object that quacks like a Menu
|
||||
class _DummyMenu:
|
||||
def __init__(self) -> None:
|
||||
self._options = {}
|
||||
|
||||
def __getitem__(self, key): # support menu['tearoff'] tests
|
||||
return self._options.get(key, 0)
|
||||
|
||||
def configure(self, **_kw):
|
||||
self._options.update(_kw)
|
||||
|
||||
return _DummyMenu()
|
||||
|
||||
def configure_widget_style(self, widget: tk.Widget, style_name: str) -> None:
|
||||
"""Apply a specific style to a widget."""
|
||||
try:
|
||||
if hasattr(widget, "configure") and self.style:
|
||||
widget.configure(style=style_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to configure widget style '{style_name}': {e}")
|
||||
|
||||
def get_theme_colors(self) -> dict[str, str]:
|
||||
"""Get current theme colors for custom widgets."""
|
||||
if not self.style:
|
||||
return {
|
||||
"bg": "#ffffff",
|
||||
"fg": "#000000",
|
||||
"select_bg": "#3584e4",
|
||||
"select_fg": "#ffffff",
|
||||
"alt_bg": "#f5f5f5",
|
||||
}
|
||||
|
||||
try:
|
||||
# Get colors from current theme and convert to strings
|
||||
bg = str(self.style.lookup("TFrame", "background") or "#ffffff")
|
||||
fg = str(self.style.lookup("TLabel", "foreground") or "#000000")
|
||||
|
||||
# Try to get better selection colors from different widget states
|
||||
select_bg = str(
|
||||
self.style.lookup("TButton", "background", ["pressed"])
|
||||
or self.style.lookup("TButton", "background", ["active"])
|
||||
or self.style.lookup("Treeview", "selectbackground")
|
||||
or "#0078d4" # Modern blue fallback
|
||||
)
|
||||
select_fg = str(
|
||||
self.style.lookup("TButton", "foreground", ["pressed"])
|
||||
or self.style.lookup("TButton", "foreground", ["active"])
|
||||
or self.style.lookup("Treeview", "selectforeground")
|
||||
or "#ffffff" # White fallback
|
||||
)
|
||||
|
||||
# Ensure contrast - if selection colors are too similar to background,
|
||||
# use fallbacks
|
||||
if select_bg == bg or select_bg.lower() == bg.lower():
|
||||
select_bg = "#0078d4" if bg != "#0078d4" else "#0066cc"
|
||||
|
||||
if select_fg == fg or select_fg.lower() == fg.lower():
|
||||
select_fg = "#ffffff" if fg != "#ffffff" else "#000000"
|
||||
|
||||
# Calculate alternating row color
|
||||
if bg.startswith("#"):
|
||||
try:
|
||||
rgb = tuple(int(bg[i : i + 2], 16) for i in (1, 3, 5))
|
||||
if sum(rgb) > 384: # Light theme
|
||||
alt_bg = (
|
||||
f"#{max(0, rgb[0] - 10):02x}"
|
||||
f"{max(0, rgb[1] - 10):02x}"
|
||||
f"{max(0, rgb[2] - 10):02x}"
|
||||
)
|
||||
else: # Dark theme
|
||||
alt_bg = (
|
||||
f"#{min(255, rgb[0] + 10):02x}"
|
||||
f"{min(255, rgb[1] + 10):02x}"
|
||||
f"{min(255, rgb[2] + 10):02x}"
|
||||
)
|
||||
except ValueError:
|
||||
alt_bg = "#f5f5f5"
|
||||
else:
|
||||
alt_bg = "#f5f5f5"
|
||||
|
||||
return {
|
||||
"bg": bg,
|
||||
"fg": fg,
|
||||
"select_bg": select_bg,
|
||||
"select_fg": select_fg,
|
||||
"alt_bg": alt_bg, # Add alternating background color
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get theme colors: {e}")
|
||||
return {
|
||||
"bg": "#ffffff",
|
||||
"fg": "#000000",
|
||||
"select_bg": "#3584e4",
|
||||
"select_fg": "#ffffff",
|
||||
"alt_bg": "#f5f5f5",
|
||||
}
|
||||
__all__ = ["ThemeManager"]
|
||||
|
||||
+7
-159
@@ -1,163 +1,11 @@
|
||||
"""Tooltip system for enhanced user experience."""
|
||||
"""Legacy shim for tooltip system.
|
||||
|
||||
import tkinter as tk
|
||||
This preserves backward compatibility for imports like:
|
||||
from tooltip_system import TooltipManager, ToolTip
|
||||
|
||||
Canonical implementation lives in: thechart.ui.tooltip_system
|
||||
"""
|
||||
|
||||
class ToolTip:
|
||||
"""Create a tooltip for a given widget."""
|
||||
from thechart.ui.tooltip_system import ToolTip, TooltipManager # noqa: F401
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: tk.Widget,
|
||||
text: str,
|
||||
delay: int = 500,
|
||||
wrap_length: int = 250,
|
||||
) -> None:
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay = delay
|
||||
self.wrap_length = wrap_length
|
||||
self.tooltip: tk.Toplevel | None = None
|
||||
self.id_after: str | None = None
|
||||
|
||||
# Bind events
|
||||
self.widget.bind("<Enter>", self._on_enter)
|
||||
self.widget.bind("<Leave>", self._on_leave)
|
||||
self.widget.bind("<ButtonPress>", self._on_leave)
|
||||
|
||||
def _on_enter(self, event: tk.Event | None = None) -> None:
|
||||
"""Mouse entered widget - schedule tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self.id_after = self.widget.after(self.delay, self._show_tooltip)
|
||||
|
||||
def _on_leave(self, event: tk.Event | None = None) -> None:
|
||||
"""Mouse left widget - hide tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
def _cancel_scheduled(self) -> None:
|
||||
"""Cancel any scheduled tooltip."""
|
||||
if self.id_after:
|
||||
self.widget.after_cancel(self.id_after)
|
||||
self.id_after = None
|
||||
|
||||
def _show_tooltip(self) -> None:
|
||||
"""Display the tooltip."""
|
||||
if self.tooltip:
|
||||
return
|
||||
|
||||
# Get widget position
|
||||
x = self.widget.winfo_rootx() + 25
|
||||
y = self.widget.winfo_rooty() + 25
|
||||
|
||||
# Create tooltip window
|
||||
self.tooltip = tk.Toplevel(self.widget)
|
||||
self.tooltip.wm_overrideredirect(True)
|
||||
self.tooltip.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
# Create tooltip content
|
||||
label = tk.Label(
|
||||
self.tooltip,
|
||||
text=self.text,
|
||||
justify="left",
|
||||
background="#ffffe0",
|
||||
foreground="#000000",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
font=("TkDefaultFont", "9", "normal"),
|
||||
wraplength=self.wrap_length,
|
||||
padx=8,
|
||||
pady=6,
|
||||
)
|
||||
label.pack()
|
||||
|
||||
# Make sure tooltip appears above other windows
|
||||
self.tooltip.lift()
|
||||
|
||||
def _hide_tooltip(self) -> None:
|
||||
"""Hide the tooltip."""
|
||||
if self.tooltip:
|
||||
self.tooltip.destroy()
|
||||
self.tooltip = None
|
||||
|
||||
def update_text(self, new_text: str) -> None:
|
||||
"""Update the tooltip text."""
|
||||
self.text = new_text
|
||||
|
||||
|
||||
class TooltipManager:
|
||||
"""Manages tooltips for UI elements."""
|
||||
|
||||
def __init__(self, theme_manager) -> None:
|
||||
self.theme_manager = theme_manager
|
||||
self.tooltips: list[ToolTip] = []
|
||||
|
||||
def add_tooltip(
|
||||
self,
|
||||
widget: tk.Widget,
|
||||
text: str,
|
||||
delay: int = 500,
|
||||
wrap_length: int = 250,
|
||||
) -> ToolTip:
|
||||
"""Add a tooltip to a widget."""
|
||||
tooltip = ToolTip(widget, text, delay, wrap_length)
|
||||
self.tooltips.append(tooltip)
|
||||
return tooltip
|
||||
|
||||
def add_scale_tooltip(self, scale_widget: tk.Widget, pathology_name: str) -> None:
|
||||
"""Add a specialized tooltip for pathology scales."""
|
||||
text = (
|
||||
f"Adjust your {pathology_name} level\\n"
|
||||
"• Drag the slider to set your current level\\n"
|
||||
"• Higher values typically indicate worse symptoms\\n"
|
||||
"• Use the full range for accurate tracking"
|
||||
)
|
||||
self.add_tooltip(scale_widget, text, delay=800)
|
||||
|
||||
def add_medicine_tooltip(self, widget: tk.Widget, medicine_name: str) -> None:
|
||||
"""Add a specialized tooltip for medicine checkboxes."""
|
||||
text = (
|
||||
f"Mark if you took {medicine_name} today\\n"
|
||||
"• Check the box when you've taken this medication\\n"
|
||||
"• This helps track your medication adherence\\n"
|
||||
"• You can add dose details when editing entries"
|
||||
)
|
||||
self.add_tooltip(widget, text, delay=600)
|
||||
|
||||
def add_button_tooltip(self, widget: tk.Widget, action: str) -> None:
|
||||
"""Add a tooltip for action buttons."""
|
||||
tooltips_map = {
|
||||
"save": (
|
||||
"Save your current entry (Ctrl+S)\\nThis will add a new daily record"
|
||||
),
|
||||
"export": (
|
||||
"Export your data to various formats\\n"
|
||||
"Supports CSV, PDF, and image exports"
|
||||
),
|
||||
"refresh": (
|
||||
"Reload data from file (F5)\\nUpdates the display with latest changes"
|
||||
),
|
||||
"settings": (
|
||||
"Open application settings (F2)\\nCustomize themes and preferences"
|
||||
),
|
||||
"quit": (
|
||||
"Exit the application (Ctrl+Q)\\nYour data will be automatically saved"
|
||||
),
|
||||
}
|
||||
|
||||
text = tooltips_map.get(action, f"Perform {action} action")
|
||||
self.add_tooltip(widget, text, delay=400)
|
||||
|
||||
def add_menu_tooltip(self, widget: tk.Widget, menu_type: str) -> None:
|
||||
"""Add tooltips for menu items."""
|
||||
tooltips_map = {
|
||||
"theme": (
|
||||
"Quick theme selection\\nClick to instantly change the app's appearance"
|
||||
),
|
||||
"file": "File operations\\nExport data and manage files",
|
||||
"tools": ("Data management tools\\nConfigure medicines and pathologies"),
|
||||
"help": ("Get help and information\\nKeyboard shortcuts and about dialog"),
|
||||
}
|
||||
|
||||
text = tooltips_map.get(menu_type, "Menu options")
|
||||
self.add_tooltip(widget, text, delay=600)
|
||||
__all__ = ["ToolTip", "TooltipManager"]
|
||||
|
||||
+3
-29
@@ -1,33 +1,7 @@
|
||||
"""Undo stack for add/update/delete operations."""
|
||||
"""Compatibility shim for undo utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from thechart.core.undo_manager import UndoAction, UndoManager # noqa: F401
|
||||
|
||||
|
||||
@dataclass
|
||||
class UndoAction:
|
||||
description: str
|
||||
undo_callable: Callable[[], None]
|
||||
|
||||
|
||||
class UndoManager:
|
||||
def __init__(self, capacity: int = 20) -> None:
|
||||
self.capacity = capacity
|
||||
self._stack: list[UndoAction] = []
|
||||
|
||||
def push(self, action: UndoAction) -> None:
|
||||
self._stack.append(action)
|
||||
if len(self._stack) > self.capacity:
|
||||
self._stack.pop(0)
|
||||
|
||||
def undo(self) -> str | None:
|
||||
if not self._stack:
|
||||
return None
|
||||
action = self._stack.pop()
|
||||
action.undo_callable()
|
||||
return action.description
|
||||
|
||||
def has_actions(self) -> bool:
|
||||
return bool(self._stack)
|
||||
__all__ = ["UndoAction", "UndoManager"]
|
||||
|
||||
Reference in New Issue
Block a user