Files
thechart/src/main.py
T
William Valentin c755f0affc Add comprehensive tests for dose tracking functionality
- Implemented `test_dose_parsing_simple.py` to validate the dose parsing workflow.
- Created `test_dose_save.py` to verify the saving functionality of dose tracking.
- Added `test_dose_save_simple.py` for programmatic testing of dose saving without UI interaction.
- Developed `test_final_workflow.py` to test the complete dose tracking workflow, ensuring doses are preserved during edits.
- Enhanced `conftest.py` with a mock pathology manager for testing.
- Updated `test_data_manager.py` to include pathology manager in DataManager tests and ensure compatibility with new features.
2025-07-31 09:50:45 -07:00

452 lines
17 KiB
Python

import os
import sys
import tkinter as tk
from collections.abc import Callable
from tkinter import messagebox, ttk
from typing import Any
import pandas as pd
from constants import LOG_LEVEL, LOG_PATH
from data_manager import DataManager
from graph_manager import GraphManager
from init import logger
from medicine_management_window import MedicineManagementWindow
from medicine_manager import MedicineManager
from pathology_management_window import PathologyManagementWindow
from pathology_manager import PathologyManager
from ui_manager import UIManager
class MedTrackerApp:
def __init__(self, root: tk.Tk) -> None:
self.root: tk.Tk = root
self.root.resizable(True, True)
self.root.title("Thechart - medication tracker")
self.root.protocol("WM_DELETE_WINDOW", self.handle_window_closing)
# Set up data file
self.filename: str = "thechart_data.csv"
first_argument: str = ""
if len(sys.argv) > 1:
first_argument: str = sys.argv[1]
if os.path.exists(first_argument):
self.filename = first_argument
logger.info(f"Using data file: {first_argument}")
else:
logger.warning(
f"Data file {first_argument} doesn't exist. \
Using default file: {self.filename}"
)
if LOG_LEVEL == "DEBUG":
logger.debug(f"Script name: {sys.argv[0]}")
logger.debug(f"Logs path: {LOG_PATH}")
logger.debug(f"First argument: {first_argument}")
# Initialize managers
self.medicine_manager: MedicineManager = MedicineManager(logger=logger)
self.pathology_manager: PathologyManager = PathologyManager(logger=logger)
self.ui_manager: UIManager = UIManager(
root, logger, self.medicine_manager, self.pathology_manager
)
self.data_manager: DataManager = DataManager(
self.filename, logger, self.medicine_manager, self.pathology_manager
)
# Set up application icon
icon_path: str = "chart-671.png"
if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"):
icon_path = "./chart-671.png"
self.ui_manager.setup_application_icon(img_path=icon_path)
# Set up the main application UI
self._setup_main_ui()
# Add menu bar
self._setup_menu()
def _setup_main_ui(self) -> None:
"""Set up the main UI components."""
import tkinter.ttk as ttk
# --- Main Frame ---
main_frame: ttk.Frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky="nsew")
# Configure root window grid
self.root.grid_rowconfigure(0, weight=1)
self.root.grid_columnconfigure(0, weight=1)
# Configure main frame grid for scaling
for i in range(2):
main_frame.grid_rowconfigure(i, weight=1 if i == 1 else 0)
main_frame.grid_columnconfigure(i, weight=3 if i == 1 else 1)
logger.debug("Main frame and root grid configured for scaling.")
# --- Create Graph Frame ---
graph_frame: ttk.Frame = self.ui_manager.create_graph_frame(main_frame)
self.graph_manager: GraphManager = GraphManager(
graph_frame, self.medicine_manager, self.pathology_manager
)
# --- Create Input Frame ---
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
self.input_frame: ttk.Frame = input_ui["frame"]
self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"]
self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"]
self.note_var: tk.StringVar = input_ui["note_var"]
self.date_var: tk.StringVar = input_ui["date_var"]
# Add buttons to input frame
self.ui_manager.add_action_buttons(
self.input_frame,
[
{
"text": "Add Entry",
"command": self.add_new_entry,
"fill": "both",
"expand": True,
},
{"text": "Quit", "command": self.handle_window_closing},
],
)
# --- Create Table Frame ---
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(main_frame)
self.tree: ttk.Treeview = table_ui["tree"]
self.tree.bind("<Double-1>", self.handle_double_click)
# Load data
self.refresh_data_display()
def _setup_menu(self) -> None:
"""Set up the menu bar."""
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
# Tools menu
tools_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Tools", menu=tools_menu)
tools_menu.add_command(
label="Manage Pathologies...", command=self._open_pathology_manager
)
tools_menu.add_command(
label="Manage Medicines...", command=self._open_medicine_manager
)
def _open_pathology_manager(self) -> None:
"""Open the pathology management window."""
PathologyManagementWindow(
self.root, self.pathology_manager, self._refresh_ui_after_config_change
)
def _open_medicine_manager(self) -> None:
"""Open the medicine management window."""
MedicineManagementWindow(
self.root, self.medicine_manager, self._refresh_ui_after_config_change
)
def _refresh_ui_after_config_change(self) -> None:
"""Refresh UI components after pathology or medicine configuration changes."""
# Recreate the input frame with new pathologies and medicines
self.input_frame.destroy()
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(
self.input_frame.master
)
self.input_frame: ttk.Frame = input_ui["frame"]
self.pathology_vars: dict[str, tk.IntVar] = input_ui["pathology_vars"]
self.medicine_vars: dict[str, tuple[tk.IntVar, str]] = input_ui["medicine_vars"]
# Add buttons to input frame
self.ui_manager.add_action_buttons(
self.input_frame,
[
{
"text": "Add Entry",
"command": self.add_new_entry,
"fill": "both",
"expand": True,
},
{"text": "Quit", "command": self.handle_window_closing},
],
)
# Recreate the table with new columns
self.tree.destroy()
table_ui: dict[str, Any] = self.ui_manager.create_table_frame(
self.tree.master.master
)
self.tree: ttk.Treeview = table_ui["tree"]
self.tree.bind("<Double-1>", self.handle_double_click)
# Refresh data display
self.refresh_data_display()
def handle_double_click(self, event: tk.Event) -> None:
"""Handle double-click event to edit an entry."""
logger.debug("Double-click event triggered on treeview.")
if len(self.tree.get_children()) > 0:
item_id = self.tree.selection()[0]
item_values = self.tree.item(item_id, "values")
logger.debug(f"Editing item_id={item_id}, values={item_values}")
self._create_edit_window(item_id, item_values)
def _create_edit_window(self, item_id: str, values: tuple[str, ...]) -> None:
"""Create a new Toplevel window for editing an entry."""
original_date = values[0] # Store the original date
# Get the full row data from the CSV (including dose columns)
df = self.data_manager.load_data()
if not df.empty and original_date in df["date"].values:
full_row = df[df["date"] == original_date].iloc[0]
# Convert to tuple in the expected order for the edit window
full_values = [full_row["date"]]
# Add pathology data dynamically
for pathology_key in self.pathology_manager.get_pathology_keys():
if pathology_key in full_row:
full_values.append(full_row[pathology_key])
else:
full_values.append(0)
# Add medicine data dynamically
for medicine_key in self.medicine_manager.get_medicine_keys():
if medicine_key in full_row:
full_values.append(full_row[medicine_key])
full_values.append(full_row.get(f"{medicine_key}_doses", ""))
else:
full_values.extend([0, ""])
full_values.append(full_row["note"])
full_values = tuple(full_values)
else:
# Fallback to the table values if full data not found
full_values = values
# Define callbacks for edit window buttons
callbacks: dict[str, Callable] = {
"save": lambda win, *args: self._save_edit(win, original_date, *args),
"delete": lambda win: self._delete_entry(win, item_id),
}
# Create edit window using UI manager with full data
_: tk.Toplevel = self.ui_manager.create_edit_window(full_values, callbacks)
def _save_edit(
self,
edit_win: tk.Toplevel,
original_date: str,
*args,
) -> None:
"""Save edited data to CSV file with dynamic pathology/medicine support."""
# Parse dynamic arguments
# Format: date, pathology1, pathology2, ..., medicine1, medicine2,
# ..., note, dose_data
if len(args) < 2: # At minimum need date and note
messagebox.showerror("Error", "Invalid save data format", parent=edit_win)
return
# Extract arguments
date = args[0]
# Get pathology count to extract values
pathology_keys = self.pathology_manager.get_pathology_keys()
medicine_keys = self.medicine_manager.get_medicine_keys()
# Expected format: date, pathology_values..., medicine_values...,
# note, dose_data
expected_pathology_count = len(pathology_keys)
expected_medicine_count = len(medicine_keys)
# Extract pathology values
pathology_values = []
for i in range(expected_pathology_count):
if i + 1 < len(args):
pathology_values.append(args[i + 1])
else:
pathology_values.append(0)
# Extract medicine values
medicine_values = []
medicine_start_idx = 1 + expected_pathology_count
for i in range(expected_medicine_count):
if medicine_start_idx + i < len(args):
medicine_values.append(args[medicine_start_idx + i])
else:
medicine_values.append(0)
# Extract note and dose data (last two arguments)
note = args[-2] if len(args) >= 2 else ""
dose_data = args[-1] if len(args) >= 1 else {}
# Build the values list for data manager
values = [date]
values.extend(pathology_values)
# Add medicine data dynamically
for i, medicine_key in enumerate(medicine_keys):
values.append(medicine_values[i] if i < len(medicine_values) else 0)
values.append(dose_data.get(medicine_key, ""))
values.append(note)
if self.data_manager.update_entry(original_date, values):
edit_win.destroy()
messagebox.showinfo(
"Success", "Entry updated successfully!", parent=self.root
)
self._clear_entries()
self.refresh_data_display()
else:
# Check if it's a duplicate date issue
df = self.data_manager.load_data()
if original_date != date and not df.empty and date in df["date"].values:
messagebox.showerror(
"Error",
f"An entry for date '{date}' already exists. "
"Please use a different date.",
parent=edit_win,
)
else:
messagebox.showerror("Error", "Failed to save changes", parent=edit_win)
def handle_window_closing(self) -> None:
if messagebox.askokcancel(
"Quit", "Do you want to quit the application?", parent=self.root
):
self.graph_manager.close()
self.root.destroy()
def add_new_entry(self) -> None:
"""Add a new entry to the CSV file."""
# Get current doses for today
today = self.date_var.get()
dose_values = {}
if today:
# Get doses for all medicines dynamically
for medicine_key in self.medicine_manager.get_medicine_keys():
doses = self.data_manager.get_today_medicine_doses(today, medicine_key)
dose_values[f"{medicine_key}_doses"] = "|".join(
[f"{ts}:{dose}" for ts, dose in doses]
)
else:
# Set empty doses for all medicines
for medicine_key in self.medicine_manager.get_medicine_keys():
dose_values[f"{medicine_key}_doses"] = ""
# Build entry dynamically
entry: list[str | int] = [self.date_var.get()]
# Add pathology data dynamically
for pathology_key in self.pathology_manager.get_pathology_keys():
entry.append(self.pathology_vars[pathology_key].get())
# Add medicine data
for medicine_key in self.medicine_manager.get_medicine_keys():
entry.append(self.medicine_vars[medicine_key][0].get())
entry.append(dose_values[f"{medicine_key}_doses"])
entry.append(self.note_var.get())
logger.debug(f"Adding entry: {entry}")
# Check if date is empty
if not self.date_var.get().strip():
messagebox.showerror("Error", "Please enter a date.", parent=self.root)
return
if self.data_manager.add_entry(entry):
messagebox.showinfo(
"Success", "Entry added successfully!", parent=self.root
)
self._clear_entries()
self.refresh_data_display()
else:
# Check if it's a duplicate date by trying to load existing data
df = self.data_manager.load_data()
if not df.empty and self.date_var.get() in df["date"].values:
messagebox.showerror(
"Error",
f"An entry for date '{self.date_var.get()}' already exists. "
"Please use a different date or edit the existing entry.",
parent=self.root,
)
else:
messagebox.showerror("Error", "Failed to add entry", parent=self.root)
def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None:
"""Delete the selected entry from the CSV file."""
logger.debug(f"Delete requested for item_id={item_id}")
if messagebox.askyesno(
"Delete Entry",
"Are you sure you want to delete this entry?",
parent=edit_win,
):
# Get the date of the entry to delete
date: str = self.tree.item(item_id, "values")[0]
logger.debug(f"Deleting entry with date={date}")
if self.data_manager.delete_entry(date):
edit_win.destroy()
messagebox.showinfo(
"Success", "Entry deleted successfully!", parent=self.root
)
self.refresh_data_display()
else:
messagebox.showerror("Error", "Failed to delete entry", parent=edit_win)
def _clear_entries(self) -> None:
"""Clear all input fields."""
logger.debug("Clearing input fields.")
self.date_var.set("")
for key in self.pathology_vars:
self.pathology_vars[key].set(0)
for key in self.medicine_vars:
self.medicine_vars[key][0].set(0)
self.note_var.set("")
def refresh_data_display(self) -> None:
"""Load data from the CSV file into the table and graph."""
logger.debug("Loading data from CSV.")
# Clear existing data in the treeview
for i in self.tree.get_children():
self.tree.delete(i)
# Load data from the CSV file
df: pd.DataFrame = self.data_manager.load_data()
# Update the treeview with the data
if not df.empty:
# Build display columns dynamically (exclude dose columns for table view)
display_columns = ["date", "depression", "anxiety", "sleep", "appetite"]
# Add medicine columns (without dose columns)
for medicine_key in self.medicine_manager.get_medicine_keys():
display_columns.append(medicine_key)
display_columns.append("note")
# Filter to only the columns we want to display
if all(col in df.columns for col in display_columns):
display_df = df[display_columns]
else:
# Fallback - just use all columns
display_df = df
for _index, row in display_df.iterrows():
self.tree.insert(parent="", index="end", values=list(row))
logger.debug(f"Loaded {len(display_df)} entries into treeview.")
# Update the graph
self.graph_manager.update_graph(df)
if __name__ == "__main__":
root: tk.Tk = tk.Tk()
app: MedTrackerApp = MedTrackerApp(root)
root.mainloop()