diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48fd1e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + name: Pruebas (capa de modelos) + runs-on: ubuntu-latest + + steps: + - name: Clonar el repositorio + uses: actions/checkout@v4 + + - name: Configurar Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Instalar dependencias de desarrollo + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Ejecutar la suite de pruebas + run: pytest diff --git a/README.md b/README.md index c526f3c..fb21791 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,25 @@ margoth/ - [x] Verificación de creación de `data/` y `media/` junto al binario - [x] Validación de ejecución del ejecutable compilado (`Margoth.exe`) +### Fase 9: Medios etiquetados ✅ +- [x] Columna `label` en `patient_media` (con migración idempotente) +- [x] El ejercicio semántico usa etiquetas legibles, no el nombre de archivo +- [x] Campo de etiqueta al subir un medio + +### Fase 10: Reportes y gestión de pacientes ✅ +- [x] Vista de reportes de progreso (aciertos, tiempos, tendencia diaria) +- [x] Editar y eliminar pacientes (borrado en cascada de medios y métricas) + +### Fase 11: Tableros CAA múltiples ✅ +- [x] Varios tableros por paciente (upsert por id, sin sobrescribir) +- [x] Tamaño de grilla configurable (1..4 × 1..4) +- [x] Selector de tablero en el constructor y en el visor + +### Calidad ✅ +- [x] Suite de pruebas de la capa de modelos (`pytest`, 44 tests) +- [x] Integración continua en GitHub Actions +- [x] Ícono propio de la aplicación (`assets/icon.ico`) + ## Verificación del Ejecutable Compilado Tras ejecutar `Margoth.exe` en `dist/Margoth/`: @@ -98,6 +117,19 @@ pip install -r requirements.txt python src/main.py ``` +### Pruebas +```bash +pip install -r requirements-dev.txt +pytest +``` +La capa de modelos se prueba headless (sin PyQt6). El mismo comando corre en +CI (GitHub Actions) en cada push y pull request. + +### Regenerar el ícono +```bash +python tools/generate_icon.py # -> assets/icon.png y assets/icon.ico +``` + ### Compilación (Windows) ```bash pip install pyinstaller @@ -114,9 +146,23 @@ El ejecutable se generará en `dist/Margoth/`. - **Cero sobrecarga cognitiva**: Interfaces minimalistas para pacientes (Teoría de Mayer) - **Accesibilidad**: Alto contraste, tipografías escalables (Segoe UI 12pt+) -- **Privacidad**: 100% offline, datos locales cifrados +- **Privacidad**: 100% offline. Los datos clínicos nunca salen del equipo (ver *Seguridad y privacidad*) - **Personalización**: Soporte para fotos y audios del entorno del paciente +## Seguridad y privacidad + +- **Sin nube**: la aplicación no consume ni expone ninguna API de red. Todos + los datos (BD SQLite y medios) viven en el equipo del terapeuta. +- **Aislamiento por paciente**: los medios se guardan en carpetas separadas + identificadas por UUID. +- **Cifrado en reposo (pendiente)**: hoy la base de datos SQLite **no** está + cifrada. La migración a [SQLCipher](https://www.zetetic.net/sqlcipher/) + (vía `pysqlcipher3`) está planificada como mejora futura; se difirió porque + requiere binarios nativos que complican el empaquetado con PyInstaller en + Windows. Mientras tanto, la protección recae en el control de acceso del + sistema operativo. **No usar en un equipo compartido sin cuenta de usuario + protegida.** + ## Contribuidores - **Carlos G** - Creador y desarrollador principal diff --git a/apps/api/manage.py b/apps/api/manage.py deleted file mode 100644 index cbbfe40..0000000 --- a/apps/api/manage.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'margoth_api.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError("Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?") from exc - execute_from_command_line(sys.argv) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..f6fb11d Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8796012 Binary files /dev/null and b/assets/icon.png differ diff --git a/build_exe.py b/build_exe.py index 9b394f1..5bf1294 100644 --- a/build_exe.py +++ b/build_exe.py @@ -7,8 +7,12 @@ def main(): project_root = os.path.dirname(os.path.abspath(__file__)) entry = os.path.join(project_root, "src", "main.py") src_path = os.path.join(project_root, "src") - styles_src = os.path.join(project_root, "assets", "styles") - add_data = f"{styles_src}{os.pathsep}assets/styles" + assets_src = os.path.join(project_root, "assets") + icon_ico = os.path.join(assets_src, "icon.ico") + + sep = os.pathsep + # Empaqueta toda la carpeta assets (estilos + ícono) junto al binario + add_data = f"{assets_src}{sep}assets" cmd = [ sys.executable, @@ -24,9 +28,13 @@ def main(): src_path, "--add-data", add_data, - entry, ] + if os.path.isfile(icon_ico): + cmd += ["--icon", icon_ico] + + cmd.append(entry) + subprocess.run(cmd, check=True) diff --git a/docs/mvp.md b/docs/mvp.md index 04870d5..5813c0b 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -1,5 +1,56 @@ -# MVP Documentation +# Documentación funcional — Margoth -## Exercises and API +Margoth es una aplicación de escritorio **100 % offline** para intervención y +rehabilitación cognitiva y del lenguaje. No expone ni consume ninguna API en la +nube: toda la lógica corre localmente y los datos clínicos nunca salen del +equipo. La persistencia es SQLite local y los medios se guardan en el sistema +de archivos, por paciente. -This document describes the exercises associated with the project and the API endpoints available in the Django application. Further details will be provided as development progresses. \ No newline at end of file +> Nota histórica: versiones muy tempranas incluían un esqueleto de API en +> Django. Se descartó por completo para cumplir el requisito de privacidad +> "100 % offline". Ya no existe backend de red. + +## Módulos clínicos + +### 1. Gestión de pacientes (Dashboard) +El terapeuta da de alta, edita y elimina pacientes, y sube material multimedia +personalizado (fotos y audios del entorno del paciente). Cada paciente tiene una +carpeta de medios aislada, identificada por UUID. + +### 2. Tableros CAA (Comunicación Aumentativa y Alternativa) +Grillas de pictogramas/fotos con audio asociado. Al pulsar una casilla se +reproduce el audio vinculado. Pensados para pacientes con dificultades del +lenguaje expresivo. + +### 3. Constructor visual de tableros CAA +Interfaz de arrastrar-asignar para montar los tableros: el terapeuta selecciona +un medio de la galería del paciente y lo coloca en una casilla de la grilla. La +configuración se guarda como `caa_boards.json` dentro de la carpeta del paciente. + +### 4. Ejercicio semántico con métricas +Se muestra un estímulo visual central y tres opciones de respuesta (una correcta +y dos distractores). Se mide el tiempo de reacción con `time.perf_counter()` y se +registra cada intento (acierto/fallo + tiempo) en la tabla `exercise_metrics`. + +### 5. Reportes de progreso +Vista de seguimiento por paciente que agrega `exercise_metrics`: porcentaje de +aciertos, tiempo de reacción promedio e histórico de sesiones. Es el insumo +clínico para evaluar la evolución del paciente. + +## Modelo de datos (SQLite local) + +| Tabla | Propósito | +|-------|-----------| +| `patients` | Datos del paciente y su carpeta de medios (UUID). | +| `patient_media` | Fotos/audios importados, con etiqueta (`label`) y tipo. | +| `exercise_metrics` | Un registro por intento de ejercicio (acierto + tiempo). | + +Los tableros CAA no viven en la BD: se serializan como JSON en la carpeta de +medios de cada paciente (`caa_boards.json`). + +## Principios de diseño + +- **Cero sobrecarga cognitiva**: interfaces minimalistas para el paciente. +- **Cero modales**: errores y estados vacíos se renderizan inline en la vista. +- **Privacidad**: 100 % offline, datos locales. +- **Accesibilidad**: alto contraste, tipografías escalables, modo claro/oscuro. diff --git a/margoth_installer.iss b/margoth_installer.iss index c976967..63541db 100644 --- a/margoth_installer.iss +++ b/margoth_installer.iss @@ -1,6 +1,9 @@ [Setup] AppName=Margoth -AppVersion=1.0.0 +AppVersion=1.1.0 +AppVerName=Margoth 1.1.0 +AppPublisher=Carlos G +AppComments=Rehabilitación cognitiva y del lenguaje, 100% offline DefaultDirName={localappdata}\Margoth DefaultGroupName=Margoth OutputBaseFilename=Margoth_Setup @@ -8,6 +11,7 @@ OutputDir=dist Compression=lzma SolidCompression=yes PrivilegesRequired=lowest +SetupIconFile=assets\icon.ico UninstallDisplayIcon={app}\Margoth.exe ArchitecturesInstallIn64BitMode=x64 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3b2c446 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..13f6026 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0.0 diff --git a/src/controllers/app_controller.py b/src/controllers/app_controller.py index cf61698..445758a 100644 --- a/src/controllers/app_controller.py +++ b/src/controllers/app_controller.py @@ -1,13 +1,16 @@ from models.caa_board_model import CAABoardModel from models.semantic_exercise_model import SemanticExerciseModel +from models.metrics_model import MetricsModel from views.main_window import MainWindow from views.dashboard_view import DashboardView from views.builder_view import BuilderView from views.semantic_exercise_view import SemanticExerciseView +from views.reports_view import ReportsView from controllers.dashboard_controller import DashboardController from controllers.caa_controller import CAAController from controllers.builder_controller import BuilderController from controllers.semantic_controller import SemanticController +from controllers.reports_controller import ReportsController class AppController: @@ -34,12 +37,20 @@ def __init__(self, theme_manager, db_manager=None, patient_model=None): self.semantic_view = SemanticExerciseView() self.semantic_model = SemanticExerciseModel(self.db_manager, self.patient_model) self.semantic_controller = SemanticController(self.semantic_view, self.semantic_model) + + self.reports_view = ReportsView() + self.reports_controller = ReportsController( + self.reports_view, MetricsModel(self.db_manager) + ) + self.dashboard_view.caa_requested.connect(self._on_caa_requested) self.dashboard_view.builder_requested.connect(self._on_builder_requested) self.dashboard_view.exercise_requested.connect(self._on_exercise_requested) + self.dashboard_view.reports_requested.connect(self._on_reports_requested) self.caa_controller.view.back_requested.connect(self._on_caa_back) self.builder_view.back_requested.connect(self._on_builder_back) self.semantic_view.exit_requested.connect(self._on_exercise_exit) + self.reports_view.back_requested.connect(self._on_reports_back) self.main_window.set_main_view(self.dashboard_view) self.dashboard_controller.initialize() @@ -79,3 +90,13 @@ def _on_exercise_requested(self, patient_ref): def _on_exercise_exit(self): self.main_window.set_main_view(self.dashboard_view) + + def _on_reports_requested(self, patient_ref): + patient_id = patient_ref.get("id") + patient_dict = self.patient_model.get_patient_by_id(patient_id) + if patient_dict: + self.reports_controller.load_report(patient_dict) + self.main_window.set_main_view(self.reports_view) + + def _on_reports_back(self): + self.main_window.set_main_view(self.dashboard_view) diff --git a/src/controllers/builder_controller.py b/src/controllers/builder_controller.py index b8cba8a..25fc217 100644 --- a/src/controllers/builder_controller.py +++ b/src/controllers/builder_controller.py @@ -6,6 +6,8 @@ def __init__(self, view, patient_model, caa_model): self._current_patient = None self._view.board_save_requested.connect(self._on_save_requested) + self._view.board_selected.connect(self._on_board_selected) + self._view.new_board_requested.connect(self._on_new_board) @property def view(self): @@ -17,7 +19,24 @@ def load_patient(self, patient_dict): images = self._patient_model.get_patient_media(patient_id, "image") audios = self._patient_model.get_patient_media(patient_id, "audio") self._view.set_gallery(images, audios) - self._view.reset_board() + self._refresh_boards() + + def _refresh_boards(self, active_id=None): + boards = self._caa_model.list_boards(self._current_patient) + self._view.set_boards(boards, active_id) + if boards: + target = active_id or boards[0]["id"] + board, _ = self._caa_model.load_board(self._current_patient, target) + self._view.load_board_data(board) + else: + self._view.new_board() + + def _on_board_selected(self, board_id): + board, _ = self._caa_model.load_board(self._current_patient, board_id) + self._view.load_board_data(board) + + def _on_new_board(self): + self._view.new_board() def _on_save_requested(self, board_data): if not self._current_patient: @@ -27,5 +46,9 @@ def _on_save_requested(self, board_data): saved = self._caa_model.save_board(self._current_patient, board_data) if saved: self._view.show_message("Tablero guardado correctamente") + # Refresca el selector conservando el tablero recién guardado activo + boards = self._caa_model.list_boards(self._current_patient) + active = board_data.get("id") or (boards[-1]["id"] if boards else None) + self._view.set_boards(boards, active) else: self._view.show_message("Error guardando tablero", is_error=True) diff --git a/src/controllers/caa_controller.py b/src/controllers/caa_controller.py index ae63763..96091f9 100644 --- a/src/controllers/caa_controller.py +++ b/src/controllers/caa_controller.py @@ -12,6 +12,7 @@ def __init__(self, db_manager): self._view.item_clicked.connect(self._on_item_clicked) self._view.generate_test_requested.connect(self._on_generate_test) + self._view.board_change_requested.connect(self._on_board_changed) @property def view(self): @@ -19,7 +20,16 @@ def view(self): def load_patient_board(self, patient_dict): self._current_patient = patient_dict - board, items = self._model.load_first_board(patient_dict) + boards = self._model.list_boards(patient_dict) + active_id = boards[0]["id"] if boards else None + self._view.set_boards(boards, active_id) + board, items = self._model.load_board(patient_dict, active_id) + self._view.render_board(board, items) + + def _on_board_changed(self, board_id): + if not self._current_patient: + return + board, items = self._model.load_board(self._current_patient, board_id) self._view.render_board(board, items) def _on_item_clicked(self, audio_path): diff --git a/src/controllers/dashboard_controller.py b/src/controllers/dashboard_controller.py index 10a17f8..8c908ab 100644 --- a/src/controllers/dashboard_controller.py +++ b/src/controllers/dashboard_controller.py @@ -3,6 +3,9 @@ def __init__(self, view, patient_model): self._view = view self._model = patient_model self._view.patient_save_requested.connect(self._on_save_patient) + self._view.patient_update_requested.connect(self._on_update_patient) + self._view.patient_delete_requested.connect(self._on_delete_patient) + self._view.edit_requested.connect(self._on_edit_requested) self._view.upload_requested.connect(self._on_upload_media) def initialize(self): @@ -18,13 +21,36 @@ def _on_save_patient(self, data): self._view.clear_form() self.load_patients() + def _on_edit_requested(self, patient_ref): + patient = self._model.get_patient_by_id(patient_ref.get("id")) + if patient: + self._view.enter_edit_mode(patient) + + def _on_update_patient(self, patient_id, data): + self._model.update_patient( + patient_id, + first_name=data["first_name"], + last_name=data["last_name"], + birth_date=data["birth_date"], + notes=data["notes"], + ) + self.load_patients() + self._view.show_message("Paciente actualizado correctamente") + + def _on_delete_patient(self, patient_id): + if self._model.delete_patient(patient_id): + self.load_patients() + self._view.show_message("Paciente eliminado") + else: + self._view.show_message("No se pudo eliminar el paciente", is_error=True) + def load_patients(self): patients = self._model.get_all_patients() self._view.populate_table(patients) - def _on_upload_media(self, patient_ref, source_path): + def _on_upload_media(self, patient_ref, source_path, label=""): patient_id = patient_ref.get("id") - result = self._model.import_media(patient_id, source_path) + result = self._model.import_media(patient_id, source_path, label=label) if result: self._view.show_message( f"Archivo '{result}' importado correctamente" diff --git a/src/controllers/reports_controller.py b/src/controllers/reports_controller.py new file mode 100644 index 0000000..3272042 --- /dev/null +++ b/src/controllers/reports_controller.py @@ -0,0 +1,19 @@ +class ReportsController: + def __init__(self, view, metrics_model): + self._view = view + self._model = metrics_model + self._current_patient = None + + @property + def view(self): + return self._view + + def load_report(self, patient_dict): + self._current_patient = patient_dict + patient_id = patient_dict.get("id") + name = f"{patient_dict.get('first_name', '')} {patient_dict.get('last_name', '')}".strip() + + summary = self._model.get_patient_summary(patient_id) + recent = self._model.get_recent_attempts(patient_id) + daily = self._model.get_daily_accuracy(patient_id) + self._view.render_report(name, summary, recent, daily) diff --git a/src/main.py b/src/main.py index e98694a..18f4a16 100644 --- a/src/main.py +++ b/src/main.py @@ -4,10 +4,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QIcon from models.database_manager import DatabaseManager from models.patient_model import PatientModel from controllers.app_controller import AppController from utils.theme_manager import ThemeManager +from utils.path_resolver import PathResolver def main(): @@ -19,6 +21,10 @@ def main(): app = QApplication(sys.argv) app.setApplicationName("Margoth") + icon_path = PathResolver.get_resource_path(os.path.join("assets", "icon.png")) + if os.path.isfile(icon_path): + app.setWindowIcon(QIcon(icon_path)) + theme_manager = ThemeManager() theme_manager.apply_theme("light") diff --git a/src/models/caa_board_model.py b/src/models/caa_board_model.py index 4c12c97..1888869 100644 --- a/src/models/caa_board_model.py +++ b/src/models/caa_board_model.py @@ -1,12 +1,21 @@ import json import os +import uuid + from utils.path_resolver import PathResolver class CAABoardModel: + """Persistencia de tableros CAA como JSON en la carpeta del paciente. + + Un paciente puede tener varios tableros. Se guardan en + ``media/{uuid}/caa_boards.json`` con la forma ``{"boards": [ ... ]}``. + """ + def __init__(self, db_manager): self._db = db_manager + # ── Lectura de bytes de medios ──────────────────────────────── def load_audio_bytes(self, audio_path): if not audio_path or not os.path.isfile(audio_path): return None @@ -27,105 +36,125 @@ def _load_image_bytes(self, image_path): print(f"Error leyendo imagen: {exc}") return None + # ── Rutas ───────────────────────────────────────────────────── def _media_path(self, patient_dict): media_folder = patient_dict.get("media_folder", "") app_root = PathResolver.get_app_data_path() return os.path.join(app_root, "media", media_folder) - def load_first_board(self, patient_dict): - media_path = self._media_path(patient_dict) - json_path = os.path.join(media_path, "caa_boards.json") + def _json_path(self, patient_dict): + return os.path.join(self._media_path(patient_dict), "caa_boards.json") + # ── Lectura/escritura del archivo completo ──────────────────── + def _read_all(self, patient_dict): + json_path = self._json_path(patient_dict) if not os.path.isfile(json_path): - return {"name": "Tablero CAA", "items": []}, [] - + return [] try: with open(json_path, "r", encoding="utf-8") as handle: data = json.load(handle) - boards = data.get("boards", []) - if boards: - board = boards[0] - items = [] - for item in board.get("items", []): - image_file = item.get("image_file", "") - audio_file = item.get("audio_file", "") - image_path = os.path.join(media_path, image_file) if image_file else "" - audio_path = os.path.join(media_path, audio_file) if audio_file else "" - items.append( - { - "position": item.get("position", [0, 0]), - "label": item.get("label", ""), - "image_bytes": self._load_image_bytes(image_path), - "audio_path": audio_path, - } - ) - return board, items - return {"name": "Tableros vacios", "items": []}, [] - except (json.JSONDecodeError, KeyError) as exc: + return data.get("boards", []) or [] + except (json.JSONDecodeError, OSError, KeyError) as exc: print(f"Error leyendo caa_boards.json: {exc}") - return {"name": "Error en tablero", "items": []}, [] + return [] - def create_test_board(self, patient_dict): + def _write_all(self, patient_dict, boards): media_path = self._media_path(patient_dict) os.makedirs(media_path, exist_ok=True) - json_path = os.path.join(media_path, "caa_boards.json") + try: + with open(self._json_path(patient_dict), "w", encoding="utf-8") as handle: + json.dump({"boards": boards}, handle, indent=2, ensure_ascii=False) + except OSError as exc: + print(f"Error guardando caa_boards.json: {exc}") + return False + return True - test_board = { - "boards": [ + # ── API pública ─────────────────────────────────────────────── + def list_boards(self, patient_dict): + """Metadatos de los tableros (id, nombre, tamaño) sin cargar bytes.""" + boards = self._read_all(patient_dict) + resultado = [] + for board in boards: + resultado.append( { - "id": "test_01", - "name": "Tablero de Prueba", - "grid_size": {"rows": 2, "cols": 2}, - "items": [ - { - "position": [0, 0], - "label": "Opcion 1", - "image_file": "", - "audio_file": "", - }, - { - "position": [0, 1], - "label": "Opcion 2", - "image_file": "", - "audio_file": "", - }, - { - "position": [1, 0], - "label": "Opcion 3", - "image_file": "", - "audio_file": "", - }, - { - "position": [1, 1], - "label": "Opcion 4", - "image_file": "", - "audio_file": "", - }, - ], + "id": board.get("id", ""), + "name": board.get("name", "Tablero CAA"), + "grid_size": board.get("grid_size", {"rows": 2, "cols": 2}), } - ] - } + ) + return resultado - try: - with open(json_path, "w", encoding="utf-8") as handle: - json.dump(test_board, handle, indent=2, ensure_ascii=False) - except OSError as exc: - print(f"Error creando tablero de prueba: {exc}") + def _hydrate(self, patient_dict, board): + """Convierte un tablero crudo en (board, items con bytes de imagen).""" + media_path = self._media_path(patient_dict) + items = [] + for item in board.get("items", []): + image_file = item.get("image_file", "") + audio_file = item.get("audio_file", "") + image_path = os.path.join(media_path, image_file) if image_file else "" + audio_path = os.path.join(media_path, audio_file) if audio_file else "" + items.append( + { + "position": item.get("position", [0, 0]), + "label": item.get("label", ""), + "image_file": image_file, + "audio_file": audio_file, + "image_bytes": self._load_image_bytes(image_path), + "audio_path": audio_path, + } + ) + return board, items + + def load_board(self, patient_dict, board_id=None): + """Carga un tablero por id; si no se indica, el primero disponible.""" + boards = self._read_all(patient_dict) + if not boards: + return {"name": "Tablero CAA", "items": []}, [] + + board = None + if board_id is not None: + board = next((b for b in boards if b.get("id") == board_id), None) + if board is None: + board = boards[0] + return self._hydrate(patient_dict, board) + + def load_first_board(self, patient_dict): + """Compatibilidad: carga el primer tablero del paciente.""" + return self.load_board(patient_dict, board_id=None) def save_board(self, patient_dict, board_data): - media_path = self._media_path(patient_dict) - if not media_path: - return False + """Inserta o actualiza (upsert) un tablero por su id, sin borrar los demás.""" + if not board_data.get("id"): + board_data = dict(board_data) + board_data["id"] = f"board_{uuid.uuid4().hex[:8]}" - os.makedirs(media_path, exist_ok=True) - json_path = os.path.join(media_path, "caa_boards.json") - payload = {"boards": [board_data]} + boards = self._read_all(patient_dict) + for idx, existing in enumerate(boards): + if existing.get("id") == board_data["id"]: + boards[idx] = board_data + break + else: + boards.append(board_data) - try: - with open(json_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, ensure_ascii=False) - except OSError as exc: - print(f"Error guardando caa_boards.json: {exc}") + return self._write_all(patient_dict, boards) + + def delete_board(self, patient_dict, board_id): + boards = self._read_all(patient_dict) + nuevos = [b for b in boards if b.get("id") != board_id] + if len(nuevos) == len(boards): return False + return self._write_all(patient_dict, nuevos) - return True + def create_test_board(self, patient_dict): + test_board = { + "id": f"board_{uuid.uuid4().hex[:8]}", + "name": "Tablero de Prueba", + "grid_size": {"rows": 2, "cols": 2}, + "items": [ + {"position": [0, 0], "label": "Opcion 1", "image_file": "", "audio_file": ""}, + {"position": [0, 1], "label": "Opcion 2", "image_file": "", "audio_file": ""}, + {"position": [1, 0], "label": "Opcion 3", "image_file": "", "audio_file": ""}, + {"position": [1, 1], "label": "Opcion 4", "image_file": "", "audio_file": ""}, + ], + } + self.save_board(patient_dict, test_board) diff --git a/src/models/database_manager.py b/src/models/database_manager.py index 2905e97..ff54876 100644 --- a/src/models/database_manager.py +++ b/src/models/database_manager.py @@ -49,6 +49,7 @@ def initialize_database(self): patient_id INTEGER NOT NULL, file_name TEXT NOT NULL, file_type TEXT NOT NULL, + label TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (patient_id) REFERENCES patients(id) ) @@ -67,8 +68,20 @@ def initialize_database(self): ) """ ) + self._migrate(conn) conn.commit() + def _migrate(self, conn): + """Migraciones idempotentes para bases de datos ya existentes.""" + # v2: etiqueta legible por medio (antes se usaba el nombre de archivo) + if not self._column_exists(conn, "patient_media", "label"): + conn.execute("ALTER TABLE patient_media ADD COLUMN label TEXT") + + @staticmethod + def _column_exists(conn, table, column): + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + def close(self): if self._connection: self._connection.close() diff --git a/src/models/metrics_model.py b/src/models/metrics_model.py new file mode 100644 index 0000000..6cf0dd8 --- /dev/null +++ b/src/models/metrics_model.py @@ -0,0 +1,89 @@ +"""Agregación de métricas clínicas a partir de exercise_metrics. + +Solo lectura: no toca UI. Provee el resumen de progreso por paciente que +consume la vista de reportes. +""" + + +class MetricsModel: + def __init__(self, db_manager): + self._db = db_manager + + def get_patient_summary(self, patient_id): + """Devuelve un resumen agregado de todos los intentos del paciente.""" + conn = self._db._get_connection() + row = conn.execute( + """ + SELECT + COUNT(*) AS total, + COALESCE(SUM(is_correct), 0) AS correctos, + AVG(reaction_time_ms) AS tiempo_promedio, + MIN(reaction_time_ms) AS mejor_tiempo, + MAX(created_at) AS ultima_actividad + FROM exercise_metrics + WHERE patient_id = ? + """, + (patient_id,), + ).fetchone() + + total = row["total"] or 0 + correctos = row["correctos"] or 0 + accuracy = (correctos / total * 100.0) if total else 0.0 + + return { + "total": total, + "correctos": correctos, + "incorrectos": total - correctos, + "accuracy": accuracy, + "tiempo_promedio_ms": row["tiempo_promedio"] if total else None, + "mejor_tiempo_ms": row["mejor_tiempo"] if total else None, + "ultima_actividad": row["ultima_actividad"], + } + + def get_recent_attempts(self, patient_id, limit=20): + """Últimos intentos (más recientes primero).""" + conn = self._db._get_connection() + rows = conn.execute( + """ + SELECT is_correct, reaction_time_ms, created_at + FROM exercise_metrics + WHERE patient_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (patient_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + def get_daily_accuracy(self, patient_id, limit_days=14): + """Precisión por día (para ver tendencia). Más reciente primero.""" + conn = self._db._get_connection() + rows = conn.execute( + """ + SELECT + DATE(created_at) AS dia, + COUNT(*) AS total, + COALESCE(SUM(is_correct), 0) AS correctos, + AVG(reaction_time_ms) AS tiempo_promedio + FROM exercise_metrics + WHERE patient_id = ? + GROUP BY DATE(created_at) + ORDER BY dia DESC + LIMIT ? + """, + (patient_id, limit_days), + ).fetchall() + resultado = [] + for r in rows: + total = r["total"] or 0 + correctos = r["correctos"] or 0 + resultado.append( + { + "dia": r["dia"], + "total": total, + "correctos": correctos, + "accuracy": (correctos / total * 100.0) if total else 0.0, + "tiempo_promedio_ms": r["tiempo_promedio"], + } + ) + return resultado diff --git a/src/models/patient_model.py b/src/models/patient_model.py index 96072e7..2833193 100644 --- a/src/models/patient_model.py +++ b/src/models/patient_model.py @@ -59,7 +59,32 @@ def update_patient(self, patient_id, **kwargs): conn.commit() return self.get_patient_by_id(patient_id) - def import_media(self, patient_id, source_file_path): + def delete_patient(self, patient_id): + """Elimina el paciente, sus medios (BD + disco) y sus métricas. + + Devuelve True si el paciente existía y fue eliminado. + """ + patient = self.get_patient_by_id(patient_id) + if not patient: + return False + + conn = self._db._get_connection() + # Borrar filas dependientes primero (los FK no declaran ON DELETE CASCADE) + conn.execute("DELETE FROM exercise_metrics WHERE patient_id = ?", (patient_id,)) + conn.execute("DELETE FROM patient_media WHERE patient_id = ?", (patient_id,)) + conn.execute("DELETE FROM patients WHERE id = ?", (patient_id,)) + conn.commit() + + # Borrar la carpeta de medios en disco + media_folder = patient.get("media_folder", "") + if media_folder: + folder_path = os.path.join(self._db.media_dir, media_folder) + if os.path.isdir(folder_path): + shutil.rmtree(folder_path, ignore_errors=True) + + return True + + def import_media(self, patient_id, source_file_path, label=None): patient = self.get_patient_by_id(patient_id) if not patient: return None @@ -82,6 +107,11 @@ def import_media(self, patient_id, source_file_path): final_filename = self._unique_filename(dest_dir, filename) dest_path = os.path.join(dest_dir, final_filename) + # Etiqueta legible: la provista, o el nombre del archivo sin extensión. + clean_label = (label or "").strip() + if not clean_label: + clean_label = os.path.splitext(filename)[0] + try: shutil.copy2(source_file_path, dest_path) except (OSError, shutil.Error) as exc: @@ -92,10 +122,10 @@ def import_media(self, patient_id, source_file_path): conn = self._db._get_connection() conn.execute( """ - INSERT INTO patient_media (patient_id, file_name, file_type) - VALUES (?, ?, ?) + INSERT INTO patient_media (patient_id, file_name, file_type, label) + VALUES (?, ?, ?, ?) """, - (patient_id, final_filename, file_type), + (patient_id, final_filename, file_type, clean_label), ) conn.commit() except sqlite3.Error as exc: diff --git a/src/models/semantic_exercise_model.py b/src/models/semantic_exercise_model.py index 064fe20..8b3ef7b 100644 --- a/src/models/semantic_exercise_model.py +++ b/src/models/semantic_exercise_model.py @@ -8,6 +8,14 @@ def __init__(self, db_manager, patient_model): self._db = db_manager self._patient_model = patient_model + @staticmethod + def _label_of(row): + """Etiqueta legible del medio; cae al nombre de archivo si no hay label.""" + label = (row["label"] or "").strip() if row["label"] is not None else "" + if label: + return label + return os.path.splitext(row["file_name"])[0] + def _get_media_path(self, patient_id): patient = self._patient_model.get_patient_by_id(patient_id) if not patient: @@ -25,7 +33,7 @@ def get_next_stimulus(self, patient_id): conn = self._db._get_connection() row = conn.execute( """ - SELECT file_name + SELECT file_name, label FROM patient_media WHERE patient_id = ? AND file_type = 'image' ORDER BY RANDOM() @@ -49,12 +57,12 @@ def get_next_stimulus(self, patient_id): print(f"Error leyendo imagen: {exc}") return None - correct_answer = os.path.splitext(file_name)[0] + correct_answer = self._label_of(row) false_options = [] other_rows = conn.execute( """ - SELECT file_name + SELECT file_name, label FROM patient_media WHERE patient_id = ? AND file_type = 'image' AND file_name != ? ORDER BY RANDOM() @@ -64,7 +72,7 @@ def get_next_stimulus(self, patient_id): ).fetchall() for other in other_rows: - false_options.append(os.path.splitext(other["file_name"])[0]) + false_options.append(self._label_of(other)) while len(false_options) < 2: false_options.append(f"Opción Falsa {len(false_options) + 1}") diff --git a/src/views/builder_view.py b/src/views/builder_view.py index 13ebbac..8ce71f1 100644 --- a/src/views/builder_view.py +++ b/src/views/builder_view.py @@ -1,22 +1,31 @@ import os +import uuid from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtWidgets import ( + QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QPushButton, + QScrollArea, QSizePolicy, + QSpinBox, QVBoxLayout, QWidget, ) +MAX_GRID = 4 + class BuilderView(QWidget): back_requested = pyqtSignal() board_save_requested = pyqtSignal(dict) + board_selected = pyqtSignal(str) + new_board_requested = pyqtSignal() def __init__(self): super().__init__() @@ -24,8 +33,9 @@ def __init__(self): self._board_data = self._default_board() self._cell_buttons = {} self._setup_ui() - self._refresh_grid_labels() + self._rebuild_grid() + # ── Construcción de UI ──────────────────────────────────────── def _setup_ui(self): root_layout = QVBoxLayout(self) root_layout.setContentsMargins(0, 0, 0, 0) @@ -44,6 +54,15 @@ def _setup_ui(self): self.header_title = QLabel("Constructor Visual CAA") self.header_title.setObjectName("headerTitle") + self.board_selector = QComboBox() + self.board_selector.setMinimumWidth(180) + self.board_selector.currentIndexChanged.connect(self._on_board_selected) + + new_button = QPushButton("➕ Nuevo") + new_button.setObjectName("themeToggle") + new_button.setCursor(Qt.CursorShape.PointingHandCursor) + new_button.clicked.connect(self.new_board_requested.emit) + self.message_label = QLabel("") self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.message_label.hide() @@ -55,6 +74,10 @@ def _setup_ui(self): header_layout.addWidget(back_button) header_layout.addWidget(self.header_title) + header_layout.addSpacing(12) + header_layout.addWidget(QLabel("Tablero:")) + header_layout.addWidget(self.board_selector) + header_layout.addWidget(new_button) header_layout.addStretch() header_layout.addWidget(self.message_label) header_layout.addWidget(save_button) @@ -63,6 +86,7 @@ def _setup_ui(self): body_layout.setContentsMargins(0, 0, 0, 0) body_layout.setSpacing(0) + # ── Galería (izquierda) ── gallery_panel = QFrame() gallery_panel.setObjectName("formPanel") gallery_panel.setFixedWidth(320) @@ -92,35 +116,50 @@ def _setup_ui(self): self.audio_list.itemClicked.connect(self._on_audio_selected) gallery_layout.addWidget(self.audio_list, 1) + # ── Grilla (derecha) ── grid_panel = QFrame() grid_layout = QVBoxLayout(grid_panel) grid_layout.setContentsMargins(20, 20, 20, 20) grid_layout.setSpacing(10) - grid_hint = QLabel("2. Haz clic en una celda para asignarlo") + # Fila de configuración del tablero + config_row = QHBoxLayout() + config_row.addWidget(QLabel("Nombre:")) + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("Nombre del tablero") + config_row.addWidget(self.name_input, 1) + + config_row.addWidget(QLabel("Filas:")) + self.rows_spin = QSpinBox() + self.rows_spin.setRange(1, MAX_GRID) + config_row.addWidget(self.rows_spin) + + config_row.addWidget(QLabel("Columnas:")) + self.cols_spin = QSpinBox() + self.cols_spin.setRange(1, MAX_GRID) + config_row.addWidget(self.cols_spin) + + apply_btn = QPushButton("Aplicar tamaño") + apply_btn.setObjectName("themeToggle") + apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) + apply_btn.clicked.connect(self._on_apply_size) + config_row.addWidget(apply_btn) + grid_layout.addLayout(config_row) + + grid_hint = QLabel("2. Haz clic en una celda para asignar el medio") grid_hint.setStyleSheet("font-size: 11pt; font-weight: 600;") grid_layout.addWidget(grid_hint) + # Contenedor de grilla dentro de un scroll (para tamaños grandes) self.grid_container = QFrame() self.grid_layout = QGridLayout(self.grid_container) self.grid_layout.setSpacing(10) self.grid_layout.setContentsMargins(0, 0, 0, 0) - rows = self._board_data.get("grid_size", {}).get("rows", 2) - cols = self._board_data.get("grid_size", {}).get("cols", 2) - for row in range(rows): - for col in range(cols): - button = QPushButton() - button.setFixedSize(200, 200) - button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.clicked.connect( - lambda checked=False, r=row, c=col: self._on_cell_clicked(r, c) - ) - self.grid_layout.addWidget(button, row, col) - self._cell_buttons[(row, col)] = button - - grid_layout.addWidget(self.grid_container, 1) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.grid_container) + grid_layout.addWidget(scroll, 1) body_layout.addWidget(gallery_panel) body_layout.addWidget(grid_panel, 1) @@ -128,6 +167,9 @@ def _setup_ui(self): root_layout.addWidget(header_bar) root_layout.addLayout(body_layout, 1) + self._sync_size_spins() + + # ── Galería ─────────────────────────────────────────────────── def set_gallery(self, images, audios): self.image_list.clear() self.audio_list.clear() @@ -139,7 +181,7 @@ def set_gallery(self, images, audios): file_name = media.get("file_name", "") if not file_name: continue - item = QListWidgetItem(file_name) + item = QListWidgetItem(self._display_text(media)) item.setData( Qt.ItemDataRole.UserRole, {"file_name": file_name, "file_type": "image"}, @@ -150,16 +192,58 @@ def set_gallery(self, images, audios): file_name = media.get("file_name", "") if not file_name: continue - item = QListWidgetItem(file_name) + item = QListWidgetItem(self._display_text(media)) item.setData( Qt.ItemDataRole.UserRole, {"file_name": file_name, "file_type": "audio"}, ) self.audio_list.addItem(item) - def reset_board(self): + @staticmethod + def _display_text(media): + label = (media.get("label") or "").strip() + file_name = media.get("file_name", "") + return f"{label} · {file_name}" if label else file_name + + # ── Selector de tableros ────────────────────────────────────── + def set_boards(self, boards, active_id=None): + """Rellena el combo de tableros sin disparar la señal de selección.""" + self.board_selector.blockSignals(True) + self.board_selector.clear() + for board in boards: + self.board_selector.addItem(board.get("name", "Tablero"), board.get("id")) + if active_id is not None: + idx = self.board_selector.findData(active_id) + if idx >= 0: + self.board_selector.setCurrentIndex(idx) + self.board_selector.blockSignals(False) + + def _on_board_selected(self, _index): + board_id = self.board_selector.currentData() + if board_id: + self.board_selected.emit(board_id) + + # ── Carga / creación de tableros ────────────────────────────── + def load_board_data(self, board): + """Carga un tablero (crudo) en el editor.""" + self._board_data = self._normalize_board(board) + self.name_input.setText(self._board_data.get("name", "")) + self._sync_size_spins() + self._rebuild_grid() + + def new_board(self): self._board_data = self._default_board() - self._refresh_grid_labels() + self.name_input.setText(self._board_data["name"]) + self._sync_size_spins() + self._rebuild_grid() + # Deseleccionar el combo para indicar que es un tablero nuevo + self.board_selector.blockSignals(True) + self.board_selector.setCurrentIndex(-1) + self.board_selector.blockSignals(False) + + def reset_board(self): + """Compatibilidad: reinicia a un tablero nuevo por defecto.""" + self.new_board() def show_message(self, text, is_error=False): color = "#d32f2f" if is_error else "#388e3c" @@ -168,25 +252,90 @@ def show_message(self, text, is_error=False): self.message_label.show() QTimer.singleShot(3000, self.message_label.hide) + # ── Modelo interno del tablero ──────────────────────────────── def _default_board(self): + return self._normalize_board( + { + "id": f"board_{uuid.uuid4().hex[:8]}", + "name": "Tablero CAA", + "grid_size": {"rows": 2, "cols": 2}, + "items": [], + } + ) + + def _normalize_board(self, board): + """Garantiza id/nombre/tamaño y un item por cada celda de la grilla.""" + grid = board.get("grid_size") or {} + rows = self._clamp(grid.get("rows", 2)) + cols = self._clamp(grid.get("cols", 2)) + + existentes = { + tuple(item.get("position", [-1, -1])): item for item in board.get("items", []) + } items = [] - for row in range(2): - for col in range(2): + for row in range(rows): + for col in range(cols): + prev = existentes.get((row, col), {}) items.append( { "position": [row, col], - "label": "", - "image_file": "", - "audio_file": "", + "label": prev.get("label", ""), + "image_file": prev.get("image_file", ""), + "audio_file": prev.get("audio_file", ""), } ) return { - "id": "board_01", - "name": "Tablero CAA", - "grid_size": {"rows": 2, "cols": 2}, + "id": board.get("id", ""), + "name": board.get("name", "Tablero CAA"), + "grid_size": {"rows": rows, "cols": cols}, "items": items, } + @staticmethod + def _clamp(value): + try: + value = int(value) + except (TypeError, ValueError): + return 2 + return max(1, min(MAX_GRID, value)) + + def _sync_size_spins(self): + grid = self._board_data.get("grid_size", {}) + self.rows_spin.setValue(self._clamp(grid.get("rows", 2))) + self.cols_spin.setValue(self._clamp(grid.get("cols", 2))) + + def _on_apply_size(self): + rows = self.rows_spin.value() + cols = self.cols_spin.value() + self._board_data["grid_size"] = {"rows": rows, "cols": cols} + # Renormaliza preservando asignaciones dentro del nuevo rango + self._board_data = self._normalize_board(self._board_data) + self._rebuild_grid() + + # ── Grilla ──────────────────────────────────────────────────── + def _rebuild_grid(self): + # Limpia botones anteriores + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._cell_buttons = {} + + rows = self._board_data["grid_size"]["rows"] + cols = self._board_data["grid_size"]["cols"] + for row in range(rows): + for col in range(cols): + button = QPushButton() + button.setMinimumSize(150, 150) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect( + lambda checked=False, r=row, c=col: self._on_cell_clicked(r, c) + ) + self.grid_layout.addWidget(button, row, col) + self._cell_buttons[(row, col)] = button + self._refresh_grid_labels() + def _on_image_selected(self, item): self.audio_list.clearSelection() self._active_media = item.data(Qt.ItemDataRole.UserRole) @@ -198,25 +347,22 @@ def _on_audio_selected(self, item): def _on_cell_clicked(self, row, col): if not self._active_media: return - file_name = self._active_media.get("file_name", "") file_type = self._active_media.get("file_type", "") if not file_name: return - cell_item = self._get_cell_item(row, col) if not cell_item: return - if file_type == "image": cell_item["image_file"] = file_name elif file_type == "audio": cell_item["audio_file"] = file_name cell_item["label"] = self._label_from_audio(file_name) - self._refresh_cell_label(row, col) def _on_save_clicked(self): + self._board_data["name"] = self.name_input.text().strip() or "Tablero CAA" self.board_save_requested.emit(self._board_data) def _get_cell_item(self, row, col): @@ -232,17 +378,14 @@ def _label_from_audio(self, file_name): def _cell_text(self, item): if not item: return "Sin asignar" - image_file = item.get("image_file", "") audio_file = item.get("audio_file", "") label = item.get("label", "") parts = [] - if image_file: parts.append(f"IMG: {image_file}") if audio_file: parts.append(f"AUD: {label or audio_file}") - return "\n".join(parts) if parts else "Sin asignar" def _refresh_cell_label(self, row, col): diff --git a/src/views/caa_board_view.py b/src/views/caa_board_view.py index affaff4..8a1e5cd 100644 --- a/src/views/caa_board_view.py +++ b/src/views/caa_board_view.py @@ -1,6 +1,7 @@ from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtWidgets import ( + QComboBox, QFrame, QGridLayout, QHBoxLayout, @@ -15,6 +16,7 @@ class CAABoardView(QWidget): item_clicked = pyqtSignal(str) back_requested = pyqtSignal() generate_test_requested = pyqtSignal() + board_change_requested = pyqtSignal(str) def __init__(self): super().__init__() @@ -38,9 +40,15 @@ def _setup_ui(self): self.board_title = QLabel("Tablero CAA") self.board_title.setObjectName("headerTitle") + self.board_selector = QComboBox() + self.board_selector.setMinimumWidth(180) + self.board_selector.currentIndexChanged.connect(self._on_board_changed) + header_layout.addWidget(back_button) header_layout.addWidget(self.board_title) header_layout.addStretch() + header_layout.addWidget(QLabel("Tablero:")) + header_layout.addWidget(self.board_selector) self.grid_container = QFrame() self.grid_layout = QGridLayout(self.grid_container) @@ -49,6 +57,24 @@ def _setup_ui(self): root_layout.addWidget(header_bar) root_layout.addWidget(self.grid_container, 1) + def set_boards(self, boards, active_id=None): + """Rellena el selector de tableros sin disparar la señal de cambio.""" + self.board_selector.blockSignals(True) + self.board_selector.clear() + for board in boards: + self.board_selector.addItem(board.get("name", "Tablero"), board.get("id")) + self.board_selector.setVisible(len(boards) > 1) + if active_id is not None: + idx = self.board_selector.findData(active_id) + if idx >= 0: + self.board_selector.setCurrentIndex(idx) + self.board_selector.blockSignals(False) + + def _on_board_changed(self, _index): + board_id = self.board_selector.currentData() + if board_id: + self.board_change_requested.emit(board_id) + def _clear_grid(self): while self.grid_layout.count(): item = self.grid_layout.takeAt(0) diff --git a/src/views/dashboard_view.py b/src/views/dashboard_view.py index 50e1a8e..e215089 100644 --- a/src/views/dashboard_view.py +++ b/src/views/dashboard_view.py @@ -9,13 +9,19 @@ class DashboardView(QWidget): patient_save_requested = pyqtSignal(dict) + patient_update_requested = pyqtSignal(int, dict) + patient_delete_requested = pyqtSignal(int) + edit_requested = pyqtSignal(dict) caa_requested = pyqtSignal(dict) builder_requested = pyqtSignal(dict) exercise_requested = pyqtSignal(dict) - upload_requested = pyqtSignal(dict, str) + reports_requested = pyqtSignal(dict) + upload_requested = pyqtSignal(dict, str, str) def __init__(self): super().__init__() + self._editing_id = None # None = alta; int = edición en curso + self._delete_armed_id = None # confirmación de borrado en dos pasos self._setup_ui() def _setup_ui(self): @@ -55,6 +61,13 @@ def _setup_ui(self): self.save_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.save_btn.clicked.connect(self._on_save_clicked) form_layout.addWidget(self.save_btn) + + self.cancel_edit_btn = QPushButton("Cancelar edición") + self.cancel_edit_btn.setObjectName("themeToggle") + self.cancel_edit_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.cancel_edit_btn.clicked.connect(self._exit_edit_mode) + self.cancel_edit_btn.hide() + form_layout.addWidget(self.cancel_edit_btn) form_layout.addStretch() # ── Panel derecho: tabla ───────────────────────────────── @@ -73,6 +86,22 @@ def _setup_ui(self): self.patients_table.itemSelectionChanged.connect(self._on_selection_changed) table_layout.addWidget(self.patients_table) + actions_row = QHBoxLayout() + self.edit_btn = QPushButton("Editar Paciente") + self.edit_btn.setObjectName("themeToggle") + self.edit_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.edit_btn.setEnabled(False) + self.edit_btn.clicked.connect(self._on_edit_clicked) + actions_row.addWidget(self.edit_btn) + + self.delete_btn = QPushButton("Eliminar Paciente") + self.delete_btn.setObjectName("themeToggle") + self.delete_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.delete_btn.setEnabled(False) + self.delete_btn.clicked.connect(self._on_delete_clicked) + actions_row.addWidget(self.delete_btn) + table_layout.addLayout(actions_row) + self.caa_btn = QPushButton("Abrir Tablero CAA") self.caa_btn.setObjectName("themeToggle") self.caa_btn.setCursor(Qt.CursorShape.PointingHandCursor) @@ -94,6 +123,20 @@ def _setup_ui(self): self.exercise_btn.clicked.connect(self._on_exercise_clicked) table_layout.addWidget(self.exercise_btn) + self.reports_btn = QPushButton("Ver Reportes de Progreso") + self.reports_btn.setObjectName("themeToggle") + self.reports_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.reports_btn.setEnabled(False) + self.reports_btn.clicked.connect(self._on_reports_clicked) + table_layout.addWidget(self.reports_btn) + + self.media_label_input = QLineEdit() + self.media_label_input.setPlaceholderText( + "Etiqueta del medio (ej. 'Agua'). Opcional: si se deja vacía se usa el nombre del archivo." + ) + self.media_label_input.setEnabled(False) + table_layout.addWidget(self.media_label_input) + self.upload_btn = QPushButton("Subir Archivo (Foto/Audio)") self.upload_btn.setObjectName("themeToggle") self.upload_btn.setCursor(Qt.CursorShape.PointingHandCursor) @@ -119,15 +162,75 @@ def _on_save_clicked(self): "birth_date": self.birth_date_input.date().toString("yyyy-MM-dd"), "notes": self.notes_input.toPlainText().strip(), } - if data["first_name"] and data["last_name"]: + if not (data["first_name"] and data["last_name"]): + self.show_message("Nombre y apellido son obligatorios", is_error=True) + return + + if self._editing_id is None: self.patient_save_requested.emit(data) + else: + self.patient_update_requested.emit(self._editing_id, data) + self._exit_edit_mode() + + def _on_edit_clicked(self): + row = self.patients_table.currentRow() + if row < 0: + return + patient_id = int(self.patients_table.item(row, 0).text()) + self.edit_requested.emit({"id": patient_id}) + + def enter_edit_mode(self, patient): + """Carga el paciente en el formulario para editarlo.""" + self._editing_id = patient.get("id") + self.first_name_input.setText(patient.get("first_name", "")) + self.last_name_input.setText(patient.get("last_name", "")) + birth = patient.get("birth_date") + if birth: + self.birth_date_input.setDate(QDate.fromString(str(birth), "yyyy-MM-dd")) + self.notes_input.setPlainText(patient.get("notes") or "") + self.save_btn.setText("Actualizar Paciente") + self.cancel_edit_btn.show() + + def _exit_edit_mode(self): + self._editing_id = None + self.save_btn.setText("Guardar Paciente") + self.cancel_edit_btn.hide() + self.clear_form() + + def _on_delete_clicked(self): + row = self.patients_table.currentRow() + if row < 0: + return + patient_id = int(self.patients_table.item(row, 0).text()) + + # Confirmación en dos pasos (sin modales): primer clic arma, segundo borra + if self._delete_armed_id != patient_id: + self._delete_armed_id = patient_id + self.delete_btn.setText("Confirmar Eliminación") + self.show_message( + "Clic de nuevo en 'Confirmar Eliminación' para borrar de forma permanente", + is_error=True, + ) + return + + self._disarm_delete() + self.patient_delete_requested.emit(patient_id) + + def _disarm_delete(self): + self._delete_armed_id = None + self.delete_btn.setText("Eliminar Paciente") def _on_selection_changed(self): + self._disarm_delete() # cambiar de fila cancela un borrado armado has_selection = len(self.patients_table.selectedItems()) > 0 + self.edit_btn.setEnabled(has_selection) + self.delete_btn.setEnabled(has_selection) self.caa_btn.setEnabled(has_selection) self.builder_btn.setEnabled(has_selection) self.exercise_btn.setEnabled(has_selection) + self.reports_btn.setEnabled(has_selection) self.upload_btn.setEnabled(has_selection) + self.media_label_input.setEnabled(has_selection) def _on_caa_clicked(self): row = self.patients_table.currentRow() @@ -150,6 +253,13 @@ def _on_exercise_clicked(self): patient_id = int(self.patients_table.item(row, 0).text()) self.exercise_requested.emit({"id": patient_id}) + def _on_reports_clicked(self): + row = self.patients_table.currentRow() + if row < 0: + return + patient_id = int(self.patients_table.item(row, 0).text()) + self.reports_requested.emit({"id": patient_id}) + def _on_upload_clicked(self): row = self.patients_table.currentRow() if row < 0: @@ -163,7 +273,9 @@ def _on_upload_clicked(self): "Im\u00e1genes (*.png *.jpg *.jpeg);;Audios (*.mp3 *.wav)", ) if file_path: - self.upload_requested.emit({"id": patient_id}, file_path) + label = self.media_label_input.text().strip() + self.upload_requested.emit({"id": patient_id}, file_path, label) + self.media_label_input.clear() def show_message(self, text, is_error=False): color = "#d32f2f" if is_error else "#388e3c" diff --git a/src/views/main_window.py b/src/views/main_window.py index a1aaf79..0d9ae0f 100644 --- a/src/views/main_window.py +++ b/src/views/main_window.py @@ -1,4 +1,7 @@ -from PyQt6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QFrame, QPushButton, QLabel, QSpacerItem, QSizePolicy +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QFrame, QPushButton, + QLabel, QSpacerItem, QSizePolicy, QStackedWidget, +) from PyQt6.QtCore import pyqtSignal, Qt @@ -38,19 +41,18 @@ def setup_ui(self): header_layout.addItem(spacer) header_layout.addWidget(self.theme_btn) - # Área Central (inyectable) - self.content_area = QFrame() - self.content_layout = QVBoxLayout(self.content_area) + # Área Central (inyectable). Usamos un QStackedWidget para conservar + # las vistas (son singletons reutilizados por AppController); nunca se + # destruyen al navegar, solo se cambia la vista activa. + self.content_stack = QStackedWidget() main_layout.addWidget(header_bar) - main_layout.addWidget(self.content_area) + main_layout.addWidget(self.content_stack) def set_main_view(self, widget): - while self.content_layout.count(): - item = self.content_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - self.content_layout.addWidget(widget) + if self.content_stack.indexOf(widget) == -1: + self.content_stack.addWidget(widget) + self.content_stack.setCurrentWidget(widget) def update_theme_button(self, current_theme): if current_theme == "dark": diff --git a/src/views/reports_view.py b/src/views/reports_view.py new file mode 100644 index 0000000..cef669e --- /dev/null +++ b/src/views/reports_view.py @@ -0,0 +1,177 @@ +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import ( + QAbstractItemView, + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + + +class ReportsView(QWidget): + back_requested = pyqtSignal() + + def __init__(self): + super().__init__() + self._stat_labels = {} + self._setup_ui() + + def _setup_ui(self): + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + header_bar = QFrame() + header_bar.setObjectName("headerBar") + header_layout = QHBoxLayout(header_bar) + header_layout.setContentsMargins(20, 10, 20, 10) + + back_button = QPushButton("⬅ Volver al Dashboard") + back_button.setObjectName("themeToggle") + back_button.setCursor(Qt.CursorShape.PointingHandCursor) + back_button.clicked.connect(self.back_requested.emit) + + self.header_title = QLabel("Reportes de Progreso") + self.header_title.setObjectName("headerTitle") + + header_layout.addWidget(back_button) + header_layout.addWidget(self.header_title) + header_layout.addStretch() + + body = QFrame() + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(20, 20, 20, 20) + body_layout.setSpacing(16) + + # ── Tarjetas de estadísticas ────────────────────────────── + self.stats_panel = QFrame() + stats_layout = QGridLayout(self.stats_panel) + stats_layout.setSpacing(12) + + self._add_stat_card(stats_layout, 0, 0, "total", "Intentos totales") + self._add_stat_card(stats_layout, 0, 1, "accuracy", "Aciertos") + self._add_stat_card(stats_layout, 0, 2, "avg", "Tiempo promedio") + self._add_stat_card(stats_layout, 0, 3, "best", "Mejor tiempo") + + body_layout.addWidget(self.stats_panel) + + # ── Estado vacío ────────────────────────────────────────── + self.empty_label = QLabel( + "Este paciente aún no tiene ejercicios registrados.\n" + "Inicia un ejercicio semántico para empezar a medir su progreso." + ) + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setStyleSheet("font-size: 13pt; color: #888;") + self.empty_label.hide() + body_layout.addWidget(self.empty_label) + + # ── Tendencia diaria ────────────────────────────────────── + self.trend_title = QLabel("Tendencia por día") + self.trend_title.setStyleSheet("font-size: 12pt; font-weight: 600;") + body_layout.addWidget(self.trend_title) + + self.trend_table = self._make_table( + ["Día", "Intentos", "Aciertos", "Precisión", "T. promedio (ms)"] + ) + body_layout.addWidget(self.trend_table) + + # ── Últimos intentos ────────────────────────────────────── + self.recent_title = QLabel("Últimos intentos") + self.recent_title.setStyleSheet("font-size: 12pt; font-weight: 600;") + body_layout.addWidget(self.recent_title) + + self.recent_table = self._make_table( + ["Fecha y hora", "Resultado", "Tiempo (ms)"] + ) + body_layout.addWidget(self.recent_table, 1) + + root_layout.addWidget(header_bar) + root_layout.addWidget(body, 1) + + def _add_stat_card(self, layout, row, col, key, caption): + card = QFrame() + card.setObjectName("formPanel") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(16, 16, 16, 16) + + value = QLabel("—") + value.setAlignment(Qt.AlignmentFlag.AlignCenter) + value.setStyleSheet("font-size: 22pt; font-weight: 700;") + + label = QLabel(caption) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet("font-size: 10pt; color: #888;") + + card_layout.addWidget(value) + card_layout.addWidget(label) + layout.addWidget(card, row, col) + self._stat_labels[key] = value + + def _make_table(self, headers): + table = QTableWidget() + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + table.verticalHeader().setVisible(False) + table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch + ) + return table + + def render_report(self, patient_name, summary, recent, daily): + self.header_title.setText(f"Progreso — {patient_name}") + + has_data = summary.get("total", 0) > 0 + self.stats_panel.setVisible(has_data) + self.trend_title.setVisible(has_data) + self.trend_table.setVisible(has_data) + self.recent_title.setVisible(has_data) + self.recent_table.setVisible(has_data) + self.empty_label.setVisible(not has_data) + + if not has_data: + return + + self._stat_labels["total"].setText(str(summary["total"])) + self._stat_labels["accuracy"].setText( + f"{summary['accuracy']:.0f}% ({summary['correctos']}/{summary['total']})" + ) + self._stat_labels["avg"].setText(self._fmt_ms(summary["tiempo_promedio_ms"])) + self._stat_labels["best"].setText(self._fmt_ms(summary["mejor_tiempo_ms"])) + + self._fill_trend(daily) + self._fill_recent(recent) + + def _fill_trend(self, daily): + self.trend_table.setRowCount(len(daily)) + for r, d in enumerate(daily): + self.trend_table.setItem(r, 0, QTableWidgetItem(str(d["dia"]))) + self.trend_table.setItem(r, 1, QTableWidgetItem(str(d["total"]))) + self.trend_table.setItem(r, 2, QTableWidgetItem(str(d["correctos"]))) + self.trend_table.setItem(r, 3, QTableWidgetItem(f"{d['accuracy']:.0f}%")) + self.trend_table.setItem( + r, 4, QTableWidgetItem(self._fmt_ms(d["tiempo_promedio_ms"])) + ) + + def _fill_recent(self, recent): + self.recent_table.setRowCount(len(recent)) + for r, a in enumerate(recent): + resultado = "✔ Acierto" if a["is_correct"] else "✘ Fallo" + self.recent_table.setItem(r, 0, QTableWidgetItem(str(a["created_at"]))) + self.recent_table.setItem(r, 1, QTableWidgetItem(resultado)) + self.recent_table.setItem( + r, 2, QTableWidgetItem(self._fmt_ms(a["reaction_time_ms"])) + ) + + @staticmethod + def _fmt_ms(value): + if value is None: + return "—" + return f"{value:.0f}" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e13b19f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,82 @@ +"""Configuración compartida de pytest. + +Aísla la capa de datos en un directorio temporal por test, sin tocar la +carpeta real ``data/`` ni ``media/`` del proyecto. No importa PyQt6: los +tests de ``models/`` corren headless. +""" + +import os +import sys + +import pytest + +_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from utils.path_resolver import PathResolver # noqa: E402 +from models.database_manager import DatabaseManager # noqa: E402 +from models.patient_model import PatientModel # noqa: E402 + + +@pytest.fixture +def app_root(tmp_path, monkeypatch): + """Redirige la raíz de datos de la app al tmp_path del test.""" + root = str(tmp_path) + monkeypatch.setattr(PathResolver, "get_app_data_path", staticmethod(lambda: root)) + return tmp_path + + +@pytest.fixture +def db(app_root): + manager = DatabaseManager() + manager.initialize_database() + yield manager + manager.close() + + +@pytest.fixture +def patient_model(db): + return PatientModel(db) + + +@pytest.fixture +def make_image(tmp_path): + """Crea un PNG mínimo válido y devuelve su ruta.""" + from PIL import Image + + counter = {"n": 0} + + def _make(name="foto.png", size=(8, 8), color=(120, 200, 90)): + counter["n"] += 1 + # Subcarpeta única para preservar el basename exacto solicitado + sub = tmp_path / "src_img" / str(counter["n"]) + sub.mkdir(parents=True, exist_ok=True) + path = sub / name + Image.new("RGB", size, color).save(path) + return str(path) + + return _make + + +@pytest.fixture +def make_audio(tmp_path): + """Crea un archivo .wav mínimo (cabecera RIFF) y devuelve su ruta.""" + import struct + import wave + + counter = {"n": 0} + + def _make(name="sonido.wav"): + counter["n"] += 1 + sub = tmp_path / "src_aud" / str(counter["n"]) + sub.mkdir(parents=True, exist_ok=True) + path = sub / name + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(8000) + wav.writeframes(struct.pack(" una fila con 2 intentos + assert len(dias) == 1 + assert dias[0]["total"] == 2 + assert dias[0]["correctos"] == 1 + assert abs(dias[0]["accuracy"] - 50.0) < 0.001 diff --git a/tests/test_patient_model.py b/tests/test_patient_model.py new file mode 100644 index 0000000..e04484c --- /dev/null +++ b/tests/test_patient_model.py @@ -0,0 +1,185 @@ +"""Tests de PatientModel: CRUD, importación de medios y utilidades.""" + +import os + + +def test_create_patient_devuelve_registro_con_carpeta(patient_model, db): + p = patient_model.create_patient("Ana", "García", "1950-05-01", "notas") + + assert p["id"] > 0 + assert p["first_name"] == "Ana" + assert p["last_name"] == "García" + assert p["media_folder"] # UUID no vacío + # La carpeta de medios se crea físicamente + assert os.path.isdir(os.path.join(db.media_dir, p["media_folder"])) + + +def test_media_folder_es_unico_por_paciente(patient_model): + a = patient_model.create_patient("Ana", "Uno") + b = patient_model.create_patient("Ben", "Dos") + assert a["media_folder"] != b["media_folder"] + + +def test_get_all_patients_ordena_por_mas_reciente(patient_model): + patient_model.create_patient("Uno", "A") + patient_model.create_patient("Dos", "B") + todos = patient_model.get_all_patients() + assert len(todos) == 2 + # created_at DESC: el último creado va primero (o empatan en el mismo tick) + ids = [p["id"] for p in todos] + assert set(ids) == {1, 2} + + +def test_get_patient_by_id_inexistente_devuelve_none(patient_model): + assert patient_model.get_patient_by_id(999) is None + + +def test_update_patient_modifica_campos_permitidos(patient_model): + p = patient_model.create_patient("Ana", "García", notes="vieja") + actualizado = patient_model.update_patient(p["id"], first_name="Ana María", notes="nueva") + assert actualizado["first_name"] == "Ana María" + assert actualizado["notes"] == "nueva" + + +def test_update_patient_ignora_campos_no_permitidos(patient_model): + p = patient_model.create_patient("Ana", "García") + original_folder = p["media_folder"] + actualizado = patient_model.update_patient(p["id"], media_folder="hackeado", id=42) + assert actualizado["media_folder"] == original_folder + assert actualizado["id"] == p["id"] + + +def test_import_media_imagen(patient_model, db, make_image): + p = patient_model.create_patient("Ana", "García") + src = make_image("gato.png") + + nombre = patient_model.import_media(p["id"], src) + + assert nombre == "gato.png" + # Copiado físicamente a la carpeta del paciente + dest = os.path.join(db.media_dir, p["media_folder"], "gato.png") + assert os.path.isfile(dest) + # Registrado en la BD como 'image' + medios = patient_model.get_patient_media(p["id"], "image") + assert len(medios) == 1 + assert medios[0]["file_type"] == "image" + + +def test_import_media_audio(patient_model, db, make_audio): + p = patient_model.create_patient("Ana", "García") + nombre = patient_model.import_media(p["id"], make_audio("hola.wav")) + assert nombre == "hola.wav" + medios = patient_model.get_patient_media(p["id"], "audio") + assert len(medios) == 1 and medios[0]["file_type"] == "audio" + + +def test_import_media_tipo_no_soportado(patient_model, tmp_path): + p = patient_model.create_patient("Ana", "García") + txt = tmp_path / "notas.txt" + txt.write_text("hola") + assert patient_model.import_media(p["id"], str(txt)) is None + + +def test_import_media_archivo_inexistente(patient_model): + p = patient_model.create_patient("Ana", "García") + assert patient_model.import_media(p["id"], "no_existe.png") is None + + +def test_import_media_paciente_inexistente(patient_model, make_image): + src = make_image() + assert patient_model.import_media(999, src) is None + + +def test_import_media_nombre_duplicado_no_sobrescribe(patient_model, db, make_image): + p = patient_model.create_patient("Ana", "García") + n1 = patient_model.import_media(p["id"], make_image("dup.png")) + n2 = patient_model.import_media(p["id"], make_image("dup.png")) + assert n1 == "dup.png" + assert n2 != n1 # segundo recibe sufijo único + assert n2.startswith("dup_") and n2.endswith(".png") + # Ambos archivos existen + folder = os.path.join(db.media_dir, p["media_folder"]) + assert os.path.isfile(os.path.join(folder, n1)) + assert os.path.isfile(os.path.join(folder, n2)) + + +def test_get_patient_media_filtra_por_tipo(patient_model, make_image, make_audio): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("i.png")) + patient_model.import_media(p["id"], make_audio("a.wav")) + + solo_img = patient_model.get_patient_media(p["id"], "image") + solo_aud = patient_model.get_patient_media(p["id"], "audio") + todos = patient_model.get_patient_media(p["id"]) + + assert len(solo_img) == 1 and solo_img[0]["file_type"] == "image" + assert len(solo_aud) == 1 and solo_aud[0]["file_type"] == "audio" + assert len(todos) == 2 + + +def test_import_media_guarda_label_explicita(patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("IMG_9921.png"), label="Perro") + medio = patient_model.get_patient_media(p["id"], "image")[0] + assert medio["label"] == "Perro" + + +def test_import_media_label_por_defecto_es_nombre_sin_extension(patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("gato.png")) + medio = patient_model.get_patient_media(p["id"], "image")[0] + assert medio["label"] == "gato" + + +def test_import_media_label_vacia_cae_al_nombre(patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("casa.png"), label=" ") + medio = patient_model.get_patient_media(p["id"], "image")[0] + assert medio["label"] == "casa" + + +def test_delete_patient_borra_todo_en_cascada(patient_model, db, make_image): + from models.semantic_exercise_model import SemanticExerciseModel + + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("i.png")) + SemanticExerciseModel(db, patient_model).save_metric(p["id"], True, 100.0) + folder = os.path.join(db.media_dir, p["media_folder"]) + assert os.path.isdir(folder) + + assert patient_model.delete_patient(p["id"]) is True + + # Paciente y dependencias eliminados + assert patient_model.get_patient_by_id(p["id"]) is None + assert patient_model.get_patient_media(p["id"]) == [] + conn = db._get_connection() + n = conn.execute( + "SELECT COUNT(*) AS c FROM exercise_metrics WHERE patient_id = ?", (p["id"],) + ).fetchone()["c"] + assert n == 0 + # Carpeta de medios borrada del disco + assert not os.path.exists(folder) + + +def test_delete_patient_inexistente_devuelve_false(patient_model): + assert patient_model.delete_patient(999) is False + + +def test_delete_patient_no_afecta_a_otros(patient_model, db, make_image): + a = patient_model.create_patient("Ana", "Uno") + b = patient_model.create_patient("Ben", "Dos") + patient_model.import_media(b["id"], make_image("b.png")) + + patient_model.delete_patient(a["id"]) + + assert patient_model.get_patient_by_id(b["id"]) is not None + assert len(patient_model.get_patient_media(b["id"])) == 1 + assert os.path.isdir(os.path.join(db.media_dir, b["media_folder"])) + + +def test_get_file_type_extensiones(patient_model): + assert patient_model._get_file_type("x.PNG") == "image" + assert patient_model._get_file_type("x.jpeg") == "image" + assert patient_model._get_file_type("x.MP3") == "audio" + assert patient_model._get_file_type("x.wav") == "audio" + assert patient_model._get_file_type("x.txt") is None diff --git a/tests/test_semantic_exercise_model.py b/tests/test_semantic_exercise_model.py new file mode 100644 index 0000000..26fe51a --- /dev/null +++ b/tests/test_semantic_exercise_model.py @@ -0,0 +1,78 @@ +"""Tests de SemanticExerciseModel: métricas y generación de estímulos.""" + +from models.semantic_exercise_model import SemanticExerciseModel + + +def _model(db, patient_model): + return SemanticExerciseModel(db, patient_model) + + +def test_save_metric_persiste_fila(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = _model(db, patient_model) + + ok = model.save_metric(p["id"], is_correct=True, reaction_time_ms=1234.5) + assert ok is True + + conn = db._get_connection() + row = conn.execute( + "SELECT patient_id, exercise_type, is_correct, reaction_time_ms " + "FROM exercise_metrics WHERE patient_id = ?", + (p["id"],), + ).fetchone() + assert row["patient_id"] == p["id"] + assert row["exercise_type"] == "semantic" + assert row["is_correct"] == 1 + assert abs(row["reaction_time_ms"] - 1234.5) < 0.001 + + +def test_save_metric_incorrecto_guarda_cero(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = _model(db, patient_model) + model.save_metric(p["id"], is_correct=False, reaction_time_ms=10.0) + conn = db._get_connection() + row = conn.execute( + "SELECT is_correct FROM exercise_metrics WHERE patient_id = ?", (p["id"],) + ).fetchone() + assert row["is_correct"] == 0 + + +def test_get_next_stimulus_sin_imagenes_devuelve_none(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = _model(db, patient_model) + assert model.get_next_stimulus(p["id"]) is None + + +def test_get_next_stimulus_construye_opciones(db, patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + # Tres imágenes -> 1 correcta + 2 distractores + for nombre in ("perro.png", "gato.png", "casa.png"): + patient_model.import_media(p["id"], make_image(nombre)) + + model = _model(db, patient_model) + stim = model.get_next_stimulus(p["id"]) + + assert stim is not None + assert stim["image_bytes"] # bytes de la imagen leídos + assert stim["correct_answer"] in stim["options"] + assert len(stim["options"]) == 3 + assert len(set(stim["options"])) == 3 # sin duplicados + + +def test_get_next_stimulus_usa_label_no_nombre_de_archivo(db, patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("IMG_0001.png"), label="Manzana") + model = _model(db, patient_model) + stim = model.get_next_stimulus(p["id"]) + assert stim["correct_answer"] == "Manzana" + assert "IMG_0001" not in stim["options"] + + +def test_get_next_stimulus_una_sola_imagen_rellena_distractores(db, patient_model, make_image): + p = patient_model.create_patient("Ana", "García") + patient_model.import_media(p["id"], make_image("solo.png")) + model = _model(db, patient_model) + stim = model.get_next_stimulus(p["id"]) + assert stim is not None + assert len(stim["options"]) == 3 + assert stim["correct_answer"] in stim["options"] diff --git a/tools/generate_icon.py b/tools/generate_icon.py new file mode 100644 index 0000000..ca6f568 --- /dev/null +++ b/tools/generate_icon.py @@ -0,0 +1,70 @@ +"""Genera el ícono de la aplicación (assets/icon.ico y assets/icon.png). + +Diseño: cuadrado redondeado en verde azulado (teal) con una burbuja de +diálogo blanca y la inicial "M" — evoca comunicación/lenguaje, el dominio de +la app. Reproducible: correr `python tools/generate_icon.py`. +""" + +import os + +from PIL import Image, ImageDraw, ImageFont + +BASE = 512 +TEAL = (13, 148, 136, 255) # fondo +TEAL_DARK = (15, 118, 110, 255) # borde inferior sutil +WHITE = (255, 255, 255, 255) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ASSETS = os.path.join(ROOT, "assets") + + +def _rounded(draw, box, radius, fill): + draw.rounded_rectangle(box, radius=radius, fill=fill) + + +def _load_font(size): + for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def build(): + img = Image.new("RGBA", (BASE, BASE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Fondo redondeado + margin = 24 + _rounded(draw, (margin, margin, BASE - margin, BASE - margin), 96, TEAL) + + # Burbuja de diálogo blanca + bx0, by0, bx1, by1 = 120, 110, 392, 330 + _rounded(draw, (bx0, by0, bx1, by1), 56, WHITE) + # Cola de la burbuja + draw.polygon([(180, 320), (150, 400), (250, 330)], fill=WHITE) + + # Letra "M" en teal centrada en la burbuja + font = _load_font(180) + text = "M" + tb = draw.textbbox((0, 0), text, font=font) + tw, th = tb[2] - tb[0], tb[3] - tb[1] + cx = (bx0 + bx1) / 2 - tw / 2 - tb[0] + cy = (by0 + by1) / 2 - th / 2 - tb[1] + draw.text((cx, cy), text, font=font, fill=TEAL_DARK) + + os.makedirs(ASSETS, exist_ok=True) + png_path = os.path.join(ASSETS, "icon.png") + ico_path = os.path.join(ASSETS, "icon.ico") + img.save(png_path) + img.save( + ico_path, + sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], + ) + print(f"Generado: {png_path}") + print(f"Generado: {ico_path}") + + +if __name__ == "__main__": + build()