feat: Implement application preferences with JSON persistence
Build and Push Docker Image / build-and-push (push) Has been cancelled

- Added preferences management in `preferences.py` with functions to load, save, get, set, and reset preferences.
- Introduced a configuration directory structure based on the operating system.
- Integrated preferences into the settings window, allowing users to reset settings and manage window geometry.
- Enhanced `search_filter.py` to support flexible date column names and improved filtering logic.
- Updated `settings_window.py` to include options for managing backup and configuration folder paths.
- Introduced an `UndoManager` class to handle undo actions for add/update/delete operations.
- Improved UIManager to support sorting in tree views and added a toast notification feature.
This commit is contained in:
William Valentin
2025-08-07 16:26:17 -07:00
parent 73498af138
commit 9372d6ef29
15 changed files with 1997 additions and 468 deletions
+142 -17
View File
@@ -1,6 +1,7 @@
import csv
import logging
import os
import tempfile
import pandas as pd
@@ -18,17 +19,31 @@ class DataManager:
medicine_manager: MedicineManager,
pathology_manager: PathologyManager,
) -> None:
self.filename: str = filename
self.logger: logging.Logger = logger
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
# Cache for loaded data to avoid repeated file I/O
self._data_cache: pd.DataFrame | None = None
self._cache_timestamp: float = 0
self._headers_cache: tuple[str, ...] | None = None
self._dtype_cache: dict[str, type] | None = None
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, ...]:
@@ -54,15 +69,39 @@ class DataManager:
def _initialize_csv_file(self) -> None:
"""Create CSV file with headers if it doesn't exist or is empty."""
if not os.path.exists(self.filename) 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())
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."""
@@ -97,8 +136,11 @@ class DataManager:
def load_data(self) -> pd.DataFrame:
"""Load data from CSV file with caching for better performance."""
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
self.logger.warning("CSV file is empty or doesn't exist. No data to load.")
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
@@ -117,6 +159,11 @@ class DataManager:
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)
@@ -124,6 +171,8 @@ class DataManager:
# 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()
@@ -205,8 +254,8 @@ class DataManager:
mask = df["date"] == original_date
if mask.any():
df.loc[mask, headers] = values
# Write back to CSV with optimized method
df.to_csv(self.filename, index=False, mode="w")
# Atomic write back to CSV to avoid partial writes
self._atomic_write_csv(df)
self._invalidate_cache()
return True
else:
@@ -230,7 +279,7 @@ class DataManager:
# Only write if something was actually deleted
if len(df) < original_len:
df.to_csv(self.filename, index=False, mode="w")
self._atomic_write_csv(df)
self._invalidate_cache()
return True
@@ -238,6 +287,31 @@ class DataManager:
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
def get_today_medicine_doses(
self, date: str, medicine_name: str
) -> list[tuple[str, str]]:
@@ -274,3 +348,54 @@ class DataManager:
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