Compare commits

..

15 Commits

Author SHA1 Message Date
William Valentin 439204326b feat: Enhance medicines section with dose preview and quick add functionality 2025-08-09 16:01:57 -07:00
William Valentin 1613fb2625 feat: Enhance create_edit_window method with dynamic fields and improved layout 2025-08-09 13:10:58 -07:00
William Valentin d0c9f55a10 feat: Update coding guidelines to include replacement of legacy code 2025-08-09 12:57:09 -07:00
William Valentin 06d8935d24 feat: Enhance logger configuration and add export info method to ExportManager 2025-08-09 12:47:49 -07:00
William Valentin 9a5a2f0022 Run ruff format changes and finalize indentation and lint fixes. 2025-08-09 12:10:16 -07:00
William Valentin 9cec07e9f6 feat: Add project directory structure guidelines for maintainability and scalability 2025-08-09 09:11:52 -07:00
William Valentin e42ff9e378 feat: Update coding guidelines to enhance modularity and avoid deprecated patterns 2025-08-09 09:03:23 -07:00
William Valentin 568e1e338e refactor: Improve compatibility shim for historical imports and enhance module loading 2025-08-08 21:49:07 -07:00
William Valentin ed34d5bfac feat: Enhance performance guidelines with multi-threading and code refactoring allowances 2025-08-08 21:42:09 -07:00
William Valentin ae4503145a 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.
2025-08-08 21:36:13 -07:00
William Valentin 7033052132 feat: Add a new task to run the Pytest suite in VSCode tasks configuration 2025-08-08 21:30:59 -07:00
William Valentin b27a39e4eb fix: Clarify integration instructions in project summary for new features 2025-08-08 21:11:30 -07:00
William Valentin eb12a486c8 fix: Remove redundant applyTo line and clarify terminal usage in coding guidelines 2025-08-08 20:22:50 -07:00
William Valentin 33d509389e feat: Add package proxy for main module and aggregate re-exports for managers 2025-08-08 18:38:00 -07:00
William Valentin bd598d63f9 feat: Add package structure for TheChart, implement entry points for console scripts and module execution 2025-08-08 18:33:51 -07:00
108 changed files with 8747 additions and 9430 deletions
@@ -86,3 +86,32 @@ applyTo: '**'
**Summary:** **Summary:**
This project is a modular, extensible Tkinter application for tracking medication and pathology data. Code should be clean, dynamic, user-friendly, and robust, following PEP8 and the architectural patterns already established. All new features or changes should integrate seamlessly with the existing managers and UI paradigms, unless instructed otherwise. This project is a modular, extensible Tkinter application for tracking medication and pathology data. Code should be clean, dynamic, user-friendly, and robust, following PEP8 and the architectural patterns already established. All new features or changes should integrate seamlessly with the existing managers and UI paradigms, unless instructed otherwise.
**Notes:**
A robust Python project directory structure is crucial for maintainability, scalability, and collaboration. Key best practices include:
Root Project Directory:
Create a top-level directory for your project, typically named after the project itself.
Source Code (src/ or my_package/):
Modern approach: Place all application source code within a src/ directory. This clearly separates source code from other project files.
Alternative: If your project is a single package, the main package directory (e.g., my_package/) can reside directly under the root, containing your modules and __init__.py.
Modularity: Break down your code into smaller, logical modules within this directory, each with a clear responsibility.
__init__.py: Include an __init__.py file in every directory intended to be a Python package, marking it as importable.
Tests (tests/):
Create a dedicated tests/ directory at the root level to house all your test files.
Structure tests to mirror the application's module structure for easier navigation and understanding.
Documentation (docs/):
Include a docs/ directory for project documentation, including usage guides, API references, and design documents.
Configuration (config/ or pyproject.toml):
Use pyproject.toml for modern project configuration, including project metadata, dependencies, and tool configurations (linters, formatters, test runners).
For application-specific or environment-dependent configurations, consider a config/ directory or environment variables.
Entry Point (main.py or cli.py):
Designate a clear entry point for your application, often main.py or cli.py for command-line interfaces. This file should primarily orchestrate the application's flow and delegate logic to other modules.
Other Important Files:
README.md: A comprehensive README at the root level providing project overview, installation instructions, and usage examples.
LICENSE: A license file specifying the terms of use and distribution.
.gitignore: For version control, specifying files and directories to be ignored by Git (e.g., virtual environments, compiled files, sensitive data).
requirements.txt: (or managed via pyproject.toml): Lists project dependencies.
Virtual Environments:
Utilize virtual environments (e.g., venv, conda) to isolate project dependencies and avoid conflicts. The virtual environment directory (e.g., .venv/) should be ignored by version control.
-1
View File
@@ -1 +0,0 @@
# placeholder
+12
View File
@@ -45,6 +45,18 @@
"$tsc" "$tsc"
], ],
"group": "build" "group": "build"
},
{
"label": "Run Pytest Suite",
"type": "shell",
"command": "/home/will/Code/thechart/.venv/bin/python",
"args": [
"-m",
"pytest",
"-q"
],
"isBackground": false,
"group": "test"
} }
] ]
} }
+5 -5
View File
@@ -55,19 +55,19 @@ The export functionality is accessible through:
The export system consists of three main components: The export system consists of three main components:
##### ExportManager Class (`src/export_manager.py`) ##### ExportManager Class (`thechart.export.export_manager`)
- Core export functionality - Core export functionality
- Handles data transformation and file generation - Handles data transformation and file generation
- Integrates with existing data and graph managers - Integrates with existing data and graph managers
- Supports all three export formats - Supports all three export formats
##### ExportWindow Class (`src/export_window.py`) ##### ExportWindow Class (`thechart.ui.export_window`)
- GUI interface for export operations - GUI interface for export operations
- Modal dialog with export options - Modal dialog with export options
- File save dialog integration - File save dialog integration
- Progress feedback and error handling - Progress feedback and error handling
##### Integration in MedTrackerApp (`src/main.py`) ##### Integration in MedTrackerApp (`python -m thechart` entry)
- Export manager initialization - Export manager initialization
- Menu integration - Menu integration
- Seamless integration with existing managers - Seamless integration with existing managers
@@ -179,8 +179,8 @@ Exported test files are created in the `test_exports/` directory:
### File Locations ### File Locations
#### Source Files #### Source Files
- `src/export_manager.py` - Core export functionality - `thechart.export.export_manager` - Core export functionality
- `src/export_window.py` - GUI export interface - `thechart.ui.export_window` - GUI export interface
#### Test Files #### Test Files
- `simple_export_test.py` - Basic export functionality test - `simple_export_test.py` - Basic export functionality test
+2 -2
View File
@@ -32,7 +32,7 @@ make run
``` ```
### First Steps ### First Steps
1. **Launch TheChart** using `make run` or `python src/main.py` 1. **Launch TheChart** using `make run` or `python -m thechart`
2. **Add your first entry** using Ctrl+S 2. **Add your first entry** using Ctrl+S
3. **Explore features** with the keyboard shortcuts (F1 for help) 3. **Explore features** with the keyboard shortcuts (F1 for help)
4. **Customize settings** with F2 or through the Theme menu 4. **Customize settings** with F2 or through the Theme menu
@@ -439,7 +439,7 @@ The UI flickering issue during scrolling has been resolved in the latest version
4. Review export logs for specific errors 4. Review export logs for specific errors
### Debug Mode ### Debug Mode
Enable debug logging by setting the log level in `src/constants.py`: Enable debug logging by setting the log level via environment or in `thechart.core.constants`:
```python ```python
LOG_LEVEL = "DEBUG" LOG_LEVEL = "DEBUG"
``` ```
+34
View File
@@ -0,0 +1,34 @@
# Migration Guide: Canonical Imports and Running TheChart
This project now uses the canonical package `thechart.*` for all imports.
What changed
- Legacy shim modules under `src/` (e.g., `src/ui_manager.py`) remain only for compatibility and now emit `DeprecationWarning`.
- Canonical modules live under `src/thechart/` and should be imported directly.
Do this
- Imports:
- from thechart.ui import UIManager, ThemeManager
- from thechart.analytics import GraphManager
- from thechart.data import DataManager
- from thechart.export import ExportManager
- from thechart.managers import MedicineManager, PathologyManager
- from thechart.search.search_filter import DataFilter, QuickFilters, SearchHistory
- from thechart.core.logger import init_logger
- from thechart.core.constants import LOG_LEVEL, LOG_PATH, LOG_CLEAR, BACKUP_PATH
- from thechart.core.auto_save import AutoSaveManager, BackupManager
- from thechart.core.error_handler import ErrorHandler, OperationTimer, handle_exceptions
- from thechart.core.preferences import get_pref, set_pref, load_preferences, save_preferences, reset_preferences
- from thechart.core.undo_manager import UndoManager, UndoAction
- from thechart.validation import InputValidator
- Run the app:
- python -m thechart
Avoid this
- from src.ui_manager import UIManager (deprecated)
- from ui_manager import UIManager (deprecated)
Notes
- Deprecation shims will be removed once all usages are migrated.
- Tests will be updated separately to import from `thechart.*` directly.
+12 -8
View File
@@ -108,25 +108,25 @@ stop: ## Stop the application
docker-compose down docker-compose down
test: ## Run the tests test: ## Run the tests
@echo "Running the tests..." @echo "Running the tests..."
.venv/bin/python -m pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=html:htmlcov $(PYTHON) -m pytest -q
test-unit: ## Run unit tests only test-unit: ## Run unit tests only
@echo "Running unit tests..." @echo "Running unit tests..."
.venv/bin/python -m pytest tests/ -v --tb=short $(PYTHON) -m pytest tests/ -v --tb=short
test-coverage: ## Run tests with detailed coverage report test-coverage: ## Run tests with detailed coverage report
@echo "Running tests with coverage..." @echo "Running tests with coverage..."
.venv/bin/python -m pytest tests/ --cov=src --cov-report=html:htmlcov --cov-report=xml --cov-report=term-missing env PYTHONPATH=src $(PYTHON) -m pytest tests/ --cov=thechart --cov-report=term-missing --cov-report=html:htmlcov --cov-report=xml
test-watch: ## Run tests in watch mode test-watch: ## Run tests in watch mode
@echo "Running tests in watch mode..." @echo "Running tests in watch mode..."
.venv/bin/python -m pytest-watch tests/ -- -v --cov=src env PYTHONPATH=src $(PYTHON) -m pytest_watch tests/ -- -v --cov=thechart
test-debug: ## Run tests with debug output test-debug: ## Run tests with debug output
@echo "Running tests with debug output..." @echo "Running tests with debug output..."
.venv/bin/python -m pytest tests/ -v -s --tb=long --cov=src env PYTHONPATH=src $(PYTHON) -m pytest tests/ -v -s --tb=long --cov=thechart
lint: ## Run the linter lint: ## Run the linter
@echo "Running the linter..." @echo "Running the linter..."
docker-compose exec ${TARGET} pipenv run pre-commit run --all-files uv run ruff check .
format: ## Format the code format: ## Format the code
@echo "Formatting the code..." @echo "Formatting the code..."
docker-compose exec ${TARGET} pipenv run pre-commit run --all-files --show-diff uv run ruff format .
attach: ## Open a shell in the container attach: ## Open a shell in the container
@echo "Opening a shell in the container..." @echo "Opening a shell in the container..."
docker-compose exec -it ${TARGET} /bin/bash docker-compose exec -it ${TARGET} /bin/bash
@@ -135,7 +135,11 @@ shell: ## Open a shell in the local environment
source .venv/bin/activate.${SHELL}; /bin/${SHELL} source .venv/bin/activate.${SHELL}; /bin/${SHELL}
requirements: ## Export the requirements to a file requirements: ## Export the requirements to a file
@echo "Exporting requirements to requirements.txt..." @echo "Exporting requirements to requirements.txt..."
poetry export --without-hashes -f requirements.txt -o requirements.txt uv pip compile requirements.in -o requirements.txt
@if [ -f requirements-dev.in ]; then \
echo "Exporting dev requirements to requirements-dev.txt..."; \
uv pip compile requirements-dev.in -o requirements-dev.txt; \
fi
update-version: ## Update version in pyproject.toml from .env file and sync uv.lock update-version: ## Update version in pyproject.toml from .env file and sync uv.lock
@echo "Updating version in pyproject.toml from .env..." @echo "Updating version in pyproject.toml from .env..."
+5 -2
View File
@@ -8,6 +8,8 @@ make install
# Run the application # Run the application
make run make run
# Or use the package entry point (preferred)
python -m thechart
# Run tests (consolidated test suite) # Run tests (consolidated test suite)
make test make test
@@ -96,7 +98,8 @@ python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
# Run the application # Run the application (either of the following)
python -m thechart
python src/main.py python src/main.py
``` ```
@@ -126,7 +129,7 @@ make test
## 🚀 Usage ## 🚀 Usage
### Basic Workflow ### Basic Workflow
1. **Launch**: Run `python src/main.py` or use the desktop file 1. **Launch**: Run `python -m thechart` (preferred) or use the desktop file
2. **Configure**: Set up medicines and pathologies via the Tools menu 2. **Configure**: Set up medicines and pathologies via the Tools menu
3. **Track**: Add daily entries with medication and symptom data 3. **Track**: Add daily entries with medication and symptom data
4. **Visualize**: View graphs and trends in the main interface 4. **Visualize**: View graphs and trends in the main interface
+8 -8
View File
@@ -15,7 +15,7 @@ The UI elements were flickering when the user scrolled through the table, causin
## Solutions Implemented ## Solutions Implemented
### 1. Auto-save Optimization (`src/main.py`) ### 1. Auto-save Optimization (`thechart` main application)
```python ```python
def _auto_save_callback(self) -> None: def _auto_save_callback(self) -> None:
"""Callback function for auto-save operations.""" """Callback function for auto-save operations."""
@@ -28,7 +28,7 @@ def _auto_save_callback(self) -> None:
``` ```
**Impact**: Eliminates UI interruptions during auto-save operations. **Impact**: Eliminates UI interruptions during auto-save operations.
### 2. Debounced Filter Updates (`src/search_filter_ui.py`) ### 2. Debounced Filter Updates (`thechart.ui.search_filter_ui`)
- Added 300ms debouncing mechanism to prevent excessive filter updates - Added 300ms debouncing mechanism to prevent excessive filter updates
- Consolidated filter updates into a single batch operation - Consolidated filter updates into a single batch operation
- Replaced immediate callbacks with debounced updates - Replaced immediate callbacks with debounced updates
@@ -47,7 +47,7 @@ def _debounced_update(self) -> None:
``` ```
**Impact**: Reduces filter update frequency from every keystroke to maximum once per 300ms. **Impact**: Reduces filter update frequency from every keystroke to maximum once per 300ms.
### 3. Efficient Tree Updates (`src/main.py`) ### 3. Efficient Tree Updates (application update path)
- Separated tree update logic into `_update_tree_efficiently()` method - Separated tree update logic into `_update_tree_efficiently()` method
- Added scroll position preservation - Added scroll position preservation
- Eliminated redundant data loading - Eliminated redundant data loading
@@ -71,7 +71,7 @@ def _update_tree_efficiently(self, df: pd.DataFrame) -> None:
``` ```
**Impact**: Maintains scroll position and reduces visual disruption during updates. **Impact**: Maintains scroll position and reduces visual disruption during updates.
### 4. Optimized Data Loading (`src/main.py`) ### 4. Optimized Data Loading (application update path)
- Eliminated redundant `load_data()` calls - Eliminated redundant `load_data()` calls
- Used single data copy for both filtered and unfiltered operations - Used single data copy for both filtered and unfiltered operations
- Improved memory efficiency - Improved memory efficiency
@@ -88,7 +88,7 @@ def refresh_data_display(self, apply_filters: bool = False) -> None:
``` ```
**Impact**: Reduces I/O operations and memory usage. **Impact**: Reduces I/O operations and memory usage.
### 5. Scroll Optimization (`src/ui_manager.py`) ### 5. Scroll Optimization (`thechart.ui.ui_manager`)
- Added optimized scroll command with threshold-based updates - Added optimized scroll command with threshold-based updates
- Reduced scrollbar update frequency for better performance - Reduced scrollbar update frequency for better performance
@@ -117,9 +117,9 @@ The application now runs without the previous UI flickering issues:
## Files Modified ## Files Modified
1. `src/main.py` - Auto-save optimization and efficient tree updates 1. Main application - Auto-save optimization and efficient tree updates
2. `src/search_filter_ui.py` - Debounced filter updates 2. `thechart.ui.search_filter_ui` - Debounced filter updates
3. `src/ui_manager.py` - Optimized scroll handling 3. `thechart.ui.ui_manager` - Optimized scroll handling
## Verification ## Verification
+2 -2
View File
@@ -33,7 +33,7 @@ make shell
source .venv/bin/activate source .venv/bin/activate
# Using uv run (recommended) # Using uv run (recommended)
uv run python src/main.py uv run python -m thechart
``` ```
## Testing Framework ## Testing Framework
@@ -266,7 +266,7 @@ Application logs are stored in `logs/` directory:
- **`app.warning.log`**: Warning messages only - **`app.warning.log`**: Warning messages only
### Debug Mode ### Debug Mode
Enable debug logging by modifying `src/logger.py` configuration. Enable debug logging via environment or edit `thechart.core.constants` and use `thechart.core.logger`.
### Common Issues ### Common Issues
+6 -6
View File
@@ -45,19 +45,19 @@ The export functionality is accessible through:
The export system consists of three main components: The export system consists of three main components:
#### ExportManager Class (`src/export_manager.py`) #### ExportManager Class (`thechart.export.export_manager`)
- Core export functionality - Core export functionality
- Handles data transformation and file generation - Handles data transformation and file generation
- Integrates with existing data and graph managers - Integrates with existing data and graph managers
- Supports all three export formats - Supports all three export formats
#### ExportWindow Class (`src/export_window.py`) #### ExportWindow Class (`thechart.ui.export_window`)
- GUI interface for export operations - GUI interface for export operations
- Modal dialog with export options - Modal dialog with export options
- File save dialog integration - File save dialog integration
- Progress feedback and error handling - Progress feedback and error handling
#### Integration in MedTrackerApp (`src/main.py`) #### Integration in MedTrackerApp (`python -m thechart` entry)
- Export manager initialization - Export manager initialization
- Menu integration - Menu integration
- Seamless integration with existing managers - Seamless integration with existing managers
@@ -168,9 +168,9 @@ Exported test files are created in the `test_exports/` directory:
## File Locations ## File Locations
### Source Files ### Source Modules
- `src/export_manager.py` - Core export functionality - `thechart.export.export_manager` - Core export functionality
- `src/export_window.py` - GUI export interface - `thechart.ui.export_window` - GUI export interface
### Test Files ### Test Files
- `simple_export_test.py` - Basic export functionality test - `simple_export_test.py` - Basic export functionality test
+3 -2
View File
@@ -9,7 +9,7 @@
"300" "300"
], ],
"color": "#FF6B6B", "color": "#FF6B6B",
"default_enabled": true "default_enabled": false
}, },
{ {
"key": "hydroxyzine", "key": "hydroxyzine",
@@ -44,13 +44,14 @@
"40" "40"
], ],
"color": "#96CEB4", "color": "#96CEB4",
"default_enabled": true "default_enabled": false
}, },
{ {
"key": "quetiapine", "key": "quetiapine",
"display_name": "Quetiapine", "display_name": "Quetiapine",
"dosage_info": "25 mg", "dosage_info": "25 mg",
"quick_doses": [ "quick_doses": [
"12",
"25", "25",
"50", "50",
"100" "100"
+20 -2
View File
@@ -15,6 +15,9 @@ dependencies = [
"ttkthemes>=3.2.2", "ttkthemes>=3.2.2",
] ]
[project.scripts]
thechart = "thechart.__main__:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.2.0", "pre-commit>=4.2.0",
@@ -33,7 +36,7 @@ python_classes = ["Test*"]
python_functions = ["test_*"] python_functions = ["test_*"]
addopts = [ addopts = [
"--verbose", "--verbose",
"--cov=src", "--cov=thechart",
"--cov-report=term-missing", "--cov-report=term-missing",
"--cov-report=html:htmlcov", "--cov-report=html:htmlcov",
"--cov-report=xml", "--cov-report=xml",
@@ -41,7 +44,7 @@ addopts = [
minversion = "8.0" minversion = "8.0"
[tool.coverage.run] [tool.coverage.run]
source = ["src"] source = ["thechart"]
omit = ["tests/*", "*/test_*", "*/__pycache__/*", ".venv/*"] omit = ["tests/*", "*/test_*", "*/__pycache__/*", ".venv/*"]
[tool.coverage.report] [tool.coverage.report]
@@ -104,3 +107,18 @@ indent-style = "space" # Use spaces for indentation
[tool.ruff.lint.pycodestyle] [tool.ruff.lint.pycodestyle]
max-line-length = 88 max-line-length = 88
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
include = ["thechart*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
thechart = ["py.typed"]
+110
View File
@@ -0,0 +1,110 @@
# TheChart Scripts Directory
This directory contains interactive demonstrations and utility scripts for TheChart application.
## Scripts Overview
### Testing Scripts
#### `run_tests.py`
Main test runner for the application.
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/run_tests.py
```
#### `integration_test.py`
Comprehensive integration test for the export system.
- Tests all export formats (JSON, XML, PDF)
- Validates data integrity and file creation
- No GUI dependencies - safe for automated testing
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/integration_test.py
```
### Feature Testing Scripts
#### `test_note_saving.py`
Tests note saving and retrieval functionality.
- Validates note persistence in CSV files
- Tests special characters and formatting
#### `test_update_entry.py`
Tests entry update functionality.
- Validates data modification operations
- Tests date validation and duplicate handling
#### `test_keyboard_shortcuts.py`
Tests keyboard shortcut functionality.
- Validates keyboard event handling
- Tests shortcut combinations and responses
### Interactive Demonstrations
#### `test_menu_theming.py`
Interactive demonstration of menu theming functionality.
- Live theme switching demonstration
- Visual display of theme colors
- Real-time menu color updates
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/test_menu_theming.py
```
## Usage
All scripts should be run from the project root directory using the virtual environment:
```bash
cd /home/will/Code/thechart
source .venv/bin/activate.fish # For fish shell
# OR
source .venv/bin/activate # For bash/zsh
python scripts/<script_name>.py
```
## Test Organization
### Unit Tests
Located in `/tests/` directory:
- `test_theme_manager.py` - Theme manager functionality tests
- `test_data_manager.py` - Data management tests
- `test_ui_manager.py` - UI component tests
- `test_graph_manager.py` - Graph functionality tests
- And more...
Run unit tests with:
```bash
cd /home/will/Code/thechart
.venv/bin/python -m pytest tests/
```
### Integration Tests
Located in `/scripts/` directory:
- `integration_test.py` - Export system integration test
- Feature-specific test scripts
### Interactive Demos
Located in `/scripts/` directory:
- `test_menu_theming.py` - Menu theming demonstration
## Test Data
- Integration tests create temporary export files in `integration_test_exports/` (auto-cleaned)
- Test scripts use the main `thechart_data.csv` file unless specified otherwise
- No test data is committed to the repository
## Development
When adding new scripts:
1. Place them in this directory
2. Use the standard shebang: `#!/usr/bin/env python3`
3. Add proper docstrings and error handling
4. Update this README with script documentation
5. Follow the project's linting and formatting standards
6. For unit tests, place them in `/tests/` directory
7. For integration tests or demos, place them in `/scripts/` directory
+10 -5
View File
@@ -1,16 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test script to analyze all theme header colors.""" """Test script to analyze all theme header colors."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
# Add src directory to Python path from thechart.core.constants import LOG_LEVEL
src_path = Path(__file__).parent / "src" from thechart.core.logger import init_logger
sys.path.insert(0, str(src_path)) from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def analyze_all_themes(): def analyze_all_themes():
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
⚠️ DEPRECATED SCRIPT ⚠️
This script has been consolidated into the new unified test suite.
Please use the new testing structure instead:
For theme testing:
.venv/bin/python scripts/quick_test.py theme
For integration testing:
.venv/bin/python scripts/quick_test.py integration
For all tests:
.venv/bin/python scripts/run_tests.py
See TESTING_MIGRATION.md for full details.
"""
import sys
print("⚠️ This script is deprecated. Please use the new test structure.")
print("See TESTING_MIGRATION.md for migration instructions.")
sys.exit(1)
# Original script content below (preserved for reference):
# """ + content[content.find('"""'):] if '"""' in content else content + """
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
⚠️ DEPRECATED SCRIPT ⚠️
This script has been consolidated into the new unified test suite.
Please use the new testing structure instead:
For theme testing:
.venv/bin/python scripts/quick_test.py theme
For integration testing:
.venv/bin/python scripts/quick_test.py integration
For all tests:
.venv/bin/python scripts/run_tests.py
See TESTING_MIGRATION.md for full details.
"""
import sys
print("⚠️ This script is deprecated. Please use the new test structure.")
print("See TESTING_MIGRATION.md for migration instructions.")
sys.exit(1)
# Original script content below (preserved for reference):
# """ + content[content.find('"""'):] if '"""' in content else content + """
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
⚠️ DEPRECATED SCRIPT ⚠️
This script has been consolidated into the new unified test suite.
Please use the new testing structure instead:
For theme testing:
.venv/bin/python scripts/quick_test.py theme
For integration testing:
.venv/bin/python scripts/quick_test.py integration
For all tests:
.venv/bin/python scripts/run_tests.py
See TESTING_MIGRATION.md for full details.
"""
import sys
print("⚠️ This script is deprecated. Please use the new test structure.")
print("See TESTING_MIGRATION.md for migration instructions.")
sys.exit(1)
# Original script content below (preserved for reference):
# """ + content[content.find('"""'):] if '"""' in content else content + """
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
⚠️ DEPRECATED SCRIPT ⚠️
This script has been consolidated into the new unified test suite.
Please use the new testing structure instead:
For theme testing:
.venv/bin/python scripts/quick_test.py theme
For integration testing:
.venv/bin/python scripts/quick_test.py integration
For all tests:
.venv/bin/python scripts/run_tests.py
See TESTING_MIGRATION.md for full details.
"""
import sys
print("⚠️ This script is deprecated. Please use the new test structure.")
print("See TESTING_MIGRATION.md for migration instructions.")
sys.exit(1)
# Original script content below (preserved for reference):
# """ + content[content.find('"""'):] if '"""' in content else content + """
+12 -7
View File
@@ -3,18 +3,23 @@
Integration test for TheChart export system Integration test for TheChart export system
Tests the complete export workflow without GUI dependencies Tests the complete export workflow without GUI dependencies
""" """
# ruff: noqa: E402
import sys import sys
from pathlib import Path from pathlib import Path
# Add src to path # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
sys.path.insert(0, "src") SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from data_manager import DataManager from thechart.core.constants import LOG_LEVEL
from export_manager import ExportManager from thechart.core.logger import init_logger
from init import logger from thechart.data import DataManager
from medicine_manager import MedicineManager from thechart.export import ExportManager
from pathology_manager import PathologyManager from thechart.managers import MedicineManager, PathologyManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
class MockGraphManager: class MockGraphManager:
+13 -5
View File
@@ -1,17 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test the darker header text for Arc theme.""" """Test the darker header text for Arc theme."""
# ruff: noqa: E402
#!/usr/bin/env python3
"""Test the darker header text for Arc theme."""
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from tkinter import ttk from tkinter import ttk
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
# Add src directory to Python path from thechart.core.constants import LOG_LEVEL
src_path = Path(__file__).parent / "src" from thechart.core.logger import init_logger
sys.path.insert(0, str(src_path)) from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def test_arc_darker_headers(): def test_arc_darker_headers():
+11 -2
View File
@@ -1,13 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test script to check table header visibility in Arc theme.""" """Test script to check table header visibility in Arc theme."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from tkinter import ttk from tkinter import ttk
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from thechart.core.constants import LOG_LEVEL
from thechart.core.logger import init_logger
from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
# Add src directory to Python path # Add src directory to Python path
src_path = Path(__file__).parent / "src" src_path = Path(__file__).parent / "src"
+10 -4
View File
@@ -2,16 +2,22 @@
""" """
Test the complete dose tracking flow: load -> display -> add -> save Test the complete dose tracking flow: load -> display -> add -> save
""" """
# ruff: noqa: E402
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
# Add the src directory to Python path # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) SRC_DIR = os.path.join(os.path.dirname(__file__), "..", "src")
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
from init import logger from thechart.core.constants import LOG_LEVEL
from ui_manager import UIManager from thechart.core.logger import init_logger
from thechart.ui import UIManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def test_dose_parsing(): def test_dose_parsing():
+16 -10
View File
@@ -3,20 +3,28 @@
Test script for dose tracking UI in edit window. Test script for dose tracking UI in edit window.
Tests the specific issue where adding new doses replaces existing ones. Tests the specific issue where adding new doses replaces existing ones.
""" """
# ruff: noqa: E402
import os
import sys import sys
import tkinter as tk import tkinter as tk
from datetime import datetime from datetime import datetime
from pathlib import Path
# Add the src directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from init import logger def _ensure_src_on_path() -> None:
from medicine_manager import MedicineManager src_dir = Path(__file__).resolve().parent.parent / "src"
from pathology_manager import PathologyManager if str(src_dir) not in sys.path:
from theme_manager import ThemeManager sys.path.insert(0, str(src_dir))
from ui_manager import UIManager
_ensure_src_on_path()
from thechart.core.constants import LOG_LEVEL
from thechart.core.logger import init_logger
from thechart.managers import Medicine, MedicineManager, PathologyManager
from thechart.ui import ThemeManager
from thechart.ui.ui_manager import UIManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def test_dose_tracking(): def test_dose_tracking():
@@ -39,8 +47,6 @@ def test_dose_tracking():
# Add a test medicine if none exist # Add a test medicine if none exist
medicines = medicine_manager.get_all_medicines() medicines = medicine_manager.get_all_medicines()
if not medicines: if not medicines:
from medicine_manager import Medicine
test_medicine = Medicine( test_medicine = Medicine(
key="bupropion", key="bupropion",
display_name="Bupropion", display_name="Bupropion",
+11 -2
View File
@@ -1,13 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test the improved header visibility fix.""" """Test the improved header visibility fix."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from tkinter import ttk from tkinter import ttk
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from thechart.core.constants import LOG_LEVEL
from thechart.core.logger import init_logger
from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
# Add src directory to Python path # Add src directory to Python path
src_path = Path(__file__).parent / "src" src_path = Path(__file__).parent / "src"
+38 -22
View File
@@ -1,32 +1,51 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test script to verify theme changing functionality works without errors.""" """Quick smoke test for ThemeManager: iterate and apply available themes.
This script can be run standalone. It ensures the local ``src`` is on sys.path
so the ``thechart`` package is importable without installation. It also hides
the Tk window and gracefully skips if no display is available.
"""
from __future__ import annotations
import contextlib
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from init import logger
from theme_manager import ThemeManager
# Add src directory to Python path def _ensure_src_on_path() -> None:
src_path = Path(__file__).parent.parent / "src" """Add the repository's ``src`` dir to sys.path when running locally."""
sys.path.insert(0, str(src_path)) repo_root = Path(__file__).resolve().parents[1]
src_dir = repo_root / "src"
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
def test_theme_changes(): def main() -> int:
"""Test changing between different themes to ensure no errors occur.""" _ensure_src_on_path()
# Imports after path fix
from thechart.core.constants import LOG_LEVEL
from thechart.core.logger import init_logger
from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
print("Testing theme changing functionality...") print("Testing theme changing functionality...")
# Create a test tkinter window # Create a test tkinter root; skip gracefully if headless
try:
root = tk.Tk() root = tk.Tk()
except tk.TclError as exc:
print(f"Skipping: no display available ({exc})")
return 0
try:
root.withdraw() # Hide the window root.withdraw() # Hide the window
# Initialize theme manager
theme_manager = ThemeManager(root, logger) theme_manager = ThemeManager(root, logger)
# Test all available themes
available_themes = theme_manager.get_available_themes() available_themes = theme_manager.get_available_themes()
print(f"Available themes: {available_themes}")
for theme in available_themes: for theme in available_themes:
print(f"Testing theme: {theme}") print(f"Testing theme: {theme}")
@@ -35,23 +54,20 @@ def test_theme_changes():
if success: if success:
print(f"{theme} applied successfully") print(f"{theme} applied successfully")
# Test getting theme colors (this is where the error was occurring)
colors = theme_manager.get_theme_colors() colors = theme_manager.get_theme_colors()
print(f" ✓ Theme colors retrieved: {list(colors.keys())}") print(f" ✓ Theme colors retrieved: {list(colors.keys())}")
# Test getting menu colors
menu_colors = theme_manager.get_menu_colors() menu_colors = theme_manager.get_menu_colors()
print(f" ✓ Menu colors retrieved: {list(menu_colors.keys())}") print(f" ✓ Menu colors retrieved: {list(menu_colors.keys())}")
else: else:
print(f" ✗ Failed to apply {theme}") print(f" ✗ Failed to apply {theme}")
except Exception as e: except Exception as e: # pragma: no cover - smoke test resilience
print(f" ✗ Error with {theme}: {e}") print(f" ✗ Error applying {theme}: {e}")
return 0
# Clean up finally:
with contextlib.suppress(Exception):
root.destroy() root.destroy()
print("Theme testing completed!")
if __name__ == "__main__": if __name__ == "__main__":
test_theme_changes() raise SystemExit(main())
+10 -5
View File
@@ -1,17 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test the improved header visibility with white text.""" """Test the improved header visibility with white text."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from tkinter import ttk from tkinter import ttk
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
# Add src directory to Python path from thechart.core.constants import LOG_LEVEL
src_path = Path(__file__).parent / "src" from thechart.core.logger import init_logger
sys.path.insert(0, str(src_path)) from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def test_white_headers(): def test_white_headers():
+10 -6
View File
@@ -1,16 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify header visibility across all themes.""" """Verify header visibility across all themes."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from init import logger # Ensure the 'src' directory is on sys.path so 'thechart' package is importable
from theme_manager import ThemeManager SRC_DIR = Path(__file__).resolve().parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
# Add src directory to Python path from thechart.core.constants import LOG_LEVEL
src_path = Path(__file__).parent / "src" from thechart.core.logger import init_logger
sys.path.insert(0, str(src_path)) from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def verify_all_themes(): def verify_all_themes():
@@ -56,7 +61,6 @@ def verify_all_themes():
darker = min(bg_lum, fg_lum) darker = min(bg_lum, fg_lum)
contrast_ratio = (lighter + 0.05) / (darker + 0.05) contrast_ratio = (lighter + 0.05) / (darker + 0.05)
# Determine status
if contrast_ratio >= 4.5: if contrast_ratio >= 4.5:
status = "✅ EXCELLENT" status = "✅ EXCELLENT"
elif contrast_ratio >= 3.0: elif contrast_ratio >= 3.0:
+13 -5
View File
@@ -1,16 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Verify that other themes still work correctly with Arc-specific change.""" """Verify that other themes still work correctly with Arc-specific change."""
# ruff: noqa: E402
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
from init import logger
from theme_manager import ThemeManager
# Add src directory to Python path def _ensure_src_on_path() -> None:
src_path = Path(__file__).parent / "src" src_dir = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(src_path)) if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
_ensure_src_on_path()
from thechart.core.constants import LOG_LEVEL
from thechart.core.logger import init_logger
from thechart.ui import ThemeManager
logger = init_logger(__name__, testing_mode=(LOG_LEVEL == "DEBUG"))
def verify_other_themes(): def verify_other_themes():
+2 -367
View File
@@ -1,369 +1,4 @@
"""Auto-save and backup utilities for TheChart. # Deprecated legacy shim. Use 'thechart.core.auto_save' instead.
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).
"""
from __future__ import annotations from __future__ import annotations
import contextlib raise ImportError("src.auto_save is removed. Import from 'thechart.core_auto_save'.")
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
+3 -48
View File
@@ -1,49 +1,4 @@
import os # Deprecated legacy shim. Use 'thechart.core.constants' instead.
import sys from __future__ import annotations
import dotenv as _dotenv raise ImportError("src.constants is removed. Import from '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]
_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",
]
+3 -523
View File
@@ -1,524 +1,4 @@
import csv # Deprecated legacy shim. Use 'thechart.data' instead.
import logging from __future__ import annotations
import os
import tempfile
from datetime import datetime
from typing import Any
import pandas as pd raise ImportError("src.data_manager is removed. Import from 'thechart.data'.")
from medicine_manager import MedicineManager
from pathology_manager import 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
+4 -389
View File
@@ -1,391 +1,6 @@
"""Enhanced error handling and user feedback system for TheChart.""" # Deprecated legacy shim. Use 'thechart.core.error_handler' instead.
from __future__ import annotations
import logging raise ImportError(
from datetime import datetime "src.error_handler is removed. Import from 'thechart.core.error_handler'."
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
+5 -455
View File
@@ -1,457 +1,7 @@
""" # Deprecated legacy shim. Use 'thechart.export.export_manager' instead.
Export Manager for TheChart Application from __future__ import annotations
Handles exporting data and graphs to various formats: raise ImportError(
- CSV data to JSON, XML "src.export_manager is removed. Import ExportManager from "
- Graphs to PDF (with data tables) "'thechart.export.export_manager'."
"""
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
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 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
# Track created export artifacts so test teardown can remove temp dirs
self._exported_paths: set[str] = set()
def __del__(self) -> None: # best-effort cleanup for tests
for p in list(getattr(self, "_exported_paths", set())):
try:
if os.path.exists(p):
os.unlink(p)
except Exception:
pass
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)
# Track for later cleanup in tests' teardown
self._exported_paths.add(export_path)
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)
# Track for later cleanup in tests' teardown
self._exported_paths.add(export_path)
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,
}
+4 -277
View File
@@ -1,279 +1,6 @@
""" # Deprecated legacy shim. Use 'thechart.ui.export_window' instead.
Export Window for TheChart Application from __future__ import annotations
Provides a GUI interface for exporting data and graphs to various formats. raise ImportError(
""" "src.export_window is removed. Import from 'thechart.ui.export_window'."
import tkinter as tk
from collections.abc import Callable
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from export_manager 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") # 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
+7 -567
View File
@@ -1,572 +1,12 @@
import sys """Compatibility shim for GraphManager.
import tkinter as tk
from contextlib import suppress
from tkinter import ttk
from types import SimpleNamespace
import matplotlib.pyplot as plt Re-exports the canonical implementation from `thechart.analytics.graph_manager`.
import pandas as pd This keeps `from graph_manager import GraphManager` working for legacy scripts.
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
# Ensure both import styles ('graph_manager' and 'src.graph_manager') refer to
# the same module object so test patches apply reliably regardless of import
# order across the suite.
_this_mod = sys.modules.get(__name__)
sys.modules["graph_manager"] = _this_mod
sys.modules["src.graph_manager"] = _this_mod
def _build_default_medicine_manager():
"""Create a lightweight default medicine manager used by legacy tests.
The test suite historically instantiated GraphManager with only a
parent frame (no managers) and then asserted on the existence and
default state of specific medicine toggle variables. To maintain
backwards compatibility we provide a minimal object exposing the
subset of the real manager's API that GraphManager relies upon.
""" """
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: from __future__ import annotations
def get_medicine_keys(self):
return list(default_medicines.keys())
def get_medicine(self, key): raise ImportError(
return default_medicines.get(key) "src.graph_manager is removed. Import GraphManager from "
"'thechart.analytics.graph_manager'."
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
application with performance improvements."""
def __init__(
self,
parent_frame: ttk.LabelFrame,
medicine_manager: MedicineManager | None = None,
pathology_manager: PathologyManager | None = 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.
"""
# Store references/construct lightweight defaults when not provided
self.parent_frame: ttk.LabelFrame = parent_frame
# Create a dedicated frame for the graph canvas to satisfy tests
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
# Use subplots (tests patch matplotlib.pyplot.subplots)
self.fig, self.ax = plt.subplots(figsize=(10, 6), dpi=80)
# Data caches
self.current_data: pd.DataFrame = pd.DataFrame()
self._last_plot_hash: str = ""
# UI / toggle state
self.toggle_vars: dict[str, tk.BooleanVar] = {}
self._setup_ui()
self._initialize_toggle_vars()
self._create_chart_toggles()
def _initialize_toggle_vars(self) -> None:
"""Initialize toggle variables for chart elements with optimization."""
# Initialize pathology toggles
for pathology_key in self.pathology_manager.get_pathology_keys():
# Pathologies default to visible (True)
self.toggle_vars[pathology_key] = tk.BooleanVar(value=True)
# Initialize medicine toggles (unchecked by default)
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:
"""Set up the UI components with performance optimizations."""
# Create canvas with optimized settings
# Use keyword arg 'figure' for compatibility with tests asserting
# call signature. Create canvas bound to graph_frame (tests patch
# FigureCanvasTkAgg in this module)
try:
# Important: use the class from this module's namespace so tests
# patching 'graph_manager.FigureCanvasTkAgg' affect this call.
CanvasClass = globals().get("FigureCanvasTkAgg", FigureCanvasTkAgg)
self.canvas = CanvasClass(figure=self.fig, master=self.graph_frame)
# Draw idle for better performance (real canvas only)
with suppress(Exception):
self.canvas.draw_idle()
except (tk.TclError, RuntimeError, TypeError):
# Fallback dummy canvas for environments where FigureCanvasTkAgg
# interacts poorly with mocks or missing Tk resources.
class _DummyCanvas:
def __init__(self, master: ttk.Frame) -> None:
self._widget = ttk.Frame(master)
def draw(self) -> None: # pragma: no cover - minimal fallback
pass
def draw_idle(self) -> None: # pragma: no cover
pass
def get_tk_widget(self): # pragma: no cover
return self._widget
self.canvas = _DummyCanvas(self.graph_frame)
# Pack canvas
canvas_widget = self.canvas.get_tk_widget()
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# Create control frame
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:
"""Create toggle controls for chart elements with improved layout."""
# Pathology toggles
pathology_frame = ttk.LabelFrame(
self.control_frame, text="Pathologies", padding="5"
)
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
# Use grid for better layout
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: # 2 columns max
col = 0
row += 1
# Medicine toggles
medicine_frame = ttk.LabelFrame(
self.control_frame, text="Medicines", padding="5"
)
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
# Use grid for medicines too
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: # 3 columns max for medicines
col = 0
row += 1
def _handle_toggle_changed(self) -> None:
"""Handle toggle changes by replotting the graph with optimization."""
if not self.current_data.empty:
self._plot_graph_data(self.current_data)
def update_graph(self, df: pd.DataFrame) -> None:
"""Update the graph with new data using optimization checks."""
# Lightweight hash: combine length, last date, and raw bytes checksum
if getattr(df, "empty", True):
data_hash = "empty"
else:
try:
# If date column exists, capture last value for change detection
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}"
# Update caches when data changed, but always (re)plot to reflect toggle changes
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
# Always attempt to plot so UI reflects toggles even when data unchanged
try:
self._plot_graph_data(df)
except Exception:
# Swallow plotting errors to satisfy tests expecting graceful handling
if self.logger: # best-effort logging
with suppress(Exception):
self.logger.exception("Error while plotting graph data")
def _plot_graph_data(self, df: pd.DataFrame) -> None:
"""Plot the graph data with current toggle settings using optimizations."""
# Use batch updates to reduce redraws
with plt.ioff(): # Turn off interactive mode for batch updates
self.ax.clear()
if hasattr(df, "empty") and not df.empty:
# Optimize data processing
df_processed = self._preprocess_data(df)
# Track if any series are plotted
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)
# Single draw call at the end (always draw to satisfy tests)
# Use draw() as tests assert draw is called on the canvas
try:
self.canvas.draw()
except Exception:
# Fallback to draw_idle in real canvas
with plt.ioff(), suppress(Exception):
self.canvas.draw_idle()
def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
"""Preprocess data for plotting with optimizations."""
# If already indexed by datetime (from DataManager cache) keep it
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:
"""Plot pathology data series with optimizations."""
has_plotted_series = False
# Batch plot pathology data
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:
"""Plot medicine data with optimizations."""
result = {"has_plotted": False, "with_data": [], "without_data": []}
# Get medicine colors and keys
medicine_colors = self.medicine_manager.get_graph_colors()
medicines = self.medicine_manager.get_medicine_keys()
# Pre-calculate daily doses for all medicines to avoid repeated computation
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
# Plot medicines with data
for medicine in medicines:
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
daily_doses = medicine_doses[medicine]
# Check if there's any data to plot
if any(dose > 0 for dose in daily_doses):
result["with_data"].append(medicine)
# Optimize dose scaling and bar plotting
scaled_doses = [dose / 10 for dose in daily_doses]
# Calculate statistics more efficiently
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)"
# Single bar plot call
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:
# Medicine is toggled on but has no dose data
if self.toggle_vars[medicine].get():
result["without_data"].append(medicine)
return result
def _configure_graph_appearance(self, medicine_data: dict) -> None:
"""Configure graph appearance with optimizations."""
# Get legend data in batch
_hl = self.ax.get_legend_handles_labels()
try:
handles, labels = _hl
except Exception:
handles, labels = [], []
# Copy to avoid mutating objects returned by mocks/tests
handles = list(handles) if handles else []
labels = list(labels) if labels else []
# Add information about medicines without data if any are toggled on
if medicine_data["without_data"]:
med_list = ", ".join(medicine_data["without_data"])
info_text = f"Tracked (no doses): {med_list}"
# Create dummy handle carrying the label so lengths match
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)
# Create legend with optimized settings
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,
)
# Set titles and labels
self.ax.set_title("Medication Effects Over Time")
self.ax.set_xlabel("Date")
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
# Optimize y-axis configuration (robust to mocked axes)
try:
current_ylim = self.ax.get_ylim()
# Some tests use Mock for ax; guard against non-subscriptable return
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))
# Optimize date formatting
self.fig.autofmt_xdate()
def _plot_series(
self,
df: pd.DataFrame,
column: str,
label: str,
marker: str,
linestyle: str,
) -> None:
"""Helper method to plot a data series with optimizations."""
# Use more efficient plotting parameters
self.ax.plot(
df.index,
df[column],
marker=marker,
linestyle=linestyle,
label=label,
markersize=4, # Smaller markers for better performance
linewidth=1.5, # Optimized line width
)
def _calculate_daily_dose(self, dose_str: str) -> float:
"""Calculate total daily dose from dose string format with optimizations."""
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
return 0.0
total_dose = 0.0
# Optimize string processing
dose_str = str(dose_str).replace("", "").strip()
# More efficient splitting and processing
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
for entry in dose_entries:
entry = entry.strip()
if not entry:
continue
try:
# More efficient dose extraction
dose_part = entry.split(":")[-1] if ":" in entry else entry
# Optimized numeric extraction
dose_value = ""
for char in dose_part:
if char.isdigit() or char == ".":
dose_value += char
elif dose_value:
break
if dose_value:
total_dose += float(dose_value)
except (ValueError, IndexError):
continue
return total_dose
def close(self) -> None:
"""Clean up resources with proper optimization."""
try:
# Clear the plot before closing
self.ax.clear()
plt.close(self.fig)
except Exception:
pass # Ignore cleanup errors
+4 -3
View File
@@ -7,7 +7,6 @@ module-level logger, and provides small utilities/exports used by tests.
from __future__ import annotations from __future__ import annotations
import os import os
import sys as _sys
from constants import ( from constants import (
LOG_CLEAR as _REAL_LOG_CLEAR, LOG_CLEAR as _REAL_LOG_CLEAR,
@@ -20,6 +19,9 @@ from constants import (
) )
from logger import init_logger as _REAL_INIT_LOGGER from logger import init_logger as _REAL_INIT_LOGGER
# Deprecated legacy shim. Use 'thechart.core.*' modules directly.
raise ImportError("src.init is removed. Use 'thechart.core.*' modules directly.")
# Preserve patched values across reloads (tests patch init.LOG_*) # Preserve patched values across reloads (tests patch init.LOG_*)
LOG_PATH = globals().get("LOG_PATH", _REAL_LOG_PATH) LOG_PATH = globals().get("LOG_PATH", _REAL_LOG_PATH)
LOG_LEVEL = globals().get("LOG_LEVEL", _REAL_LOG_LEVEL) LOG_LEVEL = globals().get("LOG_LEVEL", _REAL_LOG_LEVEL)
@@ -67,5 +69,4 @@ if LOG_CLEAR == "True":
# Ignore missing files on clear # Ignore missing files on clear
pass pass
# Ensure tests can access as 'init' (without src.) pass
_sys.modules.setdefault("init", _sys.modules.get(__name__))
+8 -286
View File
@@ -1,291 +1,13 @@
"""Input validation utilities for TheChart application.""" """Compatibility shim for InputValidator.
import re This module preserves the legacy import path
from datetime import datetime `from input_validator import InputValidator` while the canonical
from typing import Any implementation now lives under `thechart.validation.input_validator`.
New code should import from `thechart.validation`.
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: from __future__ import annotations
date_str: Date string to validate
Returns: raise ImportError(
Tuple of (is_valid, error_message, parsed_date) "src.input_validator is removed. Import from 'thechart.validation.input_validator'."
"""
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
+2 -130
View File
@@ -1,132 +1,4 @@
"""Application logging utilities. # Deprecated legacy shim. Use 'thechart.core.logger' instead.
This module centralizes logger initialization and honors environment-driven
settings from `constants` (LOG_LEVEL, LOG_PATH, LOG_CLEAR).
"""
from __future__ import annotations from __future__ import annotations
import contextlib raise ImportError("src.logger is removed. Import from 'thechart.core.logger'.")
import logging
import os
import sys as _sys
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 as _CONST_LOG_CLEAR
from constants import LOG_LEVEL as _CONST_LOG_LEVEL
from constants import LOG_PATH as _CONST_LOG_PATH
# Ensure both import styles ('logger' and 'src.logger') point to the same module
# so patches are effective regardless of import path used in tests.
_this_mod = _sys.modules.get(__name__)
_sys.modules["logger"] = _this_mod
_sys.modules["src.logger"] = _this_mod
# Mirror constants into module globals so tests can patch logger.LOG_* directly
LOG_PATH = globals().get("LOG_PATH", _CONST_LOG_PATH)
LOG_LEVEL = globals().get("LOG_LEVEL", _CONST_LOG_LEVEL)
LOG_CLEAR = globals().get("LOG_CLEAR", _CONST_LOG_CLEAR)
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:
# Re-read LOG_PATH from this module's globals so patches like
# `with patch('logger.LOG_PATH', tmpdir)` take effect for handler paths.
log_dir = globals().get("LOG_PATH", LOG_PATH)
fh_all = logging.FileHandler(
os.path.join(log_dir, "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(
os.path.join(log_dir, "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(
os.path.join(log_dir, "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
+64 -41
View File
@@ -9,28 +9,48 @@ from typing import Any
import pandas as pd import pandas as pd
from auto_save import AutoSaveManager, BackupManager from thechart.analytics import GraphManager
from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH from thechart.core.auto_save import AutoSaveManager, BackupManager
from data_manager import DataManager from thechart.core.constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
from error_handler import ErrorHandler from thechart.core.error_handler import ErrorHandler
from export_manager import ExportManager from thechart.core.logger import init_logger
from export_window import ExportWindow from thechart.core.preferences import (
from graph_manager import GraphManager get_config_dir,
from init import logger get_pref,
from input_validator import InputValidator save_preferences,
from medicine_management_window import MedicineManagementWindow set_pref,
from medicine_manager import MedicineManager )
from pathology_management_window import PathologyManagementWindow from thechart.core.undo_manager import UndoAction, UndoManager
from pathology_manager import PathologyManager from thechart.data import DataManager
from preferences import get_config_dir, get_pref, save_preferences, set_pref from thechart.export.export_manager import ExportManager
from search_filter import DataFilter from thechart.managers import MedicineManager, PathologyManager
from settings_window import SettingsWindow from thechart.search.search_filter import DataFilter
from theme_manager import ThemeManager from thechart.ui import ThemeManager, UIManager
from ui_manager import UIManager from thechart.ui.export_window import ExportWindow
from undo_manager import UndoAction, UndoManager 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.*') """TheChart application entry module."""
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: class MedTrackerApp:
@@ -110,8 +130,8 @@ class MedTrackerApp:
# Initialize search/filter system # Initialize search/filter system
self.data_filter = DataFilter() self.data_filter = DataFilter()
self.current_filtered_data = None # type: ignore[assignment]
self.current_filtered_data: pd.DataFrame | None = None self.current_filtered_data = None # type: pd.DataFrame | None
# Set up the main application UI # Set up the main application UI
self._setup_main_ui() self._setup_main_ui()
@@ -1195,11 +1215,14 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
self.backup_manager.cleanup_old_backups(keep_count=5) self.backup_manager.cleanup_old_backups(keep_count=5)
self.graph_manager.close() self.graph_manager.close()
# In tests, the root window is destroyed by the fixture; calling # Defer destroy to avoid double-destroy errors in tests;
# destroy() here leads to double-destroy errors. Quit the mainloop # withdraw immediately
# and let the environment handle final destruction.
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.root.quit() self.root.withdraw()
with contextlib.suppress(Exception):
# Schedule destroy; in real app mainloop will execute this
# In tests (no mainloop), fixture teardown will call destroy once
self.root.after(10, self.root.destroy)
def _auto_save_callback(self) -> None: def _auto_save_callback(self) -> None:
"""Callback function for auto-save operations.""" """Callback function for auto-save operations."""
@@ -1234,7 +1257,7 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
return return
try: try:
# Local import to defer module load cost until first use # 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.search_filter_widget = SearchFilterWidget(
self.main_frame, self.main_frame,
@@ -1644,20 +1667,11 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
else: else:
display_df = df display_df = df
# Always clear and repopulate tree; tests assert .delete()/.insert() # Clear and repopulate tree efficiently
children = list(self.tree.get_children()) children = list(self.tree.get_children())
# Always call delete to satisfy tests; if no children, pass a dummy
try:
if children: if children:
self.tree.delete(*children)
else:
# Some tests expect delete() to be called at least once
self.tree.delete()
except Exception:
# Fallback: delete individually for strict mocks
for c in children:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.tree.delete(c) self.tree.delete(*children)
for index, row in display_df.iterrows(): for index, row in display_df.iterrows():
tag = "evenrow" if index % 2 == 0 else "oddrow" tag = "evenrow" if index % 2 == 0 else "oddrow"
self.tree.insert("", "end", values=list(row), tags=(tag,)) self.tree.insert("", "end", values=list(row), tags=(tag,))
@@ -1679,7 +1693,16 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
logger.error(f"Error updating tree efficiently: {e}") logger.error(f"Error updating tree efficiently: {e}")
if __name__ == "__main__": def run() -> None:
"""Start the TheChart Tkinter application.
Provided to support `python -m thechart` and a console-script entry point
without changing import-time behavior (tests patch symbols on `main`).
"""
root: tk.Tk = tk.Tk() root: tk.Tk = tk.Tk()
app: MedTrackerApp = MedTrackerApp(root) _ = MedTrackerApp(root)
root.mainloop() root.mainloop()
if __name__ == "__main__":
run()
+5 -399
View File
@@ -1,401 +1,7 @@
""" # Deprecated legacy shim. Use 'thechart.ui.medicine_management_window' instead.
Medicine management window for adding, editing, and removing medicines. from __future__ import annotations
"""
import tkinter as tk raise ImportError(
from tkinter import messagebox, ttk "src.medicine_management_window is removed. Import from "
"'thechart.ui.medicine_management_window'."
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.")
+4 -193
View File
@@ -1,195 +1,6 @@
""" # Deprecated legacy shim. Use 'thechart.managers.medicine_manager' instead.
Medicine configuration manager for the MedTracker application. from __future__ import annotations
Handles dynamic loading and saving of medicine configurations.
"""
import json raise ImportError(
import logging "src.medicine_manager is removed. Import from 'thechart.managers.medicine_manager'."
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()
}
+5 -423
View File
@@ -1,425 +1,7 @@
""" # Deprecated legacy shim. Use 'thechart.ui.pathology_management_window' instead.
Pathology management window for adding, editing, and removing pathologies. from __future__ import annotations
"""
import tkinter as tk raise ImportError(
from tkinter import messagebox, ttk "src.pathology_management_window is removed. Import from "
"'thechart.ui.pathology_management_window'."
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.")
+5 -197
View File
@@ -1,199 +1,7 @@
""" # Deprecated legacy shim. Use 'thechart.managers.pathology_manager' instead.
Pathology configuration manager for the MedTracker application. from __future__ import annotations
Handles dynamic loading and saving of pathology/symptom configurations.
"""
import json raise ImportError(
import logging "src.pathology_manager is removed. Import from "
import os "'thechart.managers.pathology_manager'."
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")
+3 -114
View File
@@ -1,117 +1,6 @@
"""Application preferences with simple JSON persistence. # Deprecated legacy shim. Use 'thechart.core.preferences' instead.
API stays minimal: get_pref/set_pref for reads and writes, plus
load_preferences/save_preferences to manage disk state.
"""
from __future__ import annotations from __future__ import annotations
import json raise ImportError(
import os "src.preferences is removed. Import from 'thechart.core.preferences'."
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()
+4 -419
View File
@@ -1,421 +1,6 @@
"""Search and filter functionality for TheChart application.""" # Deprecated legacy shim. Use 'thechart.search.search_filter' instead.
from __future__ import annotations
import re raise ImportError(
from typing import Any "src.search_filter is removed. Import from 'thechart.search.search_filter'."
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 = {}
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
+4 -760
View File
@@ -1,762 +1,6 @@
"""Search and filter UI components for TheChart application.""" # Deprecated legacy shim. Use 'thechart.ui.search_filter_ui' instead.
from __future__ import annotations
import tkinter as tk raise ImportError(
from collections.abc import Callable "src.search_filter_ui is removed. Import from 'thechart.ui.search_filter_ui'."
from tkinter import messagebox, ttk
from init import logger
from preferences import get_pref, save_preferences, set_pref
from search_filter 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 = {}
self.pathology_min_vars = {}
self.pathology_max_vars = {}
# Build UI immediately so tests can access widgets/vars without calling show()
self._setup_ui()
self._bind_events()
self._ui_initialized = True
def _setup_ui(self) -> None:
"""Set up the search and filter UI."""
# Main container
self.frame = ttk.LabelFrame(self.parent, text="Search & Filter", padding="5")
# Create main content frame without scrolling - use horizontal layout
content_frame = ttk.Frame(self.frame)
content_frame.pack(fill="both", expand=True)
# Top row: Search and Quick filters
# Top row: Presets, Search and Quick filters
top_row = ttk.Frame(content_frame)
top_row.pack(fill="x", pady=(0, 5))
# Presets section (leftmost)
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 (left side of top row)
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)
clear_search_btn = ttk.Button(
search_frame, text="Clear", command=self._clear_search
)
clear_search_btn.pack(side="left")
# Quick filter buttons (right side of top row)
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),
("Clear All", self._clear_all_filters),
]
for text, command in quick_buttons:
btn = ttk.Button(quick_frame, text=text, command=command)
btn.pack(side="left", padx=(0, 3))
# Bottom row: Date range, Medicines, and Pathologies in columns
bottom_row = ttk.Frame(content_frame)
bottom_row.pack(fill="both", expand=True)
# Date range section (left column)
date_frame = ttk.LabelFrame(bottom_row, text="Date Range", padding="3")
date_frame.pack(side="left", fill="y", padx=(0, 5))
date_grid = ttk.Frame(date_frame)
date_grid.pack(fill="both")
ttk.Label(date_grid, text="From:").grid(row=0, column=0, sticky="w", pady=2)
ttk.Entry(date_grid, textvariable=self.start_date_var, width=12).grid(
row=1, column=0, sticky="ew", pady=2
)
ttk.Label(date_grid, text="To:").grid(row=2, column=0, sticky="w", pady=(5, 2))
ttk.Entry(date_grid, textvariable=self.end_date_var, width=12).grid(
row=3, column=0, sticky="ew", pady=2
)
# Medicine filters (middle column)
if self.medicine_manager.get_medicine_keys():
med_frame = ttk.LabelFrame(bottom_row, text="Medicines", padding="3")
med_frame.pack(side="left", fill="both", expand=True, padx=(0, 5))
med_grid = ttk.Frame(med_frame)
med_grid.pack(fill="both", expand=True)
# Configure grid to expand properly
med_grid.columnconfigure(0, weight=1)
med_grid.columnconfigure(1, weight=1)
medicine_keys = list(self.medicine_manager.get_medicine_keys())
for i, medicine_key in enumerate(medicine_keys):
medicine = self.medicine_manager.get_medicine(medicine_key)
if medicine:
var = tk.StringVar(value="any")
self.medicine_vars[medicine_key] = var
row = i // 2 # 2 per row for better horizontal layout
col = i % 2
frame = ttk.Frame(med_grid)
frame.grid(row=row, column=col, padx=3, pady=2, sticky="ew")
# Shorter label for horizontal layout
display_name = medicine.display_name
label = (
display_name[:10] + ":"
if len(display_name) > 10
else display_name + ":"
)
ttk.Label(frame, text=label, width=11).pack(side="left")
combo = ttk.Combobox(
frame,
textvariable=var,
values=["any", "taken", "not taken"],
state="readonly",
width=10,
)
combo.pack(side="left", padx=(2, 0), fill="x", expand=True)
# Pathology filters (right column)
if self.pathology_manager.get_pathology_keys():
path_frame = ttk.LabelFrame(
bottom_row, text="Pathology Scores", padding="3"
)
path_frame.pack(side="left", fill="both", expand=True)
path_grid = ttk.Frame(path_frame)
path_grid.pack(fill="both", expand=True)
pathology_keys = self.pathology_manager.get_pathology_keys()
for pathology_key in pathology_keys:
pathology = self.pathology_manager.get_pathology(pathology_key)
if pathology:
min_var = tk.StringVar()
max_var = tk.StringVar()
self.pathology_min_vars[pathology_key] = min_var
self.pathology_max_vars[pathology_key] = max_var
# Display all pathologies vertically in the right column
display_name = pathology.display_name
label = (
display_name[:12] if len(display_name) > 12 else display_name
)
# Create a frame for each pathology row
path_row = ttk.Frame(path_grid)
path_row.pack(fill="x", pady=1)
ttk.Label(path_row, text=label + ":", width=13).pack(side="left")
ttk.Label(path_row, text="Min:").pack(side="left", padx=(5, 2))
ttk.Entry(path_row, textvariable=min_var, width=4).pack(side="left")
ttk.Label(path_row, text="Max:").pack(side="left", padx=(5, 2))
ttk.Entry(path_row, textvariable=max_var, width=4).pack(side="left")
# Apply filters button and status (bottom)
apply_frame = ttk.Frame(content_frame)
apply_frame.pack(fill="x", pady=(10, 0))
apply_btn = ttk.Button(
apply_frame, text="Apply Filters", command=self._apply_filters
)
apply_btn.pack(side="left")
# Filter status
self.status_label = ttk.Label(apply_frame, text="No filters active")
self.status_label.pack(side="right")
def _bind_events(self) -> None:
"""Bind events for real-time updates with debouncing."""
# Update filters when search changes (debounced)
self.search_var.trace("w", lambda *args: self._debounced_update())
# Update filters when date range changes (debounced)
self.start_date_var.trace("w", lambda *args: self._debounced_update())
self.end_date_var.trace("w", lambda *args: self._debounced_update())
# Update filters when medicine selections change (debounced)
for var in self.medicine_vars.values():
var.trace("w", lambda *args: self._debounced_update())
# Update filters when pathology ranges change (debounced)
pathology_vars = list(self.pathology_min_vars.values()) + list(
self.pathology_max_vars.values()
)
for var in pathology_vars:
var.trace("w", lambda *args: self._debounced_update())
def _debounced_update(self) -> None:
"""Update filters with debouncing to prevent excessive calls."""
import contextlib
# Skip if we're performing a programmatic UI sync
if getattr(self, "_suspend_traces", False):
return
# Cancel any pending update
if self._update_timer:
with contextlib.suppress(tk.TclError):
self.parent.after_cancel(self._update_timer)
if self._debounce_delay and self._debounce_delay > 0:
# Schedule a new update
self._update_timer = self.parent.after(
self._debounce_delay, self._execute_filter_update
)
else:
# Immediate for tests/headless runs
self._execute_filter_update()
def _execute_filter_update(self) -> None:
"""Execute the actual filter update."""
self._update_timer = None
self._on_search_change()
self._on_date_change()
self._on_medicine_change()
self._on_pathology_change()
# Only call the update callback once after all filters are applied
self.update_callback()
def _on_search_change(self) -> None:
"""Handle search term changes."""
search_term = self.search_var.get()
self.data_filter.set_search_term(search_term)
if search_term:
self.search_history.add_search(search_term)
self._update_status()
def _on_date_change(self) -> None:
"""Handle date range changes."""
start_date = self.start_date_var.get().strip() or None
end_date = self.end_date_var.get().strip() or None
self.data_filter.set_date_range_filter(start_date, end_date)
self._update_status()
def _on_medicine_change(self) -> None:
"""Handle medicine filter changes."""
# Clear existing medicine filters
self.data_filter.clear_filter("medicines")
for medicine_key, var in self.medicine_vars.items():
value = var.get()
if value == "taken":
self.data_filter.set_medicine_filter(medicine_key, True)
elif value == "not taken":
self.data_filter.set_medicine_filter(medicine_key, False)
self._update_status()
def _on_pathology_change(self) -> None:
"""Handle pathology filter changes."""
# Clear existing pathology filters
self.data_filter.clear_filter("pathologies")
for pathology_key in self.pathology_min_vars:
min_val = self.pathology_min_vars[pathology_key].get().strip()
max_val = self.pathology_max_vars[pathology_key].get().strip()
min_score = None
max_score = None
try:
if min_val:
min_score = int(min_val)
if max_val:
max_score = int(max_val)
except ValueError:
continue # Skip invalid entries
if min_score is not None or max_score is not None:
self.data_filter.set_pathology_range_filter(
pathology_key, min_score, max_score
)
self._update_status()
def _apply_filters(self) -> None:
"""Manually apply all current filter settings."""
self._on_search_change()
self._on_date_change()
self._on_medicine_change()
self._on_pathology_change()
def _clear_search(self) -> None:
"""Clear search term."""
self.search_var.set("")
def _clear_all_filters(self) -> None:
"""Clear all filters and search terms."""
# Clear search
self.search_var.set("")
# Clear date range
self.start_date_var.set("")
self.end_date_var.set("")
# Clear medicine filters
for var in self.medicine_vars.values():
var.set("any")
# Clear pathology filters
for var in self.pathology_min_vars.values():
var.set("")
for var in self.pathology_max_vars.values():
var.set("")
# Clear data filter
self.data_filter.clear_all_filters()
self._update_status()
self.update_callback()
def _filter_last_week(self) -> None:
"""Apply last week filter."""
# Re-resolve from source module so tests patching src.search_filter work
from src.search_filter import QuickFilters as _QF # type: ignore
_QF.last_week(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_last_month(self) -> None:
"""Apply last month filter."""
from src.search_filter import QuickFilters as _QF # type: ignore
_QF.last_month(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_this_month(self) -> None:
"""Apply this month filter."""
QuickFilters.this_month(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_high_symptoms(self) -> None:
"""Apply high symptoms filter."""
pathology_keys = self.pathology_manager.get_pathology_keys()
from src.search_filter import QuickFilters as _QF # type: ignore
_QF.high_symptoms(self.data_filter, pathology_keys)
self._update_pathology_ui()
self._update_status()
self.update_callback()
def _update_date_ui(self) -> None:
"""Update date UI controls to reflect current filter."""
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:
"""Update pathology UI controls to reflect current filters."""
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:
"""Update filter status display."""
# If UI hasn't been set up yet (e.g., during headless tests), skip.
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:
filter_parts = []
if summary["search_term"]:
filter_parts.append(f"Search: '{summary['search_term']}'")
if "date_range" in summary["filters"]:
date_info = summary["filters"]["date_range"]
filter_parts.append(f"Date: {date_info['start']} - {date_info['end']}")
if "medicines" in summary["filters"]:
med_info = summary["filters"]["medicines"]
if med_info["taken"]:
filter_parts.append(f"Taken: {len(med_info['taken'])} medicines")
if med_info["not_taken"]:
not_taken_count = len(med_info["not_taken"])
filter_parts.append(f"Not taken: {not_taken_count} medicines")
if "pathologies" in summary["filters"]:
path_count = len(summary["filters"]["pathologies"])
filter_parts.append(f"Pathology ranges: {path_count}")
status_text = "Active filters: " + ", ".join(filter_parts)
if len(status_text) > 60:
status_text = status_text[:57] + "..."
self.status_label.config(text=status_text)
# ---------------------
# Presets management
# ---------------------
def _refresh_presets_combo(self) -> None:
presets = get_pref("filter_presets", {}) or {}
names = sorted(presets.keys())
if hasattr(self, "preset_combo") and self.preset_combo:
self.preset_combo["values"] = names
if names and not self.preset_var.get():
self.preset_var.set(names[0])
def _apply_filter_summary(self, summary: dict) -> None:
"""Apply a saved summary dict into the DataFilter and UI, then update."""
import contextlib
if not isinstance(summary, dict):
return
# Prevent trace callbacks while applying preset
self._suspend_traces = True
try:
# Clear existing filters first
self.data_filter.clear_all_filters()
# Apply search term and update UI to match
_search = summary.get("search_term", "")
self.search_var.set(_search)
self.data_filter.set_search_term(_search)
# Apply other filters from summary
filt = summary.get("filters", {}) or {}
# Date
date_rng = filt.get("date_range") or {}
self.data_filter.set_date_range_filter(
date_rng.get("start") or None, date_rng.get("end") or None
)
# Medicines
meds = filt.get("medicines") or {}
for key in meds.get("taken", []) or []:
self.data_filter.set_medicine_filter(key, True)
for key in meds.get("not_taken", []) or []:
self.data_filter.set_medicine_filter(key, False)
# Pathologies
paths = filt.get("pathologies") or {}
for key, range_text in paths.items():
with contextlib.suppress(Exception):
s = str(range_text)
parts = s.split("-")
mn = parts[0].strip() if parts else ""
mx = parts[1].strip() if len(parts) > 1 else ""
mn_i = int(mn) if mn and mn.lower() != "any" else None
mx_i = int(mx) if mx and mx.lower() != "any" else None
self.data_filter.set_pathology_range_filter(key, mn_i, mx_i)
finally:
self._suspend_traces = False
# Sync UI from current DataFilter state and notify
self.sync_ui_from_filter()
self.update_callback()
def _load_preset(self) -> None:
name = self.preset_var.get().strip()
if not name:
return
presets = get_pref("filter_presets", {}) or {}
summary = presets.get(name)
if not summary:
messagebox.showwarning("Preset", f"Preset '{name}' not found.")
return
self._apply_filter_summary(summary)
def _save_preset(self) -> None:
# Ask for a name via themed modal dialog
name = self._ask_preset_name(initial=self.preset_var.get().strip())
if not name:
return
presets = get_pref("filter_presets", {}) or {}
if name in presets and not messagebox.askyesno(
"Overwrite Preset",
f"Preset '{name}' exists. Overwrite?",
parent=self.parent,
):
return
presets[name] = self.data_filter.get_filter_summary()
set_pref("filter_presets", presets)
save_preferences()
self._refresh_presets_combo()
self.preset_var.set(name)
self._update_status()
def _ask_preset_name(self, initial: str = "") -> str | None:
"""Prompt for a preset name using a themed ttk modal dialog.
Shows a lightweight hint if the name already exists (will overwrite)
or is new (will create). Returns the entered name (stripped) or None
if cancelled.
"""
result: dict[str, str | None] = {"value": None}
top = tk.Toplevel(self.parent)
top.title("Save Preset")
top.transient(self.parent)
top.grab_set()
frame = ttk.Frame(top, padding="10")
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="Preset name:").pack(anchor="w")
name_var = tk.StringVar(value=initial)
entry = ttk.Entry(frame, textvariable=name_var, width=32)
entry.pack(fill="x", pady=(4, 6))
# Live status about overwrite vs create
status_var = tk.StringVar(value="")
status_label = ttk.Label(frame, textvariable=status_var)
status_label.pack(anchor="w", pady=(0, 10))
def _update_status(*_args: object) -> None:
presets = get_pref("filter_presets", {}) or {}
value = (name_var.get() or "").strip()
if not value:
status_var.set("")
elif value in presets:
status_var.set("Existing preset found: will overwrite")
else:
status_var.set("New preset: will create")
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
def on_ok() -> None:
value = (name_var.get() or "").strip()
if not value:
messagebox.showwarning(
"Save Preset", "Please enter a name.", parent=top
)
return
result["value"] = value
top.destroy()
def on_cancel() -> None:
result["value"] = None
top.destroy()
cancel_btn = ttk.Button(buttons, text="Cancel", command=on_cancel)
cancel_btn.pack(side="right")
ok_btn = ttk.Button(buttons, text="Save", command=on_ok)
ok_btn.pack(side="right", padx=(6, 0))
# Key bindings
entry.bind("<Return>", lambda e: on_ok())
entry.bind("<Escape>", lambda e: on_cancel())
# Center the dialog relative to parent
top.update_idletasks()
px, py = self.parent.winfo_rootx(), self.parent.winfo_rooty()
pw, ph = self.parent.winfo_width(), self.parent.winfo_height()
ww, wh = top.winfo_width(), top.winfo_height()
x = px + (pw // 2) - (ww // 2)
y = py + (ph // 2) - (wh // 2)
top.geometry(f"+{x}+{y}")
# Initialize live status and focus
_update_status()
name_var.trace_add("write", _update_status) # update as user types
entry.focus_set()
top.wait_window()
return result["value"]
def _delete_preset(self) -> None:
name = self.preset_var.get().strip()
if not name:
return
if not messagebox.askyesno(
"Delete Preset", f"Delete preset '{name}'?", parent=self.parent
):
return
presets = get_pref("filter_presets", {}) or {}
if name in presets:
del presets[name]
set_pref("filter_presets", presets)
save_preferences()
self.preset_var.set("")
self._refresh_presets_combo()
def get_widget(self) -> ttk.LabelFrame | None:
"""Get the main widget for embedding in UI (may be None until shown)."""
return self.frame
def sync_ui_from_filter(self) -> None:
"""Synchronize the UI controls with the current DataFilter state.
Best-effort: silently ignores keys not present in the UI (e.g., when
managers have changed). Does not trigger an immediate callback; traces
may schedule a debounced update which is acceptable.
"""
# Perform UI updates without firing trace handlers
import contextlib
self._suspend_traces = True
try:
# Search term
with contextlib.suppress(Exception):
# Only overwrite UI if DataFilter exposes a concrete string value;
# this avoids clobbering the UI with MagicMock objects in tests.
val = getattr(self.data_filter, "search_term", "")
if isinstance(val, str):
self.search_var.set(val)
# Date range (only if present in active filters)
with contextlib.suppress(Exception):
active = getattr(self.data_filter, "active_filters", {}) or {}
if "date_range" in active:
date_filter = active.get("date_range", {})
self.start_date_var.set(date_filter.get("start", "") or "")
self.end_date_var.set(date_filter.get("end", "") or "")
# Medicine filters
with contextlib.suppress(Exception):
active = getattr(self.data_filter, "active_filters", {}) or {}
meds = active.get("medicines", {})
for key, var in self.medicine_vars.items():
if key in meds:
var.set("taken" if meds[key] else "not taken")
else:
var.set("any")
# Pathology ranges
with contextlib.suppress(Exception):
active = getattr(self.data_filter, "active_filters", {}) or {}
paths = active.get("pathologies", {})
for key, rng in paths.items():
if key in self.pathology_min_vars:
mn = rng.get("min")
self.pathology_min_vars[key].set("" if mn is None else str(mn))
if key in self.pathology_max_vars:
mx = rng.get("max")
self.pathology_max_vars[key].set("" if mx is None else str(mx))
finally:
self._suspend_traces = False
# Update status text (safe, does not trigger traces)
self._update_status()
def show(self) -> None:
"""Show the search filter widget and configure the parent row."""
if not self._ui_initialized:
self._setup_ui()
self._bind_events()
self._ui_initialized = True
assert self.frame is not None
self.frame.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=5, pady=2)
# Configure the parent grid row for horizontal layout (smaller minsize)
if hasattr(self.parent, "grid_rowconfigure"):
self.parent.grid_rowconfigure(1, minsize=150, weight=0)
self.is_visible = True
logger.debug("Search filter widget shown and parent row configured.")
def hide(self) -> None:
"""Hide the search filter widget and reset the parent row."""
if not self.frame:
return
self.frame.grid_remove()
# Reset the parent grid row to not allocate space when hidden
if hasattr(self.parent, "grid_rowconfigure"):
self.parent.grid_rowconfigure(1, minsize=0, weight=0)
self.is_visible = False
logger.debug("Search filter widget hidden and parent row reset.")
def toggle(self) -> None:
"""Toggle visibility of the search and filter widget."""
if self.is_visible:
self.hide()
else:
self.show()
+8 -575
View File
@@ -1,578 +1,11 @@
"""Settings window for TheChart application.""" """Shim for backward compatibility.
import contextlib Re-exports canonical implementation from thechart.ui.settings_window.
import os """
import sys
import tkinter as tk
from tkinter import messagebox, ttk
from constants import BACKUP_PATH # Deprecated legacy shim. Use 'thechart.ui.settings_window' instead.
from preferences import ( from __future__ import annotations
get_config_dir,
get_pref, raise ImportError(
reset_preferences, "src.settings_window is removed. Import from 'thechart.ui.settings_window'."
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 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()
+65
View File
@@ -0,0 +1,65 @@
"""TheChart package.
This package provides the main application and components for the
TheChart (medication tracker) desktop app.
Notes
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``
and console-script usage.
"""
from __future__ import annotations
from importlib import metadata as _metadata
try: # Prefer installed package version if available
__version__ = _metadata.version("thechart")
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
]
+75
View File
@@ -0,0 +1,75 @@
"""Module entry-point for `python -m thechart` and console scripts.
Prefers the canonical package entrypoint (`thechart.main.run`) and falls back
to locating the development `src/main.py` when running from a repo checkout.
This keeps development and packaging flows simple and robust.
"""
from __future__ import annotations
import importlib
import os
import tkinter as tk
def _load_main_module():
"""Load the existing main module from common locations.
Tries in order:
- importlib.import_module("src.main")
- importlib.import_module("main")
- Load from file path next to this package (.. / main.py)
"""
try:
return importlib.import_module("src.main")
except Exception:
pass
try:
return importlib.import_module("main")
except Exception:
pass
# File-based fallback for src layout in editable/dev mode
try:
from importlib.machinery import SourceFileLoader
here = os.path.dirname(__file__)
candidate = os.path.abspath(os.path.join(here, os.pardir, "main.py"))
if os.path.exists(candidate):
return SourceFileLoader("main", candidate).load_module() # type: ignore[deprecated-import]
except Exception:
pass
raise ImportError("Could not locate application main module")
def main() -> None:
"""Start the TheChart application."""
# Preferred path: use the package entrypoint directly
try:
from .main import run as _run # Local import to avoid circulars in edge cases
_run()
return
except Exception:
# Fall back to dynamic resolution used during development
pass
mod = _load_main_module()
# Prefer a run() entry if available
try:
run_fn = mod.run # type: ignore[attr-defined]
except AttributeError:
run_fn = None # type: ignore[assignment]
if callable(run_fn): # type: ignore[truthy-function]
run_fn()
return
# Very old fallback: directly instantiate if run() is absent
root = tk.Tk()
app_cls = mod.MedTrackerApp # type: ignore[attr-defined]
_ = app_cls(root)
root.mainloop()
if __name__ == "__main__": # pragma: no cover - simple dispatcher
main()
+7
View File
@@ -0,0 +1,7 @@
"""Analytics layer re-exports for TheChart."""
from __future__ import annotations
from .graph_manager import GraphManager # noqa: F401
__all__ = ["GraphManager"]
+478
View File
@@ -0,0 +1,478 @@
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
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
s = str(dose_str).strip()
if not s or s.lower() == "nan":
return 0.0
# Split entries by '|'
entries = [e.strip() for e in s.split("|") if e.strip()]
total = 0.0
def _to_float(token: str) -> float:
token = token.strip()
# Remove bullet characters and common symbols
token = token.replace("", " ")
# Take substring after last ':' if present (timestamp:value)
if ":" in token:
token = token.rsplit(":", 1)[-1]
# Remove units like 'mg' and any trailing non-numeric except '.'
import re as _re
m = _re.search(r"([0-9]+(?:\.[0-9]+)?)", token)
if not m:
return 0.0
try:
return float(m.group(1))
except Exception:
return 0.0
for entry in entries:
total += _to_float(entry)
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"]
+54
View File
@@ -0,0 +1,54 @@
"""Core re-exports for TheChart.
This module exposes a stable, curated public API for core facilities.
Prefer importing from here in application code and scripts.
"""
from __future__ import annotations
# Explicit, stable exports (avoid star imports for clarity)
from .auto_save import AutoSaveManager, BackupManager
from .constants import BACKUP_PATH, LOG_CLEAR, LOG_LEVEL, LOG_PATH
from .error_handler import (
ErrorHandler,
OperationTimer,
UserFeedback,
handle_exceptions,
)
from .logger import init_logger
from .preferences import (
get_config_dir,
get_pref,
load_preferences,
reset_preferences,
save_preferences,
set_pref,
)
from .undo_manager import UndoAction, UndoManager
__all__ = [
# logging/constants
"init_logger",
"LOG_LEVEL",
"LOG_PATH",
"LOG_CLEAR",
"BACKUP_PATH",
# preferences
"get_config_dir",
"get_pref",
"load_preferences",
"reset_preferences",
"save_preferences",
"set_pref",
# auto-save / backup
"AutoSaveManager",
"BackupManager",
# error handling
"ErrorHandler",
"OperationTimer",
"handle_exceptions",
"UserFeedback",
# undo
"UndoAction",
"UndoManager",
]
+363
View File
@@ -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",
]
+49
View File
@@ -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",
]
+258
View File
@@ -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",
]
+104
View File
@@ -0,0 +1,104 @@
"""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 configuration (colored if colorlog available)
if colorlog is not None:
# Tests expect basicConfig from colorlog to be used with a bold + color format
bold_seq = "\033[1m"
colorlog_format = f"{bold_seq} %(log_color)s {log_format}"
# Configure root/console via colorlog.basicConfig
try:
colorlog.basicConfig(level=logger.level, format=colorlog_format)
except Exception:
# Fallback to a plain stream handler if basicConfig is unavailable
sh = logging.StreamHandler()
sh.setLevel(logger.level)
sh.setFormatter(logging.Formatter(log_format))
logger.addHandler(sh)
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
+117
View File
@@ -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()
+36
View File
@@ -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"]
+11
View File
@@ -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"]
+541
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
"""Export subsystem public API."""
from __future__ import annotations
from .export_manager import ExportManager # noqa: F401
__all__ = ["ExportManager"]
+541
View File
@@ -0,0 +1,541 @@
"""
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
import weakref
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
# Track created artifacts to allow best-effort cleanup in tests
self._created_files = set() # type: ignore[var-annotated]
self._temp_dirs = set() # type: ignore[var-annotated]
# Register a finalizer to remove created files/dirs when this object is
# collected
def _cleanup(paths: list[str], temp_dirs: list[str]) -> None:
for f in list(paths):
with contextlib.suppress(Exception):
if os.path.exists(f):
os.remove(f)
for td in list(temp_dirs):
with contextlib.suppress(Exception):
p = Path(td)
if p.exists():
for child in list(p.iterdir()):
with contextlib.suppress(Exception):
if child.is_file():
child.unlink()
with contextlib.suppress(Exception):
p.rmdir()
self._finalizer = weakref.finalize(
self,
_cleanup,
paths=list(self._created_files),
temp_dirs=list(self._temp_dirs),
)
def get_export_info(self, df: pd.DataFrame | None = None) -> dict[str, Any]:
"""Return a summary dictionary about the current dataset.
Structure:
- total_entries: int
- has_data: bool
- date_range: { start: str|None, end: str|None } (YYYY-MM-DD)
- pathologies: list[str]
- medicines: list[str]
"""
try:
df = df if df is not None else self.data_manager.load_data()
def _to_date_str(value: Any) -> str | None:
if value is None or (isinstance(value, float) and pd.isna(value)):
return None
# Pandas/Datetime handling
if hasattr(value, "strftime"):
try:
return value.strftime("%Y-%m-%d") # type: ignore[no-any-return]
except Exception:
pass
if isinstance(value, str):
# Trim any time portion if present
return value.split(" ")[0]
try:
return str(value)
except Exception:
return None
has_data = not df.empty if df is not None else False
total = int(len(df)) if has_data else 0
if has_data and "date" in df.columns:
start_raw = df["date"].min()
end_raw = df["date"].max()
start = _to_date_str(start_raw)
end = _to_date_str(end_raw)
else:
start = None
end = None
info = {
"total_entries": total,
"has_data": has_data,
"date_range": {"start": start, "end": end},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
}
return info
except Exception as e: # pragma: no cover - defensive
self.logger.error(f"Failed to build export info: {e}")
return {
"total_entries": 0,
"has_data": False,
"date_range": {"start": None, "end": None},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
}
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}")
# Track for potential cleanup (used by tests' teardown)
with contextlib.suppress(Exception):
self._created_files.add(str(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}")
# Track for potential cleanup (used by tests' teardown)
with contextlib.suppress(Exception):
self._created_files.add(str(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 (non-fatal if missing)
if include_graph:
temp_dir = Path(export_path).parent / "temp_export"
graph_path: str | None = None
# Track temp dir for later cleanup after PDF is built
with contextlib.suppress(Exception):
self._temp_dirs.add(str(temp_dir))
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
img = Image(graph_path, width=9 * inch, height=5.4 * inch)
story.append(img)
else:
# Graph not available, add a note instead and continue
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"],
)
)
# 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
# Compute reasonable column widths to satisfy tests
# Allocate wider width to the 'note' column
from reportlab.lib.units import inch as _inch
base_width = 0.8 * _inch
col_widths = [base_width for _ in columns]
with contextlib.suppress(Exception):
note_idx = columns.index("note")
col_widths[note_idx] = 2.5 * _inch
table = Table(data, repeatRows=1, colWidths=col_widths)
# 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 with graceful fallback on errors
try:
doc.build(story)
except Exception as build_err:
# Fallback: try to build a minimal PDF so export still succeeds
self.logger.error(
("Error building full PDF: %s; falling back to minimal export."),
build_err,
)
try:
fallback_doc = SimpleDocTemplate(
export_path,
pagesize=landscape(A4),
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18,
)
fallback_story = [
Paragraph("TheChart - Medication Tracker Export", title_style),
Spacer(1, 10),
Paragraph(
"Export generated with limited content due to an error.",
styles["Normal"],
),
]
fallback_doc.build(fallback_story)
except Exception as fallback_err:
self.logger.error(f"Error exporting to PDF: {fallback_err}")
return False
finally:
# Clean up temporary graph export directory and files
for td in list(getattr(self, "_temp_dirs", set())):
tdp = Path(td)
with contextlib.suppress(Exception):
if tdp.exists():
for p in list(tdp.iterdir()):
with contextlib.suppress(Exception):
if p.is_file():
p.unlink()
with contextlib.suppress(Exception):
tdp.rmdir()
self.logger.info(f"Data exported to PDF: {export_path}")
# Track for potential cleanup (used by tests' teardown)
with contextlib.suppress(Exception):
self._created_files.add(str(export_path))
return True
except Exception as e:
self.logger.error(f"Error exporting to PDF: {str(e)}")
return False
def __del__(self) -> None: # pragma: no cover - best-effort cleanup for tests
# Attempt to remove created export files so tmp dirs can be removed in tests
for f in list(getattr(self, "_created_files", set())):
with contextlib.suppress(Exception):
if os.path.exists(f):
os.remove(f)
# Clean up any temp export directories we created
for td in list(getattr(self, "_temp_dirs", set())):
with contextlib.suppress(Exception):
from pathlib import Path as _Path
p = _Path(td)
if p.exists():
for child in list(p.iterdir()):
with contextlib.suppress(Exception):
if child.is_file():
child.unlink()
with contextlib.suppress(Exception):
p.rmdir()
__all__ = ["ExportManager"]
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import importlib
"""Compatibility shim for historical `from thechart import main` imports.
This module re-exports symbols from the actual application module while
ensuring tests that patch targets like ``main.UIManager`` or ``main.GraphManager``
continue to work. We prefer importing ``main`` first (so tests patching
``main.*`` hit the right module). If that fails, we fall back to
``src.main`` and also alias it into ``sys.modules['main']`` so that patch
targets still resolve correctly.
"""
# Re-export run() and MedTrackerApp from the located main module
try:
# Prefer a top-level 'main' so tests patching 'main.*' work naturally
_mod = importlib.import_module("main")
except Exception:
try:
# Fall back to 'src.main' when installed as a package
_mod = importlib.import_module("src.main")
# Ensure patch targets like 'main.*' still resolve
import sys as _sys
_sys.modules.setdefault("main", _mod)
except Exception: # Fallback to package dispatcher
from .__main__ import main as _entry_main # type: ignore
def run() -> None: # noqa: D401
"""Run the application."""
_entry_main()
__all__ = ["run"]
else:
from src.main import * # type: ignore # noqa: F401,F403
__all__ = [name for name in dir() if not name.startswith("_")]
else:
from main import * # type: ignore # noqa: F401,F403
__all__ = [name for name in dir() if not name.startswith("_")]
+18
View File
@@ -0,0 +1,18 @@
"""Aggregate re-exports for TheChart managers.
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
# First-party re-exports
from thechart.data import DataManager # noqa: F401
from .managers import ( # noqa: F401
Medicine,
MedicineManager,
Pathology,
PathologyManager,
)
+18
View File
@@ -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",
]
+195
View File
@@ -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()
}
+199
View File
@@ -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")
View File
+13
View File
@@ -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"]
+423
View File
@@ -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
+27
View File
@@ -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",
]
+214
View File
@@ -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"]
+537
View File
@@ -0,0 +1,537 @@
"""Search and filter UI components for TheChart (canonical)."""
# ruff: noqa: I001
from __future__ import annotations
import contextlib
import sys
import tkinter as tk
from collections.abc import Callable
from tkinter import ttk
from ..search import DataFilter
from .. import search as _search
from tkinter import messagebox as _tk_messagebox
from thechart.core.preferences import get_pref as _pref_get
from thechart.core.preferences import save_preferences as _pref_save
from thechart.core.preferences import set_pref as _pref_set
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,
) -> None:
# Core refs
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: ttk.LabelFrame | None = None
self.status_label: ttk.Label | None = None
# Debounce and trace control
self._update_timer = None
self._debounce_delay = 0
self._suspend_traces = False
# UI state variables
self.search_history = _search.SearchHistory()
self.search_var = tk.StringVar()
self.start_date_var = tk.StringVar()
self.end_date_var = tk.StringVar()
self.preset_var = tk.StringVar()
# Filters' 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
self._setup_ui()
self._bind_events()
self._ui_initialized = True
def _setup_ui(self) -> None:
self.frame = ttk.LabelFrame(self.parent, text="Search & Filter", padding=5)
content = ttk.Frame(self.frame)
content.pack(fill="both", expand=True)
top = ttk.Frame(content)
top.pack(fill="x", pady=(0, 5))
# Presets section
presets = ttk.Frame(top)
presets.pack(side="left", padx=(0, 10))
ttk.Label(presets, text="Preset:").pack(side="left")
self.preset_combo = ttk.Combobox(
presets, textvariable=self.preset_var, state="readonly", width=18
)
self._refresh_presets_combo()
self.preset_combo.pack(side="left", padx=(5, 5))
ttk.Button(presets, text="Load", command=self._load_preset).pack(
side="left", padx=(0, 2)
)
ttk.Button(presets, text="Save", command=self._save_preset).pack(
side="left", padx=(0, 2)
)
ttk.Button(presets, text="Delete", command=self._delete_preset).pack(
side="left"
)
# Search section
search_row = ttk.Frame(top)
search_row.pack(side="left", fill="x", expand=True, padx=(0, 10))
ttk.Label(search_row, text="Search:").pack(side="left")
ttk.Entry(search_row, textvariable=self.search_var).pack(
side="left", padx=(5, 5), fill="x", expand=True
)
ttk.Button(search_row, text="Clear", command=self._clear_search).pack(
side="left"
)
# Quick filters
quick = ttk.Frame(top)
quick.pack(side="right")
ttk.Label(quick, text="Quick:").pack(side="left", padx=(0, 5))
for label, cmd in [
("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),
]:
ttk.Button(quick, text=label, command=cmd).pack(side="left", padx=2)
# Date range row
dates = ttk.Frame(content)
dates.pack(fill="x", pady=(0, 5))
ttk.Label(dates, text="Start Date (YYYY-MM-DD):").pack(side="left")
ttk.Entry(dates, textvariable=self.start_date_var, width=12).pack(
side="left", padx=(5, 10)
)
ttk.Label(dates, text="End Date (YYYY-MM-DD):").pack(side="left")
ttk.Entry(dates, textvariable=self.end_date_var, width=12).pack(
side="left", padx=(5, 10)
)
ttk.Button(dates, text="Apply", command=self._apply_date_filter).pack(
side="left"
)
# Middle row: medicines and pathologies
middle = ttk.Frame(content)
middle.pack(fill="x", pady=(0, 5))
meds = ttk.LabelFrame(middle, text="Medicines", padding=5)
meds.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
row = ttk.Frame(meds)
row.pack(fill="x", padx=2, pady=1)
ttk.Label(row, text=med.display_name).pack(side="left")
ttk.Radiobutton(row, text="Any", variable=var, value="any").pack(
side="left", padx=2
)
ttk.Radiobutton(row, text="Taken", variable=var, value="taken").pack(
side="left", padx=2
)
ttk.Radiobutton(
row, text="Not taken", variable=var, value="not_taken"
).pack(side="left", padx=2)
paths = ttk.LabelFrame(middle, text="Pathologies", padding=5)
paths.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(paths)
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 = ttk.Frame(content)
bottom.pack(fill="x")
ttk.Button(bottom, text="Clear All", command=self._clear_all_filters).pack(
side="left"
)
self.status_label = ttk.Label(bottom, text="No filters active")
self.status_label.pack(side="right")
def _bind_events(self) -> None:
self.search_var.trace_add("write", lambda *_: self._on_search_change())
self.start_date_var.trace_add("write", lambda *_: self._on_date_change())
self.end_date_var.trace_add("write", lambda *_: self._on_date_change())
for key, var in self.medicine_vars.items():
var.trace_add("write", lambda *_a, k=key: self._on_medicine_change(k))
for key in self.pathology_min_vars:
self.pathology_min_vars[key].trace_add(
"write", lambda *_a, k=key: self._on_pathology_change(k)
)
self.pathology_max_vars[key].trace_add(
"write", lambda *_a, k=key: self._on_pathology_change(k)
)
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 _on_medicine_change(self, med_key: str) -> None:
if self._suspend_traces:
return
val = (self.medicine_vars.get(med_key) or tk.StringVar()).get()
if val == "any":
self.data_filter.set_medicine_filter(med_key, None)
else:
self.data_filter.set_medicine_filter(med_key, val == "taken")
self._update_status()
self.update_callback()
def _on_pathology_change(self, path_key: str) -> None:
if self._suspend_traces:
return
min_v = self.pathology_min_vars.get(path_key, tk.StringVar()).get()
max_v = self.pathology_max_vars.get(path_key, tk.StringVar()).get()
try:
min_i = int(min_v) if str(min_v).strip() != "" else None
except Exception:
min_i = None
try:
max_i = int(max_v) if str(max_v).strip() != "" else None
except Exception:
max_i = None
if min_i is None and max_i is None:
with contextlib.suppress(Exception):
self.data_filter.clear_pathology_filter(path_key)
else:
self.data_filter.set_pathology_range_filter(path_key, min_i, max_i)
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:
# Apply preset for last week
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.last_week(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_last_month(self) -> None:
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.last_month(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_this_month(self) -> None:
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.this_month(self.data_filter)
self._update_date_ui()
self._update_status()
self.update_callback()
def _filter_high_symptoms(self) -> None:
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.high_symptoms(self.data_filter, self.pathology_manager.get_pathology_keys())
self._update_pathology_ui()
self._update_status()
self.update_callback()
def _filter_low_symptoms(self) -> None:
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.low_symptoms(self.data_filter, self.pathology_manager.get_pathology_keys())
self._update_pathology_ui()
self._update_status()
self.update_callback()
def _filter_no_medication(self) -> None:
mod = sys.modules.get("thechart.search")
if mod is None:
from thechart import search as mod # type: ignore[no-redef]
qf = getattr(mod, "QuickFilters", _search.QuickFilters)
qf.no_medication(self.data_filter, self.medicine_manager.get_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:
d = active["date_range"]
self.start_date_var.set(d.get("start", ""))
self.end_date_var.set(d.get("end", ""))
def _update_pathology_ui(self) -> None:
active = getattr(self.data_filter, "active_filters", {}) or {}
if "pathologies" in active:
p = active["pathologies"]
for k, v in p.items():
if k in self.pathology_min_vars:
if (mv := v.get("min")) is not None:
self.pathology_min_vars[k].set(str(mv))
if (xv := v.get("max")) is not None:
self.pathology_max_vars[k].set(str(xv))
def _update_status(self) -> None:
if not getattr(self, "status_label", None):
return
summary = self.data_filter.get_filter_summary()
if not summary.get("has_filters"):
self.status_label.config(text="No filters active")
return
parts: list[str] = ["Active filters"]
if summary.get("search_term"):
parts.append(f"Search: '{summary['search_term']}'")
f = summary.get("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.get("taken"):
parts.append("Taken: " + ", ".join(m["taken"]))
if m.get("not_taken"):
parts.append("Not taken: " + ", ".join(m["not_taken"]))
if "pathologies" in f:
parts.extend([f"{k}: {v}" for k, v in f["pathologies"].items()])
self.status_label.config(text=" | ".join(parts))
def get_widget(self) -> ttk.LabelFrame:
assert self.frame is not None
return self.frame
def show(self) -> None:
if self.is_visible:
return
self.is_visible = True
assert self.frame is not None
self.frame.pack(fill="x", padx=5, pady=5)
parent = self.frame.master
if hasattr(parent, "grid_rowconfigure"):
with contextlib.suppress(Exception):
parent.grid_rowconfigure(1, minsize=150, weight=0)
def hide(self) -> None:
if not self.is_visible:
return
self.is_visible = False
assert self.frame is not None
self.frame.pack_forget()
parent = self.frame.master
if hasattr(parent, "grid_rowconfigure"):
with contextlib.suppress(Exception):
parent.grid_rowconfigure(1, minsize=0, weight=0)
def toggle(self) -> None:
if self.is_visible:
self.hide()
else:
self.show()
def _apply_filters(self) -> None:
try:
self.data_filter.set_search_term(self.search_var.get())
self.data_filter.set_date_range_filter(
self.start_date_var.get() or None, self.end_date_var.get() or None
)
for key, var in self.medicine_vars.items():
v = var.get()
self.data_filter.set_medicine_filter(
key, None if v == "any" else (v == "taken")
)
for key in self.pathology_min_vars:
min_v = self.pathology_min_vars[key].get()
max_v = self.pathology_max_vars[key].get()
try:
mn = int(min_v) if str(min_v).strip() else None
except Exception:
mn = None
try:
mx = int(max_v) if str(max_v).strip() else None
except Exception:
mx = None
self.data_filter.set_pathology_range_filter(key, mn, mx)
self._update_status()
self.update_callback()
except Exception:
pass
# Stubs in canonical; shim overrides with full impl
def _refresh_presets_combo(self) -> None:
presets = _pref_get("filter_presets", {}) or {}
values = sorted(presets.keys()) if isinstance(presets, dict) else []
with contextlib.suppress(Exception):
self.preset_combo["values"] = values
def _ask_preset_name(self, initial: str = "") -> str:
# Minimal input mechanism; tests monkeypatch this method
return initial or ""
def _save_preset(self) -> None:
name = self._ask_preset_name(self.preset_var.get())
if not name:
return
presets = _pref_get("filter_presets", {}) or {}
try:
summary = self.data_filter.get_filter_summary()
except Exception:
summary = {"has_filters": False, "filters": {}, "search_term": ""}
presets[name] = summary
_pref_set("filter_presets", presets)
_pref_save()
self.preset_var.set(name)
self._refresh_presets_combo()
def _load_preset(self) -> None:
name = self.preset_var.get()
presets = _pref_get("filter_presets", {}) or {}
summary = presets.get(name)
if not summary:
with contextlib.suppress(Exception):
_tk_messagebox.showwarning("Load Preset", f"Preset '{name}' not found")
return
# Clear existing filters and prepare to apply new ones
with contextlib.suppress(Exception):
self.data_filter.clear_all_filters()
# Apply search term
st = summary.get("search_term")
if st is not None:
self.search_var.set(st)
with contextlib.suppress(Exception):
self.data_filter.set_search_term(st)
# Apply detailed filters
filters = summary.get("filters", {})
# Date range
dr = filters.get("date_range", {}) if isinstance(filters, dict) else {}
self.start_date_var.set(dr.get("start", "") or "")
self.end_date_var.set(dr.get("end", "") or "")
with contextlib.suppress(Exception):
self.data_filter.set_date_range_filter(
dr.get("start") or None, dr.get("end") or None
)
# Medicines
meds = filters.get("medicines", {}) if isinstance(filters, dict) else {}
taken = set(meds.get("taken", []) or [])
not_taken = set(meds.get("not_taken", []) or [])
for key, var in self.medicine_vars.items():
if key in taken:
var.set("taken")
with contextlib.suppress(Exception):
self.data_filter.set_medicine_filter(key, True)
elif key in not_taken:
var.set("not_taken")
with contextlib.suppress(Exception):
self.data_filter.set_medicine_filter(key, False)
else:
var.set("any")
with contextlib.suppress(Exception):
self.data_filter.set_medicine_filter(key, None)
# Pathologies score range: values like "2-8"
paths = filters.get("pathologies", {}) if isinstance(filters, dict) else {}
for key, rng in paths.items():
if isinstance(rng, str) and "-" in rng:
lo, hi = rng.split("-", 1)
if key in self.pathology_min_vars:
self.pathology_min_vars[key].set(lo)
if key in self.pathology_max_vars:
self.pathology_max_vars[key].set(hi)
with contextlib.suppress(Exception):
try:
lo_i = int(lo)
except Exception:
lo_i = None
try:
hi_i = int(hi)
except Exception:
hi_i = None
self.data_filter.set_pathology_range_filter(key, lo_i, hi_i)
self._update_status()
self.update_callback()
def _delete_preset(self) -> None:
name = self.preset_var.get()
presets = _pref_get("filter_presets", {}) or {}
if name in presets:
with contextlib.suppress(Exception):
presets.pop(name)
_pref_set("filter_presets", presets)
_pref_save()
self.preset_var.set("")
self._refresh_presets_combo()
+580
View File
@@ -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"]
+416
View File
@@ -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",
}
+163
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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"]
+296
View File
@@ -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
+4 -443
View File
@@ -1,445 +1,6 @@
"""Theme manager for the application using ttkthemes.""" # Deprecated legacy shim. Use 'thechart.ui.theme_manager' instead.
from __future__ import annotations
import logging raise ImportError(
import tkinter as tk "src.theme_manager is removed. Import from 'thechart.ui.theme_manager'."
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
)
# 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",
}
+4 -161
View File
@@ -1,163 +1,6 @@
"""Tooltip system for enhanced user experience.""" # Deprecated legacy shim. Use 'thechart.ui.tooltip_system' instead.
from __future__ import annotations
import tkinter as tk raise ImportError(
"src.tooltip_system is removed. Import from 'thechart.ui.tooltip_system'."
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)
+7 -1923
View File
File diff suppressed because it is too large Load Diff
+4 -31
View File
@@ -1,33 +1,6 @@
"""Undo stack for add/update/delete operations.""" # Deprecated legacy shim. Use 'thechart.core.undo_manager' instead.
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable raise ImportError(
from dataclasses import dataclass "src.undo_manager is removed. Import from 'thechart.core.undo_manager'."
)
@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)
+65 -4
View File
@@ -7,12 +7,73 @@ import pytest
import pandas as pd import pandas as pd
from unittest.mock import Mock from unittest.mock import Mock
import logging import logging
import warnings
import os as _os
# Add src to path for imports # Force a headless-friendly Matplotlib backend in tests
import sys _os.environ.setdefault("MPLBACKEND", "Agg")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.medicine_manager import MedicineManager, Medicine
@pytest.fixture(autouse=True, scope="session")
def _matplotlib_headless_backend():
"""Force Matplotlib to use the Agg backend for all tests.
Doing this at session scope ensures any pyplot usage in code under test
doesn't try to initialize interactive Tk backends.
"""
try:
import matplotlib as _mpl
_mpl.use("Agg", force=True)
except Exception:
# If Matplotlib isn't available or already configured, ignore.
pass
@pytest.fixture(autouse=True)
def _stub_pyplot_ui_calls(monkeypatch):
"""No-op pyplot UI calls that can be noisy or slow in CI.
This reduces flicker and avoids timing issues without changing behavior.
"""
try:
import matplotlib.pyplot as _plt
monkeypatch.setattr(_plt, "pause", lambda *args, **kwargs: None, raising=False)
monkeypatch.setattr(_plt, "draw", lambda *args, **kwargs: None, raising=False)
except Exception:
pass
@pytest.fixture(autouse=True, scope="session")
def _tune_reportlab_for_tests():
"""Apply small ReportLab tweaks for stable tests without heavy font checks."""
try:
from reportlab import rl_config
# Disable glyph warnings which are irrelevant for our tests
rl_config.warnOnMissingFontGlyphs = 0 # type: ignore[attr-defined]
except Exception:
pass
# Test-only warning hygiene to keep output clean while preserving behavior
# - Silence legacy deprecation shims that originate inside package internals
warnings.filterwarnings(
"ignore",
message=r".*search_filter is deprecated.*",
category=DeprecationWarning,
)
# - Silence a Pillow deprecation surfaced via Matplotlib's Tk backend used by tests
warnings.filterwarnings(
"ignore",
message=r".*'mode' parameter is deprecated and will be removed in Pillow 13.*",
category=DeprecationWarning,
)
# - Silence pandas parse fallback warning triggered intentionally by invalid test data
warnings.filterwarnings(
"ignore",
message=r"Could not infer format, so each element will be parsed individually.*",
category=UserWarning,
)
from thechart.managers import MedicineManager, Medicine
@pytest.fixture @pytest.fixture
+1 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
from datetime import datetime from datetime import datetime
import pandas as pd import pandas as pd
from src.auto_save import AutoSaveManager from thechart.core import AutoSaveManager
class TestAutoSaveManager: class TestAutoSaveManager:
+24 -38
View File
@@ -1,104 +1,90 @@
""" """Tests for the canonical constants module (thechart.core.constants)."""
Tests for constants module.
"""
import os
from unittest.mock import patch
import os
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) from unittest.mock import patch
def _fresh_constants(): def _fresh_constants():
"""Import or reload the constants module and return it. """Import or reload the constants module and return it.
Ensures a local binding exists in callers to avoid UnboundLocalError Ensures a local binding exists in callers to avoid UnboundLocalError
from conditional imports in the tests. while supporting env var patching between tests.
""" """
import importlib import importlib
# If already imported, reload to pick up env changes
if 'constants' in sys.modules: mod_name = "thechart.core.constants"
import constants # bind locally for importlib.reload if mod_name in sys.modules:
return importlib.reload(constants) mod = sys.modules[mod_name]
# Otherwise, import fresh return importlib.reload(mod)
import constants import thechart.core.constants as constants
return constants return constants
class TestConstants: class TestConstants:
"""Test cases for the constants module.""" """Test cases for the canonical constants module."""
def test_default_log_level(self): def test_default_log_level(self):
"""Test default LOG_LEVEL when not set in environment."""
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_LEVEL == "INFO" assert constants.LOG_LEVEL == "INFO"
def test_custom_log_level(self): def test_custom_log_level(self):
"""Test custom LOG_LEVEL from environment.""" with patch.dict(os.environ, {"LOG_LEVEL": "debug"}, clear=True):
with patch.dict(os.environ, {'LOG_LEVEL': 'debug'}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_LEVEL == "DEBUG" assert constants.LOG_LEVEL == "DEBUG"
def test_default_log_path(self): def test_default_log_path(self):
"""Test default LOG_PATH when not set in environment."""
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_PATH == "/tmp/logs/thechart" assert constants.LOG_PATH == "/tmp/logs/thechart"
def test_custom_log_path(self): def test_custom_log_path(self):
"""Test custom LOG_PATH from environment.""" with patch.dict(os.environ, {"LOG_PATH": "/custom/log/path"}, clear=True):
with patch.dict(os.environ, {'LOG_PATH': '/custom/log/path'}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_PATH == "/custom/log/path" assert constants.LOG_PATH == "/custom/log/path"
def test_default_log_clear(self): def test_default_log_clear(self):
"""Test default LOG_CLEAR when not set in environment."""
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_CLEAR == "False" assert constants.LOG_CLEAR == "False"
def test_custom_log_clear_true(self): def test_custom_log_clear_true(self):
"""Test LOG_CLEAR when set to true in environment.""" with patch.dict(os.environ, {"LOG_CLEAR": "true"}, clear=True):
with patch.dict(os.environ, {'LOG_CLEAR': 'true'}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_CLEAR == "True" assert constants.LOG_CLEAR == "True"
def test_custom_log_clear_false(self): def test_custom_log_clear_false(self):
"""Test LOG_CLEAR when set to false in environment.""" with patch.dict(os.environ, {"LOG_CLEAR": "false"}, clear=True):
with patch.dict(os.environ, {'LOG_CLEAR': 'false'}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_CLEAR == "False" assert constants.LOG_CLEAR == "False"
def test_log_level_case_insensitive(self): def test_log_level_case_insensitive(self):
"""Test that LOG_LEVEL is converted to uppercase.""" with patch.dict(os.environ, {"LOG_LEVEL": "warning"}, clear=True):
with patch.dict(os.environ, {'LOG_LEVEL': 'warning'}, clear=True):
constants = _fresh_constants() constants = _fresh_constants()
assert constants.LOG_LEVEL == "WARNING" assert constants.LOG_LEVEL == "WARNING"
def test_dotenv_override(self): def test_dotenv_override(self):
"""Test that dotenv override parameter is set to True."""
# This is a structural test since dotenv is loaded during import # This is a structural test since dotenv is loaded during import
with patch('constants.load_dotenv') as mock_load_dotenv: with patch("thechart.core.constants.load_dotenv") as mock_load_dotenv:
import importlib import importlib
if 'constants' in sys.modules:
importlib.reload(sys.modules['constants']) name = "thechart.core.constants"
if name in sys.modules:
importlib.reload(sys.modules[name])
else: else:
import constants import thechart.core.constants # noqa: F401
mock_load_dotenv.assert_called_once_with(override=True) mock_load_dotenv.assert_called_once_with(override=True)
def test_all_constants_are_strings(self): def test_all_constants_are_strings(self):
"""Test that all constants are string type.""" constants = _fresh_constants()
import constants
assert isinstance(constants.LOG_LEVEL, str) assert isinstance(constants.LOG_LEVEL, str)
assert isinstance(constants.LOG_PATH, str) assert isinstance(constants.LOG_PATH, str)
assert isinstance(constants.LOG_CLEAR, str) assert isinstance(constants.LOG_CLEAR, str)
def test_constants_not_empty(self): def test_constants_not_empty(self):
"""Test that constants are not empty strings.""" constants = _fresh_constants()
import constants
assert constants.LOG_LEVEL != "" assert constants.LOG_LEVEL != ""
assert constants.LOG_PATH != "" assert constants.LOG_PATH != ""
assert constants.LOG_CLEAR != "" assert constants.LOG_CLEAR != ""
+1 -1
View File
@@ -8,7 +8,7 @@ from unittest.mock import patch
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.data_manager import DataManager from thechart.data import DataManager
class TestDataManager: class TestDataManager:
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
import tkinter as tk import tkinter as tk
from src.ui_manager import UIManager from thechart.ui import UIManager
@pytest.fixture @pytest.fixture
def root_window(): def root_window():
+1 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import time import time
import logging import logging
from src.error_handler import ErrorHandler, OperationTimer from thechart.core import ErrorHandler, OperationTimer
class TestErrorHandler: class TestErrorHandler:
-67
View File
@@ -1,67 +0,0 @@
"""
Tests for export cleanup tracking in ExportManager.
"""
import os
import sys
import tempfile
from pathlib import Path
# Ensure src imports like other tests do
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from export_manager import ExportManager
from data_manager import DataManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
from init import logger
def test_export_cleanup_on_del():
# Setup a temporary workspace and CSV
tmpdir = tempfile.mkdtemp()
csv_path = os.path.join(tmpdir, "data.csv")
# Minimal managers
med = MedicineManager(logger=logger)
path = PathologyManager(logger=logger)
data = DataManager(csv_path, logger, med, path)
# Create a couple of rows so export works
data.add_entry([
"2024-01-01", 1, 1, 1, 1, 1, "", 0, "", 0, "", 0, "", 0, "", "note"
])
em = ExportManager(data, graph_manager=None, medicine_manager=med, pathology_manager=path, logger=logger)
json_path = os.path.join(tmpdir, "out.json")
xml_path = os.path.join(tmpdir, "out.xml")
assert em.export_data_to_json(json_path) is True
assert em.export_data_to_xml(xml_path) is True
# Files should exist now
assert os.path.exists(json_path)
assert os.path.exists(xml_path)
# Deleting the export manager should best-effort remove its tracked files
del em
# Force garbage collection to trigger __del__ in CPython test environment
import gc
gc.collect()
assert not os.path.exists(json_path)
assert not os.path.exists(xml_path)
# Cleanup temp dir
try:
os.unlink(csv_path)
except Exception:
pass
try:
os.rmdir(tmpdir)
except Exception:
# If test fails earlier, ignore
pass
+14 -19
View File
@@ -8,10 +8,7 @@ from pathlib import Path
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock, patch, MagicMock
import pandas as pd import pandas as pd
import sys from thechart.export import ExportManager
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.export_manager import ExportManager
class TestExportManager: class TestExportManager:
@@ -212,8 +209,8 @@ class TestExportManager:
"No data available to update graph for export" "No data available to update graph for export"
) )
@patch('src.export_manager.ExportManager._save_graph_as_image') @patch('thechart.export.export_manager.ExportManager._save_graph_as_image')
@patch('src.export_manager.SimpleDocTemplate') @patch('thechart.export.export_manager.SimpleDocTemplate')
def test_export_to_pdf_success(self, mock_doc, mock_save_graph, export_manager): def test_export_to_pdf_success(self, mock_doc, mock_save_graph, export_manager):
"""Test successful PDF export.""" """Test successful PDF export."""
# Mock graph image saving # Mock graph image saving
@@ -241,8 +238,8 @@ class TestExportManager:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.unlink(temp_path) os.unlink(temp_path)
@patch('src.export_manager.ExportManager._save_graph_as_image') @patch('thechart.export.export_manager.ExportManager._save_graph_as_image')
@patch('src.export_manager.SimpleDocTemplate') @patch('thechart.export.export_manager.SimpleDocTemplate')
def test_export_to_pdf_no_graph(self, mock_doc, mock_save_graph, export_manager): def test_export_to_pdf_no_graph(self, mock_doc, mock_save_graph, export_manager):
"""Test PDF export without graph.""" """Test PDF export without graph."""
# Mock document building # Mock document building
@@ -262,7 +259,7 @@ class TestExportManager:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.unlink(temp_path) os.unlink(temp_path)
@patch('src.export_manager.SimpleDocTemplate') @patch('thechart.export.export_manager.SimpleDocTemplate')
def test_export_to_pdf_empty_data(self, mock_doc, export_manager): def test_export_to_pdf_empty_data(self, mock_doc, export_manager):
"""Test PDF export with empty data.""" """Test PDF export with empty data."""
export_manager.data_manager.load_data.return_value = pd.DataFrame() export_manager.data_manager.load_data.return_value = pd.DataFrame()
@@ -283,7 +280,7 @@ class TestExportManager:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.unlink(temp_path) os.unlink(temp_path)
@patch('src.export_manager.SimpleDocTemplate') @patch('thechart.export.export_manager.SimpleDocTemplate')
def test_export_to_pdf_exception(self, mock_doc, export_manager): def test_export_to_pdf_exception(self, mock_doc, export_manager):
"""Test PDF export with exception.""" """Test PDF export with exception."""
# Mock document building to raise exception # Mock document building to raise exception
@@ -330,9 +327,8 @@ class TestExportManagerIntegration:
@pytest.fixture @pytest.fixture
def real_data_manager(self, temp_csv_file, mock_logger): def real_data_manager(self, temp_csv_file, mock_logger):
"""Create a data manager with real test data.""" """Create a data manager with real test data."""
from src.medicine_manager import MedicineManager from thechart.managers import MedicineManager, PathologyManager
from src.pathology_manager import PathologyManager from thechart.data import DataManager
from src.data_manager import DataManager
# Create managers with real data # Create managers with real data
medicine_manager = MedicineManager(logger=mock_logger) medicine_manager = MedicineManager(logger=mock_logger)
@@ -358,9 +354,8 @@ class TestExportManagerIntegration:
"""Create a real graph manager for testing.""" """Create a real graph manager for testing."""
import tkinter as tk import tkinter as tk
import tkinter.ttk as ttk import tkinter.ttk as ttk
from src.graph_manager import GraphManager from thechart.analytics import GraphManager
from src.medicine_manager import MedicineManager from thechart.managers import MedicineManager, PathologyManager
from src.pathology_manager import PathologyManager
# Create minimal tkinter setup # Create minimal tkinter setup
root = tk.Tk() root = tk.Tk()
@@ -430,7 +425,7 @@ class TestExportManagerIntegration:
try: try:
# Mock the SimpleDocTemplate to verify landscape format # Mock the SimpleDocTemplate to verify landscape format
with patch('src.export_manager.SimpleDocTemplate') as mock_doc: with patch('thechart.export.export_manager.SimpleDocTemplate') as mock_doc:
mock_doc_instance = Mock() mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance mock_doc.return_value = mock_doc_instance
@@ -467,11 +462,11 @@ class TestExportManagerIntegration:
try: try:
# Mock Table to verify column widths and styling # Mock Table to verify column widths and styling
with patch('src.export_manager.Table') as mock_table: with patch('thechart.export.export_manager.Table') as mock_table:
mock_table_instance = Mock() mock_table_instance = Mock()
mock_table.return_value = mock_table_instance mock_table.return_value = mock_table_instance
with patch('src.export_manager.SimpleDocTemplate') as mock_doc: with patch('thechart.export.export_manager.SimpleDocTemplate') as mock_doc:
mock_doc_instance = Mock() mock_doc_instance = Mock()
mock_doc.return_value = mock_doc_instance mock_doc.return_value = mock_doc_instance
+7 -7
View File
@@ -4,8 +4,8 @@ import tkinter as tk
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from src.search_filter_ui import SearchFilterWidget from thechart.ui import SearchFilterWidget
from src.search_filter import DataFilter from thechart.search import DataFilter
@pytest.fixture @pytest.fixture
@@ -52,17 +52,17 @@ def test_save_preset_creates_when_new(widget, monkeypatch):
data_filter.get_filter_summary.return_value = summary data_filter.get_filter_summary.return_value = summary
# Pretend no existing presets # Pretend no existing presets
monkeypatch.setattr("src.search_filter_ui.get_pref", lambda k, d=None: {}) monkeypatch.setattr("thechart.ui.search_filter_ui._pref_get", lambda k, d=None: {})
saved = {} saved = {}
def fake_set_pref(key, value): def fake_set_pref(key, value):
saved[key] = value saved[key] = value
monkeypatch.setattr("src.search_filter_ui.set_pref", fake_set_pref) monkeypatch.setattr("thechart.ui.search_filter_ui._pref_set", fake_set_pref)
called = {"saved": False} called = {"saved": False}
def fake_save_preferences(): def fake_save_preferences():
called["saved"] = True called["saved"] = True
monkeypatch.setattr("src.search_filter_ui.save_preferences", fake_save_preferences) monkeypatch.setattr("thechart.ui.search_filter_ui._pref_save", fake_save_preferences)
# Bypass dialog # Bypass dialog
monkeypatch.setattr(SearchFilterWidget, "_ask_preset_name", lambda self, initial="": "TestPreset") monkeypatch.setattr(SearchFilterWidget, "_ask_preset_name", lambda self, initial="": "TestPreset")
@@ -90,7 +90,7 @@ def test_load_preset_applies_filters(widget, monkeypatch):
# Provide get_pref to return our preset # Provide get_pref to return our preset
monkeypatch.setattr( monkeypatch.setattr(
"src.search_filter_ui.get_pref", "thechart.ui.search_filter_ui._pref_get",
lambda k, d=None: {"filter_presets": {"MyPreset": summary}}.get(k, d), lambda k, d=None: {"filter_presets": {"MyPreset": summary}}.get(k, d),
) )
@@ -98,7 +98,7 @@ def test_load_preset_applies_filters(widget, monkeypatch):
w.preset_var.set("MyPreset") w.preset_var.set("MyPreset")
# Suppress any warnings # Suppress any warnings
monkeypatch.setattr("src.search_filter_ui.messagebox.showwarning", lambda *_a, **_k: None) monkeypatch.setattr("thechart.ui.search_filter_ui._tk_messagebox.showwarning", lambda *_a, **_k: None)
w._load_preset() w._load_preset()
+23 -20
View File
@@ -11,7 +11,7 @@ from unittest.mock import Mock, patch
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.graph_manager import GraphManager from thechart.analytics import GraphManager
class TestGraphManager: class TestGraphManager:
@@ -93,7 +93,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -111,7 +111,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg'): with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg'):
gm = GraphManager(parent_frame) gm = GraphManager(parent_frame)
# Test with empty DataFrame # Test with empty DataFrame
@@ -128,7 +128,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -146,7 +146,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -167,7 +167,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -198,7 +198,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -217,7 +217,7 @@ class TestGraphManager:
mock_ax.plot.side_effect = Exception("Plot error") mock_ax.plot.side_effect = Exception("Plot error")
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -227,7 +227,10 @@ class TestGraphManager:
try: try:
gm.update_graph(sample_dataframe) gm.update_graph(sample_dataframe)
except Exception as e: except Exception as e:
pytest.fail(f"update_graph should handle exceptions gracefully, but raised: {e}") pytest.fail(
"update_graph should handle exceptions gracefully, but raised: "
f"{e}"
)
def test_grid_configuration(self, parent_frame): def test_grid_configuration(self, parent_frame):
"""Test that grid configuration is set up correctly.""" """Test that grid configuration is set up correctly."""
@@ -247,7 +250,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas.get_tk_widget.return_value = Mock() mock_canvas.get_tk_widget.return_value = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -264,7 +267,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -376,7 +379,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -414,7 +417,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -458,7 +461,7 @@ class TestGraphManager:
mock_ax.get_legend_handles_labels.return_value = ([], []) mock_ax.get_legend_handles_labels.return_value = ([], [])
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -514,7 +517,7 @@ class TestGraphManager:
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -566,7 +569,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -613,7 +616,7 @@ class TestGraphManager:
mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Test Label']) mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Test Label'])
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -667,7 +670,7 @@ class TestGraphManager:
mock_ax.get_legend_handles_labels.return_value = ([], []) mock_ax.get_legend_handles_labels.return_value = ([], [])
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -712,7 +715,7 @@ class TestGraphManager:
mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Depression']) mock_ax.get_legend_handles_labels.return_value = ([Mock()], ['Depression'])
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
@@ -743,7 +746,7 @@ class TestGraphManager:
mock_ax = Mock() mock_ax = Mock()
mock_subplots.return_value = (mock_fig, mock_ax) mock_subplots.return_value = (mock_fig, mock_ax)
with patch('graph_manager.FigureCanvasTkAgg') as mock_canvas_class: with patch('thechart.analytics.graph_manager.FigureCanvasTkAgg') as mock_canvas_class:
mock_canvas = Mock() mock_canvas = Mock()
mock_canvas_class.return_value = mock_canvas mock_canvas_class.return_value = mock_canvas
+27 -255
View File
@@ -1,262 +1,34 @@
""" """
Tests for init module. Canonical replacements for legacy init tests, targeting thechart.core.logger.
""" """
import os import os
import pytest from unittest.mock import patch
from unittest.mock import patch, Mock
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
class TestInit: class TestInitCanonical:
"""Test cases for the init module.""" def test_loggers_write_mode_respects_log_clear(self, temp_log_dir):
from thechart.core.logger import init_logger
with patch('thechart.core.logger.LOG_PATH', temp_log_dir), \
patch('thechart.core.logger.LOG_CLEAR', 'True'):
logger = init_logger('init', testing_mode=False)
assert any(hasattr(h, 'stream') for h in logger.handlers)
def test_log_directory_creation(self, temp_log_dir): def test_testing_mode_flag(self, temp_log_dir):
"""Test that log directory is created if it doesn't exist.""" from thechart.core.logger import init_logger
with patch('init.LOG_PATH', temp_log_dir + '/new_dir'), \ with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
patch('os.path.exists', return_value=False), \ assert init_logger('init', testing_mode=True).level == 10 # DEBUG
patch('os.mkdir') as mock_mkdir: assert init_logger('init', testing_mode=False).level in (20, 30, 40, 50)
# Re-import to trigger the directory creation logic def test_log_file_paths(self, temp_log_dir):
import importlib from thechart.core.logger import init_logger
if 'init' in sys.modules: with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
importlib.reload(sys.modules['init']) logger = init_logger('init', testing_mode=False)
else: # Touch files via logging
import src.init logger.debug("d"); logger.warning("w"); logger.error("e")
expected = {
mock_mkdir.assert_called_once() os.path.join(temp_log_dir, 'thechart.log'),
os.path.join(temp_log_dir, 'thechart.warning.log'),
def test_log_directory_exists(self, temp_log_dir): os.path.join(temp_log_dir, 'thechart.error.log'),
"""Test behavior when log directory already exists.""" }
with patch('init.LOG_PATH', temp_log_dir), \ actual = {getattr(h, 'baseFilename', None) for h in logger.handlers if hasattr(h, 'baseFilename')}
patch('os.path.exists', return_value=True), \ assert expected.issubset(actual)
patch('os.mkdir') as mock_mkdir:
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
mock_mkdir.assert_not_called()
def test_log_directory_creation_error(self, temp_log_dir):
"""Test handling of errors during log directory creation."""
with patch('init.LOG_PATH', '/invalid/path'), \
patch('os.path.exists', return_value=False), \
patch('os.mkdir', side_effect=PermissionError("Permission denied")), \
patch('builtins.print') as mock_print:
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
mock_print.assert_called()
def test_logger_initialization(self, temp_log_dir):
"""Test that logger is initialized correctly."""
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_LEVEL', 'INFO'), \
patch('init.init_logger') as mock_init_logger:
mock_logger = Mock()
mock_init_logger.return_value = mock_logger
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
mock_init_logger.assert_called_once_with('init', testing_mode=False)
def test_logger_initialization_debug_mode(self, temp_log_dir):
"""Test logger initialization in debug mode."""
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_LEVEL', 'DEBUG'), \
patch('init.init_logger') as mock_init_logger:
mock_logger = Mock()
mock_init_logger.return_value = mock_logger
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
mock_init_logger.assert_called_once_with('init', testing_mode=True)
def test_log_files_definition(self, temp_log_dir):
"""Test that log files tuple is defined correctly."""
with patch('init.LOG_PATH', temp_log_dir):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
expected_files = (
f"{temp_log_dir}/thechart.log",
f"{temp_log_dir}/thechart.warning.log",
f"{temp_log_dir}/thechart.error.log",
)
# Access the (re)loaded module directly from sys.modules to avoid
# UnboundLocalError when the conditional local import path isn't taken.
assert sys.modules['init'].log_files == expected_files
def test_testing_mode_detection(self, temp_log_dir):
"""Test that testing mode is detected correctly."""
with patch('init.LOG_PATH', temp_log_dir):
# Test with DEBUG level
with patch('init.LOG_LEVEL', 'DEBUG'):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
# Access via sys.modules to avoid UnboundLocalError from conditional import
assert sys.modules['init'].testing_mode is True
# Test with non-DEBUG level
with patch('init.LOG_LEVEL', 'INFO'):
importlib.reload(sys.modules['init'])
# Access via sys.modules to avoid UnboundLocalError from conditional import
assert sys.modules['init'].testing_mode is False
def test_log_clear_true(self, temp_log_dir):
"""Test log file clearing when LOG_CLEAR is True."""
# Create some test log files
log_files = [
os.path.join(temp_log_dir, "thechart.log"),
os.path.join(temp_log_dir, "thechart.warning.log"),
os.path.join(temp_log_dir, "thechart.error.log"),
]
for log_file in log_files:
with open(log_file, 'w') as f:
f.write("Old log content")
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_CLEAR', 'True'), \
patch('init.log_files', log_files):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
# Check that files were truncated
for log_file in log_files:
with open(log_file, 'r') as f:
assert f.read() == ""
def test_log_clear_false(self, temp_log_dir):
"""Test that log files are not cleared when LOG_CLEAR is False."""
# Create some test log files
log_files = [
os.path.join(temp_log_dir, "thechart.log"),
os.path.join(temp_log_dir, "thechart.warning.log"),
os.path.join(temp_log_dir, "thechart.error.log"),
]
original_content = "Original log content"
for log_file in log_files:
with open(log_file, 'w') as f:
f.write(original_content)
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_CLEAR', 'False'), \
patch('init.log_files', log_files):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
# Check that files were not truncated
for log_file in log_files:
with open(log_file, 'r') as f:
assert f.read() == original_content
def test_log_clear_nonexistent_files(self, temp_log_dir):
"""Test log clearing when some log files don't exist."""
log_files = [
os.path.join(temp_log_dir, "thechart.log"),
os.path.join(temp_log_dir, "nonexistent.log"),
]
# Create only one of the files
with open(log_files[0], 'w') as f:
f.write("Content")
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_CLEAR', 'True'), \
patch('init.log_files', log_files):
# This should not raise an exception
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
def test_log_clear_permission_error(self, temp_log_dir):
"""Test handling of permission errors during log clearing."""
log_files = [os.path.join(temp_log_dir, "thechart.log")]
with open(log_files[0], 'w') as f:
f.write("Content")
with patch('init.LOG_PATH', temp_log_dir), \
patch('init.LOG_CLEAR', 'True'), \
patch('init.log_files', log_files), \
patch('builtins.open', side_effect=PermissionError("Permission denied")), \
patch('init.logger') as mock_logger:
mock_logger.error = Mock()
# Should raise the exception after logging
with pytest.raises(PermissionError):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
def test_module_exports(self, temp_log_dir):
"""Test that module exports expected objects."""
with patch('init.LOG_PATH', temp_log_dir):
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
# Check that expected objects are available
mod = sys.modules['init']
assert hasattr(mod, 'logger')
assert hasattr(mod, 'log_files')
assert hasattr(mod, 'testing_mode')
def test_log_path_printing(self, temp_log_dir):
"""Test that LOG_PATH is printed when directory is created."""
with patch('init.LOG_PATH', temp_log_dir + '/new_dir'), \
patch('os.path.exists', return_value=False), \
patch('os.mkdir'), \
patch('builtins.print') as mock_print:
import importlib
if 'init' in sys.modules:
importlib.reload(sys.modules['init'])
else:
import src.init
mock_print.assert_called_with(temp_log_dir + '/new_dir')
+12 -15
View File
@@ -4,7 +4,6 @@ Consolidates various functional tests into a unified test suite.
""" """
import os import os
import sys
import tempfile import tempfile
import tkinter as tk import tkinter as tk
from pathlib import Path from pathlib import Path
@@ -13,19 +12,15 @@ import pytest
import pandas as pd import pandas as pd
import time import time
# Add src to path from thechart.core.logger import init_logger
sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from thechart.data import DataManager
from thechart.export import ExportManager
from data_manager import DataManager from thechart.validation import InputValidator
from export_manager import ExportManager from thechart.core.error_handler import ErrorHandler
from input_validator import InputValidator from thechart.core.auto_save import AutoSaveManager
from error_handler import ErrorHandler from thechart.search import DataFilter, QuickFilters, SearchHistory
from auto_save import AutoSaveManager from thechart.managers import MedicineManager, PathologyManager
from search_filter import DataFilter, QuickFilters, SearchHistory from thechart.ui import ThemeManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
from theme_manager import ThemeManager
from init import logger
class TestIntegrationSuite: class TestIntegrationSuite:
@@ -38,7 +33,9 @@ class TestIntegrationSuite:
self.temp_dir = tempfile.mkdtemp() self.temp_dir = tempfile.mkdtemp()
self.test_csv = os.path.join(self.temp_dir, "test_data.csv") self.test_csv = os.path.join(self.temp_dir, "test_data.csv")
# Initialize managers # Initialize logger and managers
global logger
logger = init_logger("thechart.test.integration", testing_mode=True)
self.medicine_manager = MedicineManager(logger=logger) self.medicine_manager = MedicineManager(logger=logger)
self.pathology_manager = PathologyManager(logger=logger) self.pathology_manager = PathologyManager(logger=logger)
self.data_manager = DataManager( self.data_manager = DataManager(
+19 -22
View File
@@ -6,10 +6,7 @@ import logging
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
import sys from thechart.core.logger import init_logger
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.logger import init_logger
class TestLogger: class TestLogger:
@@ -17,7 +14,7 @@ class TestLogger:
def test_init_logger_basic(self, temp_log_dir): def test_init_logger_basic(self, temp_log_dir):
"""Test basic logger initialization.""" """Test basic logger initialization."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
assert isinstance(logger, logging.Logger) assert isinstance(logger, logging.Logger)
@@ -26,21 +23,21 @@ class TestLogger:
def test_init_logger_testing_mode(self, temp_log_dir): def test_init_logger_testing_mode(self, temp_log_dir):
"""Test logger initialization in testing mode.""" """Test logger initialization in testing mode."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=True) logger = init_logger("test_logger", testing_mode=True)
assert logger.level == logging.DEBUG assert logger.level == logging.DEBUG
def test_init_logger_production_mode(self, temp_log_dir): def test_init_logger_production_mode(self, temp_log_dir):
"""Test logger initialization in production mode.""" """Test logger initialization in production mode."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
assert logger.level == logging.INFO assert logger.level == logging.INFO
def test_file_handlers_created(self, temp_log_dir): def test_file_handlers_created(self, temp_log_dir):
"""Test that file handlers are created correctly.""" """Test that file handlers are created correctly."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
# Check that handlers were added # Check that handlers were added
@@ -48,7 +45,7 @@ class TestLogger:
def test_file_handler_levels(self, temp_log_dir): def test_file_handler_levels(self, temp_log_dir):
"""Test that file handlers have correct log levels.""" """Test that file handlers have correct log levels."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
handler_levels = [handler.level for handler in logger.handlers if isinstance(handler, logging.FileHandler)] handler_levels = [handler.level for handler in logger.handlers if isinstance(handler, logging.FileHandler)]
@@ -60,7 +57,7 @@ class TestLogger:
def test_log_file_paths(self, temp_log_dir): def test_log_file_paths(self, temp_log_dir):
"""Test that log files are created with correct paths.""" """Test that log files are created with correct paths."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
# Log something to trigger file creation # Log something to trigger file creation
@@ -70,9 +67,9 @@ class TestLogger:
# Check that log files would be created (paths are correct) # Check that log files would be created (paths are correct)
expected_files = [ expected_files = [
os.path.join(temp_log_dir, "app.log"), os.path.join(temp_log_dir, "thechart.log"),
os.path.join(temp_log_dir, "app.warning.log"), os.path.join(temp_log_dir, "thechart.warning.log"),
os.path.join(temp_log_dir, "app.error.log") os.path.join(temp_log_dir, "thechart.error.log")
] ]
# The files should exist or be ready to be created # The files should exist or be ready to be created
@@ -82,7 +79,7 @@ class TestLogger:
def test_formatter_format(self, temp_log_dir): def test_formatter_format(self, temp_log_dir):
"""Test that formatters are set correctly.""" """Test that formatters are set correctly."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
expected_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s" expected_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
@@ -94,7 +91,7 @@ class TestLogger:
@patch('colorlog.basicConfig') @patch('colorlog.basicConfig')
def test_colorlog_configuration(self, mock_basicConfig, temp_log_dir): def test_colorlog_configuration(self, mock_basicConfig, temp_log_dir):
"""Test that colorlog is configured correctly.""" """Test that colorlog is configured correctly."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
init_logger("test_logger", testing_mode=False) init_logger("test_logger", testing_mode=False)
mock_basicConfig.assert_called_once() mock_basicConfig.assert_called_once()
@@ -108,7 +105,7 @@ class TestLogger:
def test_multiple_logger_instances(self, temp_log_dir): def test_multiple_logger_instances(self, temp_log_dir):
"""Test creating multiple logger instances.""" """Test creating multiple logger instances."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger1 = init_logger("logger1", testing_mode=False) logger1 = init_logger("logger1", testing_mode=False)
logger2 = init_logger("logger2", testing_mode=True) logger2 = init_logger("logger2", testing_mode=True)
@@ -119,7 +116,7 @@ class TestLogger:
def test_logger_inheritance(self, temp_log_dir): def test_logger_inheritance(self, temp_log_dir):
"""Test that logger follows Python logging hierarchy.""" """Test that logger follows Python logging hierarchy."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test.module.logger", testing_mode=False) logger = init_logger("test.module.logger", testing_mode=False)
assert logger.name == "test.module.logger" assert logger.name == "test.module.logger"
@@ -129,7 +126,7 @@ class TestLogger:
"""Test error handling when file handler creation fails.""" """Test error handling when file handler creation fails."""
mock_file_handler.side_effect = PermissionError("Cannot create log file") mock_file_handler.side_effect = PermissionError("Cannot create log file")
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
# Should not raise an exception, but handle gracefully # Should not raise an exception, but handle gracefully
try: try:
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
@@ -140,7 +137,7 @@ class TestLogger:
def test_logger_name_parameter(self, temp_log_dir): def test_logger_name_parameter(self, temp_log_dir):
"""Test that logger name is set correctly from parameter.""" """Test that logger name is set correctly from parameter."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
test_name = "my.custom.logger.name" test_name = "my.custom.logger.name"
logger = init_logger(test_name, testing_mode=False) logger = init_logger(test_name, testing_mode=False)
@@ -148,7 +145,7 @@ class TestLogger:
def test_testing_mode_boolean(self, temp_log_dir): def test_testing_mode_boolean(self, temp_log_dir):
"""Test that testing_mode parameter accepts boolean values.""" """Test that testing_mode parameter accepts boolean values."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger_true = init_logger("test1", testing_mode=True) logger_true = init_logger("test1", testing_mode=True)
logger_false = init_logger("test2", testing_mode=False) logger_false = init_logger("test2", testing_mode=False)
@@ -157,7 +154,7 @@ class TestLogger:
def test_log_format_contains_required_fields(self, temp_log_dir): def test_log_format_contains_required_fields(self, temp_log_dir):
"""Test that log format contains all required fields.""" """Test that log format contains all required fields."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s" log_format = "%(asctime)s - %(name)s - %(funcName)s - %(levelname)s - %(message)s"
@@ -169,7 +166,7 @@ class TestLogger:
def test_handler_file_mode(self, temp_log_dir): def test_handler_file_mode(self, temp_log_dir):
"""Test that file handlers use append mode by default.""" """Test that file handlers use append mode by default."""
with patch('logger.LOG_PATH', temp_log_dir): with patch('thechart.core.logger.LOG_PATH', temp_log_dir):
logger = init_logger("test_logger", testing_mode=False) logger = init_logger("test_logger", testing_mode=False)
# File handlers should be in append mode by default # File handlers should be in append mode by default
+1 -1
View File
@@ -10,7 +10,7 @@ import pandas as pd
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from src.main import MedTrackerApp from thechart.main import MedTrackerApp
class TestMedTrackerApp: class TestMedTrackerApp:

Some files were not shown because too many files have changed in this diff Show More