From de2139f31b4e2b144bbbcd42524c543dc401a435 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 18:55:27 -0500 Subject: [PATCH 01/10] fix: eliminar crash de navegacion al reutilizar vistas MainWindow.set_main_view destruia la vista saliente con deleteLater(), pero AppController reutiliza instancias unicas de cada vista. Al volver a una vista ya mostrada se referenciaba un objeto C++ destruido y la app lanzaba RuntimeError. - Reemplaza el QVBoxLayout inyectable por un QStackedWidget - Las vistas se registran una sola vez y se alternan con setCurrentWidget - Verificado con repro headless y smoke test de navegacion completa --- src/views/main_window.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) 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": From 435b0de8c470d69a56330c0c7797a3787945c886 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 18:57:57 -0500 Subject: [PATCH 02/10] test: agregar suite de pruebas de la capa de modelos Cubre la logica de datos headless (sin PyQt6): - PatientModel: CRUD, import_media (imagen/audio/duplicados/errores), filtrado por tipo y deteccion de extension - SemanticExerciseModel: persistencia de metricas y armado de estimulos - CAABoardModel: round-trip de tableros y tolerancia a JSON corrupto 24 tests en verde. Fixtures aislan la BD en un tmp_path por test. --- pytest.ini | 4 + requirements-dev.txt | 2 + tests/conftest.py | 82 +++++++++++++++++ tests/test_caa_board_model.py | 74 +++++++++++++++ tests/test_patient_model.py | 125 ++++++++++++++++++++++++++ tests/test_semantic_exercise_model.py | 69 ++++++++++++++ 6 files changed, 356 insertions(+) create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_caa_board_model.py create mode 100644 tests/test_patient_model.py create mode 100644 tests/test_semantic_exercise_model.py 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/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(" 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_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..7a04909 --- /dev/null +++ b/tests/test_semantic_exercise_model.py @@ -0,0 +1,69 @@ +"""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_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"] From b407118de8f68d9acea49caabb33082a4f2ef65a Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 18:58:44 -0500 Subject: [PATCH 03/10] chore: eliminar esqueleto Django y documentar la app offline - Borra apps/api/manage.py, unico vestigio de la API en la nube descartada (contradecia el requisito 100% offline) - Reescribe docs/mvp.md con los modulos clinicos reales, el modelo de datos y los principios de diseno --- apps/api/manage.py | 16 ------------- docs/mvp.md | 57 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 19 deletions(-) delete mode 100644 apps/api/manage.py 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/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. From 29331148e14613658907ce11b1cab871d4260feb Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 18:59:11 -0500 Subject: [PATCH 04/10] ci: agregar workflow de pruebas en GitHub Actions Corre pytest sobre Python 3.13 en cada push y pull request contra main. La capa de modelos es headless, por lo que no requiere display. --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 44368b31c7ccf111bb695952b8dcf011390a24b5 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:02:19 -0500 Subject: [PATCH 05/10] feat: etiquetar medios y usar etiquetas en el ejercicio semantico Antes el ejercicio semantico derivaba la respuesta y los distractores del nombre de archivo (p. ej. 'IMG_9921'), lo que resultaba clinicamente inutil. - patient_media gana la columna 'label' con migracion idempotente para BD existentes (ALTER TABLE ... ADD COLUMN si falta) - import_media acepta una etiqueta; por defecto usa el nombre sin extension - SemanticExerciseModel arma las opciones con la etiqueta (fallback al nombre de archivo para datos antiguos) - Dashboard: campo de etiqueta al subir un medio - Constructor CAA: la galeria muestra la etiqueta legible - Tests: label por defecto/explicita/vacia, uso en el estimulo y migracion 31 tests en verde. --- src/controllers/dashboard_controller.py | 4 +- src/models/database_manager.py | 13 ++++++ src/models/patient_model.py | 13 ++++-- src/models/semantic_exercise_model.py | 16 ++++++-- src/views/builder_view.py | 11 ++++- src/views/dashboard_view.py | 14 ++++++- tests/test_database_manager.py | 53 +++++++++++++++++++++++++ tests/test_patient_model.py | 21 ++++++++++ tests/test_semantic_exercise_model.py | 9 +++++ 9 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 tests/test_database_manager.py diff --git a/src/controllers/dashboard_controller.py b/src/controllers/dashboard_controller.py index 10a17f8..bdfaa12 100644 --- a/src/controllers/dashboard_controller.py +++ b/src/controllers/dashboard_controller.py @@ -22,9 +22,9 @@ 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/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/patient_model.py b/src/models/patient_model.py index 96072e7..dd818e9 100644 --- a/src/models/patient_model.py +++ b/src/models/patient_model.py @@ -59,7 +59,7 @@ 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 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 +82,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 +97,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..16320a6 100644 --- a/src/views/builder_view.py +++ b/src/views/builder_view.py @@ -139,7 +139,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,13 +150,20 @@ 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) + @staticmethod + def _display_text(media): + """Muestra la etiqueta legible; si no hay, el nombre del archivo.""" + label = (media.get("label") or "").strip() + file_name = media.get("file_name", "") + return f"{label} · {file_name}" if label else file_name + def reset_board(self): self._board_data = self._default_board() self._refresh_grid_labels() diff --git a/src/views/dashboard_view.py b/src/views/dashboard_view.py index 50e1a8e..0f8dd95 100644 --- a/src/views/dashboard_view.py +++ b/src/views/dashboard_view.py @@ -12,7 +12,7 @@ class DashboardView(QWidget): caa_requested = pyqtSignal(dict) builder_requested = pyqtSignal(dict) exercise_requested = pyqtSignal(dict) - upload_requested = pyqtSignal(dict, str) + upload_requested = pyqtSignal(dict, str, str) def __init__(self): super().__init__() @@ -94,6 +94,13 @@ def _setup_ui(self): self.exercise_btn.clicked.connect(self._on_exercise_clicked) table_layout.addWidget(self.exercise_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) @@ -128,6 +135,7 @@ def _on_selection_changed(self): self.builder_btn.setEnabled(has_selection) self.exercise_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() @@ -163,7 +171,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/tests/test_database_manager.py b/tests/test_database_manager.py new file mode 100644 index 0000000..2491c53 --- /dev/null +++ b/tests/test_database_manager.py @@ -0,0 +1,53 @@ +"""Tests de DatabaseManager: esquema y migración idempotente de 'label'.""" + +import sqlite3 + +from models.database_manager import DatabaseManager + + +def _columns(conn, table): + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + +def test_initialize_crea_tablas_y_columna_label(db): + conn = db._get_connection() + tablas = { + r["name"] + for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + assert {"patients", "patient_media", "exercise_metrics"} <= tablas + assert "label" in _columns(conn, "patient_media") + + +def test_migracion_agrega_label_a_bd_antigua(app_root): + """Simula una BD v1 (sin columna label) y verifica que se migra.""" + manager = DatabaseManager() + # Crea la tabla vieja SIN 'label', como en versiones previas + conn = manager._get_connection() + conn.execute( + """ + CREATE TABLE patient_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + patient_id INTEGER NOT NULL, + file_name TEXT NOT NULL, + file_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() + assert "label" not in _columns(conn, "patient_media") + + # initialize_database debe migrar sin borrar datos + manager.initialize_database() + assert "label" in _columns(conn, "patient_media") + + # Idempotente: correr de nuevo no falla + manager.initialize_database() + manager.close() + + +def test_column_exists_helper(db): + conn = db._get_connection() + assert db._column_exists(conn, "patient_media", "file_name") is True + assert db._column_exists(conn, "patient_media", "no_existe") is False diff --git a/tests/test_patient_model.py b/tests/test_patient_model.py index 9e2c378..2f48894 100644 --- a/tests/test_patient_model.py +++ b/tests/test_patient_model.py @@ -117,6 +117,27 @@ def test_get_patient_media_filtra_por_tipo(patient_model, make_image, make_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_get_file_type_extensiones(patient_model): assert patient_model._get_file_type("x.PNG") == "image" assert patient_model._get_file_type("x.jpeg") == "image" diff --git a/tests/test_semantic_exercise_model.py b/tests/test_semantic_exercise_model.py index 7a04909..26fe51a 100644 --- a/tests/test_semantic_exercise_model.py +++ b/tests/test_semantic_exercise_model.py @@ -59,6 +59,15 @@ def test_get_next_stimulus_construye_opciones(db, patient_model, make_image): 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")) From a6b9e1762f2c73d738828099426e966ce5520ab1 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:05:17 -0500 Subject: [PATCH 06/10] feat: vista de reportes de progreso por paciente Las metricas del ejercicio semantico se guardaban pero nunca se mostraban. - MetricsModel: resumen (intentos, % aciertos, tiempo promedio/mejor), ultimos intentos y precision diaria; solo lectura - ReportsView: tarjetas de estadisticas + tablas de tendencia diaria y ultimos intentos; estado vacio inline (sin modales) - ReportsController + boton 'Ver Reportes de Progreso' en el dashboard, cableado en AppController - Tests de MetricsModel (accuracy, tiempos, aislamiento, limite, diario) 37 tests en verde; smoke de navegacion incluye la vista de reportes. --- src/controllers/app_controller.py | 21 +++ src/controllers/reports_controller.py | 19 +++ src/models/metrics_model.py | 89 +++++++++++++ src/views/dashboard_view.py | 16 +++ src/views/reports_view.py | 177 ++++++++++++++++++++++++++ tests/test_metrics_model.py | 59 +++++++++ 6 files changed, 381 insertions(+) create mode 100644 src/controllers/reports_controller.py create mode 100644 src/models/metrics_model.py create mode 100644 src/views/reports_view.py create mode 100644 tests/test_metrics_model.py 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/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/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/views/dashboard_view.py b/src/views/dashboard_view.py index 0f8dd95..d104a81 100644 --- a/src/views/dashboard_view.py +++ b/src/views/dashboard_view.py @@ -12,6 +12,7 @@ class DashboardView(QWidget): caa_requested = pyqtSignal(dict) builder_requested = pyqtSignal(dict) exercise_requested = pyqtSignal(dict) + reports_requested = pyqtSignal(dict) upload_requested = pyqtSignal(dict, str, str) def __init__(self): @@ -94,6 +95,13 @@ 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." @@ -134,6 +142,7 @@ def _on_selection_changed(self): 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) @@ -158,6 +167,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: 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/test_metrics_model.py b/tests/test_metrics_model.py new file mode 100644 index 0000000..3a49f11 --- /dev/null +++ b/tests/test_metrics_model.py @@ -0,0 +1,59 @@ +"""Tests de MetricsModel: agregación de exercise_metrics.""" + +from models.metrics_model import MetricsModel +from models.semantic_exercise_model import SemanticExerciseModel + + +def _seed(db, patient_model, resultados): + """resultados: lista de (is_correct, reaction_ms).""" + p = patient_model.create_patient("Ana", "García") + sem = SemanticExerciseModel(db, patient_model) + for correcto, ms in resultados: + sem.save_metric(p["id"], correcto, ms) + return p + + +def test_summary_sin_datos(db, patient_model): + p = patient_model.create_patient("Ana", "García") + s = MetricsModel(db).get_patient_summary(p["id"]) + assert s["total"] == 0 + assert s["accuracy"] == 0.0 + assert s["tiempo_promedio_ms"] is None + assert s["mejor_tiempo_ms"] is None + + +def test_summary_calcula_accuracy_y_tiempos(db, patient_model): + p = _seed(db, patient_model, [(True, 1000.0), (False, 2000.0), (True, 3000.0)]) + s = MetricsModel(db).get_patient_summary(p["id"]) + assert s["total"] == 3 + assert s["correctos"] == 2 + assert s["incorrectos"] == 1 + assert abs(s["accuracy"] - (2 / 3 * 100)) < 0.001 + assert abs(s["tiempo_promedio_ms"] - 2000.0) < 0.001 + assert s["mejor_tiempo_ms"] == 1000.0 + + +def test_summary_aislado_por_paciente(db, patient_model): + p1 = _seed(db, patient_model, [(True, 500.0)]) + p2 = _seed(db, patient_model, [(False, 900.0), (False, 800.0)]) + m = MetricsModel(db) + assert m.get_patient_summary(p1["id"])["total"] == 1 + assert m.get_patient_summary(p2["id"])["total"] == 2 + assert m.get_patient_summary(p2["id"])["correctos"] == 0 + + +def test_recent_attempts_respeta_limite(db, patient_model): + p = _seed(db, patient_model, [(True, float(i)) for i in range(30)]) + recientes = MetricsModel(db).get_recent_attempts(p["id"], limit=5) + assert len(recientes) == 5 + assert all("reaction_time_ms" in r for r in recientes) + + +def test_daily_accuracy_agrupa(db, patient_model): + p = _seed(db, patient_model, [(True, 100.0), (False, 200.0)]) + dias = MetricsModel(db).get_daily_accuracy(p["id"]) + # Todo en el mismo día (mismo tick) -> 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 From 36447ca0fe8a8b0fe0c9252f34e8b96946107854 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:07:44 -0500 Subject: [PATCH 07/10] feat: editar y eliminar pacientes desde el dashboard - PatientModel.delete_patient: borra metricas, medios (BD) y la carpeta de medios en disco antes de eliminar al paciente; aislado por paciente - Dashboard: botones Editar (carga el paciente en el formulario, el boton Guardar pasa a Actualizar) y Eliminar con confirmacion inline en dos pasos (sin modales, cumpliendo la regla clinica de UI) - DashboardController: rutas de edicion/actualizacion/borrado con refresco - Tests de cascada (metricas+medios+carpeta), inexistente y aislamiento Validado en vivo: editar actualiza y sale de modo edicion; borrar exige segundo clic de confirmacion. --- src/controllers/dashboard_controller.py | 26 ++++++++ src/models/patient_model.py | 25 +++++++ src/views/dashboard_view.py | 88 ++++++++++++++++++++++++- tests/test_patient_model.py | 39 +++++++++++ 4 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/controllers/dashboard_controller.py b/src/controllers/dashboard_controller.py index bdfaa12..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,6 +21,29 @@ 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) diff --git a/src/models/patient_model.py b/src/models/patient_model.py index dd818e9..2833193 100644 --- a/src/models/patient_model.py +++ b/src/models/patient_model.py @@ -59,6 +59,31 @@ def update_patient(self, patient_id, **kwargs): conn.commit() return self.get_patient_by_id(patient_id) + 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: diff --git a/src/views/dashboard_view.py b/src/views/dashboard_view.py index d104a81..e215089 100644 --- a/src/views/dashboard_view.py +++ b/src/views/dashboard_view.py @@ -9,6 +9,9 @@ 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) @@ -17,6 +20,8 @@ class DashboardView(QWidget): 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): @@ -56,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 ───────────────────────────────── @@ -74,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) @@ -134,11 +162,69 @@ 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) diff --git a/tests/test_patient_model.py b/tests/test_patient_model.py index 2f48894..e04484c 100644 --- a/tests/test_patient_model.py +++ b/tests/test_patient_model.py @@ -138,6 +138,45 @@ def test_import_media_label_vacia_cae_al_nombre(patient_model, make_image): 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" From 86bf2fa5b1a20ce2d046fa4bb39df9b268736f19 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:08:21 -0500 Subject: [PATCH 08/10] docs: corregir la afirmacion de cifrado en reposo La BD SQLite no esta cifrada, pero README afirmaba 'datos locales cifrados'. - Reemplaza la afirmacion por el estado real y agrega seccion 'Seguridad y privacidad' - Documenta la migracion a SQLCipher como mejora futura y por que se difiere (binarios nativos vs empaquetado PyInstaller en Windows) - Advierte no usar en equipo compartido sin cuenta protegida --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c526f3c..300c2b7 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,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 From 7feb19fe212b4c78a4849fb63ac3798bc6a97503 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:12:46 -0500 Subject: [PATCH 09/10] feat: multiples tableros CAA por paciente y grilla configurable Antes save_board sobrescribia boards[0]: un solo tablero fijo 2x2 por paciente. Modelo (CAABoardModel): - Upsert por id (list_boards/load_board/delete_board); ya no se pierden los demas tableros al guardar - create_test_board y load_first_board reusan la nueva logica Constructor visual: - Selector de tablero + boton 'Nuevo'; nombre editable - Tamano de grilla configurable (1..4 filas x columnas) con preservacion de asignaciones dentro del nuevo rango; grilla en area con scroll - Cada tablero nace con id propio para que reguardar actualice (no duplique) Visor CAA (lado paciente): - Selector de tablero en el encabezado (visible con 2+ tableros) Tests: upsert sin borrar otros, actualizar por id, id autogenerado, cargar por id y borrar. 44 tests en verde; smokes de navegacion y multi-tablero. --- src/controllers/builder_controller.py | 25 +++- src/controllers/caa_controller.py | 12 +- src/models/caa_board_model.py | 185 +++++++++++++---------- src/views/builder_view.py | 206 +++++++++++++++++++++----- src/views/caa_board_view.py | 26 ++++ tests/test_caa_board_model.py | 52 +++++++ 6 files changed, 391 insertions(+), 115 deletions(-) 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/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/views/builder_view.py b/src/views/builder_view.py index 16320a6..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() @@ -159,14 +201,49 @@ def set_gallery(self, images, audios): @staticmethod def _display_text(media): - """Muestra la etiqueta legible; si no hay, el nombre del archivo.""" label = (media.get("label") or "").strip() file_name = media.get("file_name", "") return f"{label} · {file_name}" if label else file_name - def reset_board(self): + # ── 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" @@ -175,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) @@ -205,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): @@ -239,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/tests/test_caa_board_model.py b/tests/test_caa_board_model.py index 5b907ed..86c6fcf 100644 --- a/tests/test_caa_board_model.py +++ b/tests/test_caa_board_model.py @@ -63,6 +63,58 @@ def test_create_test_board_genera_grilla_2x2(db, patient_model): assert len(items) == 4 +def test_save_board_upsert_no_borra_otros(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = CAABoardModel(db) + b1 = {"id": "b1", "name": "Casa", "grid_size": {"rows": 2, "cols": 2}, "items": []} + b2 = {"id": "b2", "name": "Comida", "grid_size": {"rows": 3, "cols": 3}, "items": []} + model.save_board(p, b1) + model.save_board(p, b2) # NO debe sobrescribir b1 + + tableros = model.list_boards(p) + assert len(tableros) == 2 + ids = {t["id"] for t in tableros} + assert ids == {"b1", "b2"} + + +def test_save_board_actualiza_existente_por_id(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = CAABoardModel(db) + model.save_board(p, {"id": "b1", "name": "Viejo", "items": []}) + model.save_board(p, {"id": "b1", "name": "Nuevo", "items": []}) + tableros = model.list_boards(p) + assert len(tableros) == 1 + assert tableros[0]["name"] == "Nuevo" + + +def test_save_board_sin_id_genera_uno(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = CAABoardModel(db) + model.save_board(p, {"name": "Sin id", "items": []}) + tableros = model.list_boards(p) + assert len(tableros) == 1 + assert tableros[0]["id"] # id generado no vacío + + +def test_load_board_por_id(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = CAABoardModel(db) + model.save_board(p, {"id": "b1", "name": "Uno", "grid_size": {"rows": 2, "cols": 2}, "items": []}) + model.save_board(p, {"id": "b2", "name": "Dos", "grid_size": {"rows": 2, "cols": 2}, "items": []}) + board, _ = model.load_board(p, "b2") + assert board["name"] == "Dos" + + +def test_delete_board(db, patient_model): + p = patient_model.create_patient("Ana", "García") + model = CAABoardModel(db) + model.save_board(p, {"id": "b1", "name": "Uno", "items": []}) + model.save_board(p, {"id": "b2", "name": "Dos", "items": []}) + assert model.delete_board(p, "b1") is True + assert {t["id"] for t in model.list_boards(p)} == {"b2"} + assert model.delete_board(p, "inexistente") is False + + def test_load_first_board_json_corrupto_no_lanza(db, patient_model): p = patient_model.create_patient("Ana", "García") folder = os.path.join(db.media_dir, p["media_folder"]) From 9255848ef1ce08105f0bbc2cc4b0ac5d31a92030 Mon Sep 17 00:00:00 2001 From: Carloswar Date: Fri, 10 Jul 2026 19:20:29 -0500 Subject: [PATCH 10/10] feat: icono propio, metadatos de distribucion y build verificado - tools/generate_icon.py genera assets/icon.ico y icon.png (burbuja de dialogo con 'M', evoca lenguaje/comunicacion) - La app fija su icono de ventana (main.py) - build_exe.py empaqueta assets/ y embebe el icono en el .exe (--icon) - Instalador Inno Setup: version 1.1.0, publisher, SetupIconFile - README: fases 9-11 + calidad, seccion de pruebas y de regeneracion de icono Verificado en vivo: el ejecutable compilado arranca, crea data/ y media/ junto al binario y su BD ya trae la columna 'label' (codigo actualizado). --- README.md | 32 +++++++++++++++++++ assets/icon.ico | Bin 0 -> 24850 bytes assets/icon.png | Bin 0 -> 6878 bytes build_exe.py | 14 +++++++-- margoth_installer.iss | 6 +++- src/main.py | 6 ++++ tools/generate_icon.py | 70 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 assets/icon.ico create mode 100644 assets/icon.png create mode 100644 tools/generate_icon.py diff --git a/README.md b/README.md index 300c2b7..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 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f6fb11d5ec021eeea32e469d26ea08db78e123b7 GIT binary patch literal 24850 zcmagFW0dE>7PtFv+qP}nwx?~|wrxz?wr$%zZM%Eg_MLOi{dn*DRreV?N{5LiqIC83caad~Vc6S)R5PfPRs+N))Z zwODz6J9?{Ik;TRqDJ{01|txe=o{4mGCxf3R}Y8t%rtN#=gib}w)1tC^PDN6 zkwJ{I? z){~g1qG$l3N~Z?*jjnW`K_pKPRn0nNVWL;sl=U0~6B;E5#Gg=DC|DinVZ7gz$R{Zb zw1O4vP&1oEo_ur1uJn+vkzw0sR#swbj*8I0Q|=|AOt(z8zPQ0lD72Xjlvm?c7PyJU zDMKas-Ow7&jnE)vvx8{BnsXw==uN=u+2NwBVbKuW^NxK4`sf%M!;X?dTwCcwn8su! z^C_~L_f__XlBIQf+mEE;Cw@d3y-@>qb%-%VWSXH#&@l?r4##H6z%?^dLWHcgbt6*^ zN~9!8tiM(?WTtcKTtge29!9mqL&O-ulRushuZ}>G_aWoh+6<^p*Sl)I647e(tPOcl zg}p^cp8xzIBF3vmhlvOiex5b>Mwu5y=j8ptQK8XvX>DC^s@Ws(|8+~nV%|WGf&&a1 zG{uDu?TPC*A3vsB=ICM$Vu#Q-cA3WlDHRz2X>VrEcv+(r*r+YuFa9f~Xb=aZOEq97jO=$+^1d{Ah&{9Ul0qh#uwl0MH>fcTR(lc?y@YK9=Jb zbjnyTW}aAqi{X>gI~5B_WG+5l-@5J)$|igwLAxTjD%Wfb=fE0~j(#1j|6}cw3G53) zNErQsKrqyQM~Re}ylAbkLD2t)PZR$0X2Spaw6mAjBLDzq@IRk!QMa~1RYU#0&RNe` z&#}`7m#K>At`Nlbgu0g`WMU$xl!T1v`&`L~6pzJbLx&`n3{rwjmt_5(54jyG&j%N? zu_Y6%X=Y_bR<>y9uGnzXXW?O4&Cb|1bYHilN=e?@&ggi_z3JNJdI2-ZM1*y9#eJC^ zcaQT=1Dj3Hm-t#jC}Y~6K5gaOa2%dtceg_BI}zkY*M8B;QZRWiJ0%Z9^;j6Yi8re3 zL=mnw%beW0=nBQSPA7y-_Umhj8GnBro#C@YrlpfoQftp%mj{6XBR~Q{0vff6sdg+4 zZ;!yy{k5ovx_f&fX0tKOS?bF8J2GQy$OU5y)R@(dtI1(BOwH*71qdlJleV0n*6~i0 zdutl+>)LiDMh36m7MqRg0A>ygq(xQ(Jv${kG=>}5UoL6uNBbSpkVP#ufsx>H_x2Bz z5jZ5RStc}6Y^2U@d#XzwMM@e8rRNO9Of+hm;pW-j4~R69dvDj*4ZM9QHC!>s_WfK~1rc4-?weCH2&=Ri*sP*fU9ug;p?eb$Y>5y)a8{ zhpcm;4h-*oq?3x$Ee(!~tzu}XT**rQsK0+K_!PiUcz=f(YFaWGaKX##UI;n6W6;06 zycuHRQ4c<>upn~4N%Hz{c@(_yw6>!PL$)ctF#K?abr9GLOkZi=>UviRl$OpmrMLK2 zX}`QR?5b?{_!$q81q-8+1NzDLVmOyn7%_z{CC9##>AqjSYUt+G(4tB`I}+{Y(k~C= zpz3vm;{wR1G+6zJ;q%S~?OCIWZ4bkjt~9tkD}QTo1&GiU`$W9kn8Vr$*Lbd(7q6%F;>Q)lcq z&*_%j_n?p4?hNcLfL96RnA zZ*YAjd}Ro4Ix8q5G^Iz&zBz8s@*(}y6ou^F3qZQH^-|rj_eLL-)IJ1MDD-|$(Ve3A zy$X-|Dj#f+&=`{D7wTW0m7pEbEi%D=QM=1KaT{wpVP4A;w-MNcghJL;@xMecZv6fC0hWS1(H$PnML4P->}bi^bvP4{^iwSE;qlZ;BlY z-WdKRK}|{bhd^X{fvZiHb&*N`$I|6STL@?BO2v<w

g}815kUk}gn+$dLAZlzO7fcEeHx4lcSLETuwN{?srCI!xNE z0Y~h!RFUPoGnt!gew}3vk3!5i5{^S+WRH#EBL$-Jj1&oyiV1_ZsU#t5F(- z(P-z;A^9t#g<`g%&~Jr@?fA+ehI*!PkHYVQQ(L>sY*HWP*F<74{=l<&!Uh^R$Ma_E zh-ptWF&FroY)n`C zM|zsMmp?z*T|U=lWXP8P0x#hI5SagiFO2^U%<8~z;s2QB^M8SPM$6hpbqwv>?0Wm9 z)|T+(Dqw(DPDc+;js}@f4V0CX9Ed}@id;=uO-{NH2PL%>RLB|=DKG4BcT|IW&%f=aMwd{A4!U`6Qdg z+^n`=P!Z9he6=33f5a-ZlsvRR(6vc$Np_$Q2ipeH5$=$k-Kt$_eocVRi@+54=?U1? zm0hp)C46RFnQ{w$EQ2})y5CV}0v5ek6FE`GVtkP_$7B^Ty0pAA@yP=p#Ne8xk{bH z>F&s0Z2qU5S*~BUi+dayQ#fKe9bXs4Pe65W4Rc z&%WSlJ}nPN0f{kn*}$6vXU2a^hBtx?E<3kZL@2!a)49qE_=!WzjQbM^=84TAH?aiG-V5B z|4n^#%qz#tXPBZD_J_x++|yexDzN2Ppry73lO=ST+Al+&cG6XMAs6%e2XSEg7W}b4 zhZ3*rW#=GFM24HB0~4R9u^ZfGvo6{&`{tI9W4tK#Lv`n`ON(07)tPZd`wN=R^@op8 zypN;1jukvyj3{;;0w2poK34f1&W7vo-|0j+C6; z8BUoiJT%k|o?>_|L>aGv=FJi_4~_WDjUJUaEow^*-A>R5lB2)b1mjB^mb)`}wRd=w zuvW}B6>Ci=`IMPOV8r8xhE|$9Y2{8%K|i8G-woDs@;53iv&~D2ICaoCJx*9>N4fV` z*m6r4KfERp#KzjCo#VVnKzk^gnnU*lsR>!EB`(HveXA6uelj-(mv)-D?u!xIG zGZJGSw2nksn=)||2^(sr^A_TwnGP+ippE@OKsdf|$F~*Q41GxF`8G?DAP%s_=(dBU z2N8`kHFS}JLYK>!^bTLmgm20s{k#f($vEpWU+FKq)YR8QMP)M}8S3(X=C$w>*x}iZ zD#Dm8qDdXprkkO}aYRX*Ne2sl#uS5{FtUR`7=H1KJfFYN=dW! zVRh1v1AVxyLeWt3#%M=P5ZYBb=K@jOV~0cpW4V?0cU$o@><-GVK{wDw@7GJLB5_O-@C9mGZf9ANDX%m0dF=u{iY?hfLi2Kn)7_|xd#C9PPO6vLHVmJP zBGo>LvHOfR2N~$CKeReQODk;YOlCTz_88R3or~lU(SEvfQ+oHt0YO}2qt^=B4c(2@ ztB!8s_~{>F@;yTM|8fuOx-*HrhS2Emx@vcwmbxp}>E_6UF%TLA@Oxz<=*b{{g70}B zNZUxx@V@4b32>d_P4tl$pV#A{^kS@h(W^$}lh@r=F7_(TV5Q77hbJpIscXS`8 zj+~XU@_p8UU(iT(@ns(S`u_1P5B5VzQIVKfun#UTjGIO9k8z2mB&EGG%2eFAXQMj` z3i}v8o)8f9*xW4-WAO(P zA8w6G(nKF@;~pEsVk84BT9kKSED5H>`Iqx}k%ca!VC-jSXeaW3;GULG)M!2*;~f}D24>@P!Ps##{m#O& zFXZOO8pkc0Hw@C2itVat7=ih5tB!Z>$5YMChCgP*r5S5?`X1yGcRH^1#u2C zFCG{hh66iAGFnlQ`uLN%<0RbHhYmgVx%0tRr>4Lz8PC)33tj0~XA;BP`1l8mNmz`7 z7JC|5qH&+^=GFxhErXYMBMh8wN(jZ^VfmOj zu;|pO7XgD|uN}4Kh_PBAy8m2kfK50w&%YH{@5fho-Cb~@B@P;v8zsmcib^s&0M)z3 z&Rga_vTXLqV(Mck9_PjwVL^SRpZjVfbZVT<|Jg|n(I;_x`Ka^JqFKM~TL6}WfTKbv z=j;ChO@ja01^)|8m6nb>002z)|Db7G+uK2P1?wlr`DXitJP_1#g%$e<%`>tPtwqIZ zQ(!|07p_%}=c_1A1#H+Vbu30oYlPeM0%fo8ge{rOcbd=N4vSXl?U#@D9+7M!EpV^CgdgN^bON0KC@UW~GQGx2P3EeY+ikxj^@DYK^E3g*Eko*R4OaFXjyx%Fx&Q@|= zX-6n9x9Dzs2_!-?2T5qB$%xmOgX!eK3YAwX0u7~oj2kX#s(I8~cdX#*Y66lj-HCcp z)uMR01u|$XE6kj+^u8J*s!a^9E3JwiFD&K=<YK&~+fw4;TxI&mC@=mh zYjOq?+LY%CSl-lRVltW3B1eo2G)h57$poat;V+PNz@|t(-9ls*P1)Wzm%8yzI}{`~ zJ}d-sYiyV?orl{Te0Q@y0&o~al5rzYec+iRrJyKN@h|W_QY&jQz01$Pd|W-8fCtNRhI2upQ62Xw0)@V_P%Igr6sDI0%vdX;>pOiId~p6-@xQMP90O1 z$;Hgs;t91pWixxb0)Ag;sGdC_OF{UA57?xjg=WC3vXo+BOL&x zw)>-0OH2*zq-dpGhZYx)>a2@*on=yziy^ULu6*d___>0gL!LNF@+r&Lly=M z@}|NVG4n{6VhAV5ZNs5R(Z~y5T6`j}B;ZXqq$-zpkhy~0GX=ubIoj)n#OJTgK*K?t zG;U_?riA(#8D0N!l8-jirnzW2RbK$C#bvyCe8K`PjmJuiBKLQrC-(gSR2*0{87RGj zc_8=*>{q+t9?a`AG&UxslzQ|_WAcLL%v?5i!x7Aa(vgz9aX8crL$3$O^ew2#8Dp2N#DNI;W(c?-h^y5ZihsN$r~I( zuQnTqBg@2YF$j_J&ov(d0NkR-CfGj573@fEFBJv<_VT>G=q@sQ;u|L=@*+iqlO zTG6NHH(7GJ;W^$xP19*%VRH-!@jdPlb`zAGeHV!oO4&gUFMHma)SNFQCz)$!69_?z zPheCbGC3oUO$n5%?y6<6+I!_abg!3H7Ly^dzVCpcLPh+M0?Q0*x2Q1Mvp1*g7Q;ESI1&$ap zJm+ga5|Z}fjGObO3cU;@l5>o4b?f`FB65p5ksmF~yR!SBb(;k@b?FwQ^Vjh{!Q6kl(2BmKM|u+ZlE zqLs$dU%n_tvPJ;s`z$}-X0Ug+yJO^TyaCMxrZc4nWAfc3QfUhs(&2@E)f)2X@z-N4 ziN_DPqA-kJyuoxHe=UAVDMh}Xw5n*dS@R&rZ5vDXY__NT`fB@P#g1#+6P189tc%Cu zTvMU+RV?a~ttCw*+HpG~ERCTyJ+Ra*%@8c>IQo_}{8?^>K50q7;06Qj0~_d zND=5Cb)<~jbAd&&5GF;+6X=%->>n|yU^fCizV?59M(^R~b^FnES$D1HUk@pM;BH`DSt37WH{ib#MnKZ7rNl0VARn#A+aC%jvDtsHlIQKE@gjCKU3HH%DNWWEk}J`hQX6FDO3bsr z(litjk{^=TSHKjuW>H{aLe#nMuR%;QZv?!08U1=zx z*_rk;6n49mhht;&a+!$Qv>B^1|0m{Ui^X*W-pq z?$u*BNr_(LYm>{|9S=igA5!oj6jrEi2|cr_au9kEW*NuIvH1-sRr5BRxL@@q;V;9W z5b5*1zZ9O_+%LCIQAPuv+BwkFyn>tjVC_3Kuu% zucIEN5w3l%Naz4-ul@Jhv}U`vy6#WdHQ(vG6&)HM8oT6y3mhVGU3dagbqyUY&6yR| zS8DR+H$hRm1{N&^4DtU3`UC#82mBZMhxiSr000bu|3UwiE{uncTF!rPxW^TG$j~a zT@YQtIg78NG(odevMe*pi|6x4o;QWuh7|XVF*%E~|JRANnbW^6K7Mk3KC(exZ}*47 zlL7ndUr zd>rhwB;5sp#&7pAgZNM52-Nf7gz5KeLJDU@fp&i~K zO$>$zU$3+3KG#K5WP2|8Wb2(nZ5e<>$T+j0Hr0W#3Y-|H_(5k8pw%o*VC^BbyEi<` zogpqRYxW5T=Me+!CQ*DI1ML+QNeUF!TAk^fvqNbyBLtNIo8(D?>A(1S;|Bu(XPQi* zAX_Z};u7sWABc1dL<0qvo!1rH?Kz)yCkPvd(m=RKk+F2bfd_*f# z15~PivgL+H!DWEhP!7|)fPXgvdM(IVlCa@EV~8a-qEYIG-(z0<*V5M&pJ^fvum?tr z&rfcoH0LcO4A}1|RH&T>KtVu9Lrgpo1kymFXoNUGcXuCi^pvOXGxiCDY~UQ`qC*P} zV^!N!;Pd%S7KXJxfL)1Hxp&W50W=E?xDr5nvZnvH{mr@x`~agq5Xyf9j<)y%b-IgLw!L1`Bo&GBc958V9 zI&FT1oBDlY?PlMdEPLF3g#_dAJr?NdTM;cPF2WFT*J1o;B6v~~NqfER0LVts0>Rg% z2d(yrhifSRLLVU?xS0BmJ597WL+VcN&26j>qXTB?knjN1Rx5r=oWV?*UkfjfS{lQ1 zG=xNS?Xdjl8)Z8BI*!07!FfI+6Rxt&QfK?+3D4@2IrRG@F5XBYaH%U>d`JZ7_Zb5#B&Q;p1B||dfW^3n1_5Y5u;~;kq$3|~pND=rVw7YF zC}$sW_x-n&#*Trx)tJhM;*b36*h}^Vt{#bO^7lBx2Q-{Xt*v2`WVx)=`3+dT_`55G z38VsgwKpQFgf9UMc%cjO?lT50r6I&MC&rZfVP@kzU`J?r0C{~P`IKCK(%#{h$9Gx& zHO2j~C_h1-K_e4{O@l0CqNM;CP$vR1j42LA4(S-5&f~?OP&TgoKsRo;wH0g%Zm#*X7aC9$}qB?~Gk}9iV^-I4qwQs1S#-LDm zZc5QVDG9Zbdd%SNm4%TSGo_8ysUtXc*|JT}nsJ#9|*U1RmqL?#CdB`-(cpA7~;g6daq47jQ zs0i@$#Iw=bwThGMo(6MZ$&%TD{%jwn47u#ZHy>a1=f>g^ggGpd+|UO_E|vbS&n{TG z_c<8vVlx!&H!`Pyw`QaadPxKluWF)pd-?>ksI#37m0Vo$N@NeUD3J2RKU@q$P zQy5h4bP0#|$Or?dKx?zvf#!Q?pT84h^R;Eg$>|DLX{%+j2xTIxcS}>X34&XVm5@R;M*W^+kc2`&1Ni2y=b zNZ~+sxo?6g5kPf;Q`SO+Is(g3Z8gwzLN z8L4JrrvE7l@2>UaX(jN0up&%Q0!s$a4fTINvv1w16F7vXn;H(?LEgSz;@m!#t|C#= zOlb@!@?o^bE+jIit8zerL(ESyApT;T##H;4){sP$Tgk)RdQO0LPAm-gPH8ZPNRkFc zq-6j=$#4g>=_KwBa&=!7S8%zss1SC4dW6tRU^fRhYO;{12tp+ZNJ)UxTj^kUgKHE| zXmB*@-+p-+kj{VWxxhl*PmIQ{=dC(QywiBU7)%ueGpor6^RKi(qk+i;*a$Ybav~11 zY_{enC(&hR$~F#Vj><90Hc>7uipd4!&SHPz{!;tP?FBk_v$5Z5@x-DP3ai3}92U51 zyB*^YQ0ZED%1uYfjtU1PxecL;7EP|;Iz(lEBrP%HSbq{5Tjm15i^siuVKp909BPaX z7bG|E(Zn)~!GaiS6nBtNhC(64!=KG@DRntm%nT5BAVnN*;%P0Q%eb8u{2_Up;9#G0 zt(Ba0(Yui0wSLryiA|fJEcF=H7X{647ck9CS5}IglpL0+Y&Rn;u2@^$2oktnBNwyh zDO7kOP$;>bey&MkZR7|Xl-eBSLYZ}lr(vd1>8_?38umYc9W)_Jyg1Cc7tpGcmB)EI;;*q=}^{QfMGCkpR~6fdds7 z43UBqD+GkxgE#k$xczfmZ58PgbMPhdUr&ew%l0_dwq%(nqY2E#LIkcX+~ctCUBDy%cAGN8jdUl> zr`&O&A2+Vmt4AD(De|? zfXWD!0VrLYI9Eq05+PTYY(_8>^<3#|W7A$Lyq*mIcdw9_)bD`OgNwK5Bc>76cRDKI zai}}Ju$yEUjF-v39ex2(rNI!l%O~O)M5KzUf?#>hv+1PK0_Ss4Sf2U>2Ao0{*~9MZ zf2}eWtj{l!P!vrm><9_Hzst4DH$Tdf8VwJOPp4C7$WZxEwXgd^SOvW*cp-|4lziGR za5L6rBtTdjQNrTVM9Ve>7q>+)`_&$FZ~EHB^npPWZdrdQFmf-+yhIs-%AMF6S&3k$ zvskgB%i5dL=;@hz!ZPZJ~m!sKcob=qaPYcZ$@zDhJ6h=p^Nq6f=YSF;k3=3r9`GPzh** z$cjH9-M&);W^0nB$+7%*sZ@3fZH_%J)#r`YoTi%0*XrGU3#jxc{OZ#=yH&#J=e)i> z;4n(khJ<`3t_k~SE#_p%P?$h6aTfPSSE9h3mS()U(*UTiL$-&z_c+`79;m!&nQz7G z9~9?P_ubt{!qW!63{i-dG?m5iOU%-Vg2hB!lP<=bB2q}f=QiC{N6Ljz4Z`%NnC;wr z2nEv(%c3zC1qAo9V2a|R-~6Q%i^5-z2QPc6JzWJ8n(c!$Uzv?p-p?VCJ#kl1u~qFh z@@(A7RPMuF)_>d-5PqNX*tV~hu|5rmcJtuUX#vku7AA^sw`gm}8~hrvXq)4g0WU@% z0s1_u`)S=ZR&aS-6wYBWB>uDGqy6V)y1S1C2K?@IcQ@hbZk_OJC7!gaJ}XnH*i5LQ z4oP%(YhVfjVf+O|EytH`#i{b4W2%=EMC)V(>wwH?(YvAB@sUw0SxU2b^67IZ8ztZm z=%Ol|^$RQWv+bz^a>Rjzp|)%@bHs6u#y_pY%YCGKF-WAU zh$-xYqdS5mk{m(jfbaLfa7~UxmgGf-cs_CHe|0Ow+iV%^!YE@G ziRm67mbx2=rWJIJ~5h|2%c#dgo5A}^X^M9z4{tJ`n_^?AH4vQ0|) z%h`Z?EcEA&O~K%!p(w&?jH2!c#8b{Ad826UcWY#a&&=WLP%mE+s3;6Gl;@g><@o+H zR=XjEZ=3GbSn=&@UlT%6P(imnX5vd!_}|49)(+1>iB;aX&fmEb;RP}{=XRbXq*!M{ zaL1B~RWVR6*+X|uH*c>e7{yn94J}9*wciTeF1NO-VcV%f53xuK$l?f5UiX$_x$jT@ z!^vH(hn*K-8o$(HS*DCcHh%xM`*dLmg&hLF$&H*=xY~)-x3eAI;Nv;&3~Q$PBZ>GO zK?ESW0#g^Cs;_yX?7iR8+WOh>>NX!F&F1kVMpsCWh!RA+$z#O|1hS(|)B{QhQJ8AH zj(7KXdnsUJBh|UT<>>L&;BKE8(=s`6|EHymGhSgyUw zr~6BvsT5jP_x9Ja=411ro783_`Sx3V)p*33!Aqq#dv@(9h6Iiz z4iMtvmor!tWuTbm81Lq~U-}l^<-R$d1~{kFk$Ro69lH%R|G}8M+K$!Wq|K?{m+mco z0m)A{BaM9xA{4auh8uOT3`4bTGQ*~ndI_BCprj2J?9y4D~uztZwfVlulc)b0OF?h^)XY)A4 z;;z2UE{gH^n6X+IYK9OvdZ~AlU#{Zcg-HQrYTR_RdAe6#x61uk=$?;SU1#aP5w6Fc z=6FL(Ny580te!*l^Fi~sJT(a;j@4G~eM@`56kulby876N zqm}+v&qn=t!PrrPAHeVnMCsQ(*-q20ocQwh8vZVWzkwzJ#eu?11m*1QII_@TcNKU4 zdlT+XCLAE!(6>2#E}wrp)J04oc(bP8U)T#I&`kpeTx|T~X*_n=9hq#Z6`v{od0VRr zxi|8yp2Me|=1Yb8Ia}rbJk*tqLa5*yMe3k6b`uTr+rZr$hyUlX?(S3D`}5YWeox0l z4p0DwsT>xD8|>ZiqtC(565~RJ4g}%vinc__S1{_Krv|a^><}J=gUwst;=m?1I+~?BmH=tt)u#Lmx zkh>zQPf!*@NH@#5xrv=A8k}i17xnEM;3~J1OEnqx$mPUmy@K(f7|5llHVa#X#Ia*h z*qCI~x!nh@p?*qNJExCI#n+=07as%)r^l85XQJ(}dte)c0;Vk&2}r|7*r%@ksx7~A zdHZZt4F)OOvEULt#G_$#@7hyxRG_*>t-gE5px;=w=u9YSP=ZJZG7Bsi_4|v;Pt{)B zmhJ`Io#C-$=Qwa$2_KESrU|~})oq3j$f3h*Qb9@oX0LF%@p`jMyGLu_{@883Z~Ma; zKiW^;h1|x0y`GAT-w6f?0w@(~Xlp?;O)J9s(nWodLc+zD$73q3gQwhLvPYPMmhxUv z9|jd17aekbIpxDa*B2TNuDi~m*B+Sh#Nf#azwfyAig8Ab7P*vdI?DSe#i-${Ab^@1 z1?N!y5z6x8H{In@_@zL9*|3l<9u5l`G8?RoQ53f>CU^(8>CXRKPbFmCBId)RX&&VP;;*R(3V@8&zi_bhOwIwaa0Z^6Syq6N%4Yg#>dI zg-ZUg{m03a@T$GxK4Q)?2N(%l9HVBntn)#RK>Y6W6U0AxC<(>CQecXIOopPmuFZ(% z<^zg`p-aidWpNh9D)MhN^L?m3`hqBR3sEg8{UA`W$--$!w?Daw{nX`!EwRw2jQcJr zH_FfKq;U!n@zViCGI_jX&v1uk_j5=3#NqYl7k^k?|+hfBrccDqTRVHSb1_Iwx zx6(=h#5lP0v|~#Uf(~FYlNiTe=%(`2PfZ40&fZhH?jzeV`o4dHod_?o3R=?^#3t2i zHK9jQ zAH8Ll_~k5#3oftU?d;0WJ|ILul%Hx1Kr#n`f%+NskV+l3Kcn^i!3xtr*4IB#m^gtv z&yC(2KF4-@R2dy94kt?#(IQNu(1*#`#5fdI3p`wIS84qq?OCAJi#p|SqiFw9;ttMp z3?T_b*l#>h;`^=rr3wx-xE;Jr60=+p5d{qGLhb8JGz)fQ&l>X}riq0(tVTRhC18V2VZv@@JQ&wZ`WnmfnBWFHHp9*YslzeEoFLz-Z zC1AccNQ(9|XP2i@t5;*D##9^&rOq@<;=G>0O$A}o1=mm@P$ljSJYV)Mx+RsC6wV?- z@yG6~j27qqM|d{DNn+7ZQwuUsjlsD-{mo;Y{friY%Kr}jbY-3~^6*c#Mj+s4bodky zEI$NZ(Bb&EuSA`>ht2+SGmL0j2F zlUo4=YA1ym8={rk;u_3wL^o-OP)N7uX1q_?9G9MfeuZD}mpnhhi10Hh8V!S@v5Gjr z^==(;`z)aJ7PD>~uO`-;K12WhZ;Ti}giQIyO&1q{0UJhqZcdsIEm=CPPHvl?sU$9r?JOga~cXmBjShZe2}zB!|~_%|4@o{;FMWs9djhs}of1*=NwVUcdIe zRp&qmFVKghn#G|1qBUtUM1lsYKsDh`W^q}XR2C-ufyEA)=l`Y|dZa;0|5~F#q(=rm zzWeaG_|}Xt-0@<_E<`6sjmwFn_1u)XHh}F7P>LQKx_oJc&qB7B<&P{0T4}Zim z=X`_fk4mWblN7@g3Ig=br%cid6RFAB3aq(^A}HWt>TCo5}{h6p&A z2Jnod)3FE{2f0PDRZAvWLYJNvGJ4H_$XGnxf8{0>;g%TX&vaiyP(xsYgxUSP_K5>i z_bIR9(EbB7bE6$3x)@?=Y4aIFf;1TFvVpp26EyZBc&2Z!oup4#Q6{z=50NnE7Ut(U zg|>M5l@nl7$T8c%KNR2ug3b!mzMVO3f**$;^VL%6hcq+O;lEl9VSw|4C!hhN_GGjXjI@F^1?ge`HAv}lGAPAwm z=?eAA00u9WL!SedI$`R;34RzAxK+ebGc7Dhtj0m*xcig}F>Mb-y=Ag|j@*D@?niU} zkMz7a{`Ftu^n-lc(KOS3RuIT{0@&~OKh9V9*s3*v1YunVA@;|IJbEPlA7rdZWp{#? z2Nqd>v3}MjVU$k94Z6wKxf^ysTO}yrO)g~#^xKUHcTQ+Z3%5&(VI>F=ES5t0;Hj>l zUJG_~o1N~&-&R%y(WvExg4ox-HZaMG5X7U-TbF+i3YJ+q<-tqi(@R#sW3L&7Mnl`O zmj+OpG|St=YpY(y4zEtalkhM`7)n8h>4dk`oJc?-*l`0)D7!$zZ;4TmY4!5ru*TCO z(yzXln3IZpO5YWaIGlt-kL{?UmVC?vZB zoEeFx#`2h@bB`oM7MHy7!aV2&a3L}X(o9_3JkS7VYTIyO=ka8{Z2p% zb41tNe}SUOeU*AFjuaTV-4A*`NLdBZXcA9-D=zid3kfp$#A5dgs{klpFsJ?rOs71*#xoH4okSnGtg)--~g1cjvl?*bMW$RpwU8+h~fvc)X9QSL1!{L#a!>`x_=Oiu#9?r zqVGKwBH_WQ5+wI|h%r@MTE=7R1@(6b; zaQp_Fj$$-!k`(iH6Snivl+P`Z-!E#g;Qd0jcRIW~cEoQAq>GaOV@ivH6&+1-9I!Zp zzpa?pLH19v^jBb3_J!ZEA=OjWtMvdvEKJ_^7sOabqDx?IswYj5Ej^Q@cSa+*b9wQdCE}E-l1HHxl^4?@cWr#(ET;x( z2>y<-eC&!WvM%7q_WzraYmIp!i1+S?8*Z90c=&8PCz~(bKk7r)Pa6w3Vh3o(R@pz9 zEw;}`q5-rl7fT~&MRCLI3?_=HG#JfrdpUjKf$2ej)HUPmjN=YJ%%^YO9~M-Rwc%U$ z6>eG%5W2ML+rAh>3<-~(%YWpJ_8>zLD^bs$JVd#j3y=eJ$nr&j4f^Dni3&c95RD*l zgXmckoTX^KUmZaBedYu>x>f5Sj6C0#g^xAhGij)We{EB*MK zKEA!a*wz_8e81dUT+)**9%XP*v(}S(Rx}v-l?Z_F;x*mlWQNVECYdoBy)9$C3SiKQ zC$FxidN9+;gn~ZLF&~Tf&pTPFZ3@UU={Yo1&XO`?VFuYhp$w38#|Lk@PcgFO-Ihq)j7e0G2GO^%poH}FyF0%ih(Maip`g1rja zge3F62a-cG=pwWODbkpE>Fls63i;jt)5cRqMfrTayL2Osv~;%;3ri>n(p>@zC?V3l z#0m(~ARyhkbcuAc(kLxRhop2Zz5o6B;r;ZU^L&^y^W3>HbMM@_b7zJQ!wI$J1srli zfEF!PN)T?mfOu+vGX8fxHGxbKdbIoRVuQZ)mrC}5hbeSm*wi53?-yCk+2HA)wIBt4 zApPA?9qlG@o)60Kx-ebw9T+flYVjCKA@mUPK$Vi9|1qJXRy(k;l*&jWE6!6T!H^$R zNgRvp0rDOfH4}|(pR@Ak&=X2&0k&TtUIy7Bp||seocWcUT0X!WLO~EBB^?qV;F9gj zyjGgRej~?-iXU7|DE@f4H)VRYXfxr@0xA-5AbXc`IiHdDG;Fk*2Df;%2?lv_&;eap zbEljh8a4N2TEYUc?j2hn42(ZVOvV%8NESB-rCeEwIr0fT^o{laHqR%xJ3b5sAA%)y z2OP8BOZ0OFYf#e0$vx}6C!J&ec?SPvbv()_ip#D9rN)&S#B+-UjHP9cymr@nei!X@UtoE2 z*f+3@XO$_62MIa%|HhrXyX2+>-=+hyzD#qy-%ebzaD36I%TiG zyKb8r4gPU^N`4U_Y7+TimVLC5#TgmW-hty1lz;d>(?h`TrBLAA!s&G9&J`|9@?u+t z!qmPVS5^h!@F{R$3mGc9KE2Fucz=`jBkO3Pp%R`(YRa6CKMs3dELz~mBOf%k6ZUJo zB=#kKh5yCq<;FE0TtSB^dLjhdI^*d2_HwB0N*#d9ps&t}*ovc%Jg;nB@qsbwJ`iD? zyt6lT^4RN1=H~}!z60Enkl_1)v8t4G&;c-Tgla=a)M&QPb$%On2AS>Jj>P@9H+V|Q zk^Esy12DFPk#8E|?H$n-9ghyD6Y&&K{aCW*`TlJJfb;M>&VDg^2|o`!RRfTE<|_?x z2+YkIWZk1z@O={IvxaoQ=EhNe<(uQ^M@Z1M+30JFDhNAjS$J;0qhm(=9Pss1>3yk0 ztwx`&eT#JQQc`iQ47wz(+m>!Na3kvNp4_djX%9fmC!mkp z_n@mI-lxesXAvy}$H^ah@XQN4m?~~X*GcSamiN%=ePdhF{P0+0T6eeN+b)m}mh3d4 z*ej25$x41=o>ze`jrR@S%)KFpOJCYZPGka_sk|2jK+|MI3aLu`0&x``4nqkHT{g>52cN*URQO+}2z=qhNLL|@;AUA{t(j60f$Dg+G z(@TwsbZK!-oH_<&=PHB#xWh!=OwoDI#V0?Wp5hY#OvhHOx(OhN`Syl6K{sN&6a&xo}5aqcJM4DyGWy!*YVOtybTvA8|`xJ7}7eNP${;u(4j z+YxL-M5T>6q30OUrGA{B(e@-x9wLF*E?}n`3V$#0I3-Iq$s@zMj;V@T&_aHC0zDhQ zQY9GD;EM6&)-;-VQ7z0U@v_lq)wLv=O}TJT_PcbkhMtPi^QYB_dAbNftbsTABKfl# z3N9YCUB4QzX1^Z3yj-ZVgrNtAkNg)*B)nU$VSI1OvSKFT(zRbiyQ`n&bazo52Nb1HT1%iro324 zfo04zaJ~p5YqRdbe4Z*xDgWpbe8SSh^5&pYgDU@Gma)UhvJU9H(e%}fIgS#60$0p% z@u;3ZXb4A#MZqeFNMi$|pL%<`Yo)ih3fDJZe;4!G@_+uOiu>0ab~JlI!@=XL^OVk- zo5Tth!eY7HsjYdxqY2=-VR3`GZH)YWIirPCPT!5h$e^)uuB~K#%Ujf2^|ikD$)B;z4II0F`HPKWsl@bkG?w&NLS;oN>o_-d5G!k+`J)8Ar3MxFO~l>DySJPF&d(_bjLlr z`38>$?i4W;Jd}E$e#))gU5eD!Gxe?P63IV5VsoBrFE0^+JE28SB(B}ilo=9Qvi<^E z4B|$bB(I%_Xy9cL6v6#f4O8*3t5{Q~ayK?*$hQ52Y26!F1uOID!4)+N=J^goe=-cl zvjyXk^vbrF3hBi4?sRpGf!X*oa%jSCAp?saYN4H2d+|e7qU&@pXMX%a0?O=JO?#NV z8OnMq5-$}smIbgA&?iBgfKMrR55b&G7uLWMw!QDmrt91`zqUoo+Gf(mlWHdcqbM2D za>tJL9A-NRB_|25|EzhT=#v_bF!8>){*k1j76h(%Jr0qAAe)C|!0vFT$0oNym<}7I zuSb}SfaF+m@b%LO^^XcJU{0bZF3Z1)Z(P%O9M6m2tUOq)Cm>RYgED~M14*s~dx8`- zrGp#sv=4tWJqjok>GDDIn$8AXA}tp~40A*@iAit`Z?%tht+HwWQ*;f-Kba+Q?c=lZ zKfVh($M%9eP5F5yt4n81M8rwNTRW4bm|UzNIB2h#UcyJQcRwQ}0FcC|7pC9)XzCd~ z=Vj}wp*i`?VfN&(k?eju;chn4T=#$_&*dA$e(!EA~$awt2rBoi7-tkyZnwiYFA?{UzM+{4jZ$ zWCCZG#T!wH#Bd)z7OONjpXV9QVMxdCF&* zJ*p{S@2b608kLtB65xTJhJNAFa&a~YgCq!Ziv2eaNR&5SH&E+VGC{8uH5Khvgp^gy zw;r@LxaNtWAf4?D1cYqY+jqr^9JIMIBuG$qm9ldBTm(*jU+v7_S9P0R>$^c-jd5&V z7$3Xs_eF=HGO?XO?7)l|&{0Cq^-qIks(4i!#Y489OkY z4`x3knGV0P^lko<+s1GLlfQWJ_D=JQ`|-COhokGMzvqS?{{~RTW~XA3{3PIJf$jMZ zND3Y+unBc8IB@2^?Qqbgi)H-tGv=CjE=K*Z_L_r=6PO~Ui~295d7jqe{FRaa;I-D> zxTFBry4z>-O_V+7j!{EKmWQcL*#>S}En-xjdsg+GaK;^h-B&L@?PZ|=wwyqxBYT-I zGUyZ?Dz~T7`mz{(na@u&^&F$a{}WFk4BQ>lZZto?r(C$!x5BjjChf+^qBP^3d*;p) z1>;2@baAc(#g;~P3M_UJ-4cqEA78WXM~D?Ihg-p29Xb@9rkVGpj2I(NeQgXll&;1P z^XL=)lM;(A1>#>a2V>fZdkiy35o>2#N*WCr>RM0&#oEnM`IKbv{5daO zLNhYn&9GzKF&Un4pS|(NP)VN8&w<-AXPz1u)S)o+UrHLd%u^o*s#$XT*_ z)cb73fhjHw6h7x)haV91RB1XmFuZJoR=g#K^p0^2wLbLU3^}~1wlV?i`bDy)9tn+- z5%L-US}@JBmWF_Vw3X(NbBuM!sq?yUbfR!8aB2GwYs1{h{MH9I1VVI0gs(kQS3}di zd0f@mrFHR}b|++3@&>b{ey`9is@)HJJUzMm_s!-h9#VrubOv zhs3e5;WJeGugN>nSt)hE_9ntlYQ@8F@8O)S==TKXXA6II^?hLBE>9;72Agu@cXmm< zL#to$vVS&ON^5xgOoBZBqQf#+4U2-n;&yOS^c5g7c;b*L*6UHm2jEc2O-nG)1mOhU z9fb)}Q|O=~lik7rW3(^&UruUXWqq1Cx)a~riY)@=ytT4&lNZX8*C(0XK7U>8!My+7 zv)Pa(IHV>H_2P81d`4WNa%RTB6RpoHc@>$Tw>KK_TtCva!^1QfoMDs*7=sp_!Onv5 z7}VCsB}sBhse)VgNiM-bWQL`*jBJ2`6_9%7v!p1I zQS!mwgqM*MfQj~;z6%;6(qXXYb7Qiz2n&jtbW(4IcYl6J5zxDrH}<}ZACD|woVkBY zht4rF9Pl$q##`TM;B}qfMHdXSY|iJh5wlHMPZ`TQhsCHPNM%k&L_ztuP^X9STx+wJ z2lKn4Gys)NPo^Xrs>r0nW9yg8n0#b00WDsFt7>wLJQpDn=o`Q!@Gn0Q`32D!WFhj z`Xiz9hg^b^2du^^8N`@nDine##&rE*}f9a9szlpcU* z=#*Y4kSfmlhq-ymiKkGP=`w^9p8Q+jek0D9{3z{!HPsB6gki254{Uk6w_$%YE9+Wr zCK3rcC#yV8*=C?&A&{)nAWYR@PETak=gX*Yr8X&CVAO^Oct81%+Mu3>gmI zfA+_6zU}VHtxk~8codUj^ERmFkN?$QuZ7$lvAB=V>boH`wpMy-DO+45%uJtCWzT@kf)d@6 zS0~#dEqG}!i_;$4o|{+OgnXmaTxHvpt^Ct9?(`lfA8!g@u%P>|hb>YTcBC5E`==4!4!~?4e4xY8;K6jzQ7SCX2 zh^~wJ{&7hE*aKRTMXx*i@k_jGx~Zr1+E>+6!%!({$WX17u}lAO>T3SeOp;jLwnj0z zHb;K*g3|y|lz8Pub0DDg_I(d%jBJl#enCf0sK zNjCaz@gBEPH7CNhMXu>@&+zoFYZ?NVL5wguLsn1C;^q%~lk3c5?;)LJ2z+jFP!*jw z3>_2Q9O<$K@+kYrIKFoC;0f0zW%~6m+=7`#-3Gh-Cuva;>{Id@4mAKDx0Mr#V7^5j>mSk%YPR&L77nw*}sQj+LRP zJDHU1wsVYus7uRJw&#l~SjCkDgc%1RxJ>}+AEM6%U(}P%%Z?B60vg1tw}xTHkYka9 zxBg5d-8FnMNyp+Et~G!Z^0LY&mslq9uMkdzLiWfXq8sh)t?uW2k#0O0+quXOtO z0szj?7J#fiuYlAmiihpZ(w-Y;cQC#~oRkRpck5C_} zF;D)ji}8M^0Yhk0TYn113E``{^Yl;PeS+D5n%r!iFpHbi01Qm`^M+v~))*J(S$&Am zp=j8DoAs&t=C*zVLK-;X%)b%^rZoMpge3r=F8QyUQW_HgZtcbBh8$wf8c6qf4w8mS zcM8X`CU-(xEZLS()4;`k{R5E&Q+;EpN88C%lA>$gpt;3Dn12pVf%bqYP5;0@VWtH1 zKPogTyFogk|2i^zk7Eu0M^8O5kwQ{Ensd{EejRv!&de?X>V`3+ps^D%4_e(MDjk@ zWkx=gznZ~B|07RnR<_J7yU+o_lr}q5n05oEuLGMMh1d$%w|X3Q6d*+@d_I)q6;@Nw z@Ts3ezY*sB2IAE^48zLmyc{2UZ`GkrY~kN`r>l_0XyvJi8Wq2wGY1+{xYMnRCbHlu zFEly@3+jOwb^LM3zSW@hMKor$j{A!~5YCQ61DsO*5> zdbKBqh)xm-%2)%#3q1jPLn1?dyKg0900e?a1I}icc;Zhg`^on(KwR9=R;tpy8kat= z!xYvqO}B_49H)_p{G_zV3y|i#&Tzq|iYz=Dr`7^QlX&=P+9x zfHVlgVNLqb{k%d50a7?CE>$Ib!MJ*v2Q9-^TcfMVQkioBk;fXXA{dQdZtl>qE@kBMKp#%x4CVCy@ zLsKQY3OO2{zaPmU^-G!Z;Y1FQttCA@&N!|M1P0 z697ppW4d}iHUyh#$e7otuzZ^WA@Fk+8~b31Mk)gi$mOeB@`L4Qks^1dcO?9|4l|R3 zvNbk2rp_m$2sA<40uiBLYE!aMxX=d}i0-w{0AXGl1;E#9X>7sLdB0Nvl!yXN;XGHf zglFbXA8y&wAxft6XsoDjP@d=9xJErUFNEQIpf<-`=n&(Luld~#^-Y=zpv50J@%4bJ z0APqWi@70kV%;s`#h}9u}r(YgYb!@L7CxvymHr&dB*LiuDp7Kj`;%niUcZ@PHf|u>ntDgN|6@F_415aE7 zp3_4lH%JmEz8z1zudv*Yw)9;W7fWh;N$lw6u9OH>4^N>1yS#iY7L~|-B`tYU{^bit zbHjutDn-wFk}v3cM?N==C|aMcs`q)Z<1!!;L@ZuI0x_jidAKuW&pvZ!E%&Q(QoZ(? z^2_{yazPiD2^C?uyoLp5-Rn>a8ufO%h#eM?<4MUYK5z3sSx@9bCEvvA(T5!Dyddf2 z5)>4Z81cke$i0DP!X5R#_W0L*r+>|A@`*PTICNc27G7UY%GI+I$PhfJzP}+l6d3yG ztUOOhk@b@NquN~QQ-5Ftlu%K9_d?u`t4><%Y=g+4RrvjHPKn?`Fzo77t$W}c5@5o$ zMi!$phBLf7SEd9WNaZOh+uFW0}jn}-qYKx!% zh~2v`e^aCnfpFhZsb6hfNj}*cg7g2%7!RxMRI<0D4rhH5*&u$>0gOlz)BGJw0ET{< zNF)7pOc`dJnM`g{ub0Rge$eDC3a-L?U$rBa^!aOF7m7nfqmu0lhp;KJ<5`5iVjCtwVtIvLqR*DrLF;a+Vu@g1M3wIlG4%2A^;z#q!kK05BTYiun_pzPvEciK4I{C0kv2;~7K$S;DB|8Yi1&+ID=|SO z6XLdfU>S`pv#=!o!$#(0V$pm6_VD~9%uPE$SlN81&fDAh2BiFoPcuq+Nx5r^R*z(; zK%kSzh1O2&u4a4hQvDCYoQ%wa|ZRMu7FfhtatXHXPDr%L8 zvgLINZ^!RFDAd#{>UI&uVuTJNU%%U>g0IVmA7#!9r)J*2g_@=*PO?&}nd5&eYqpHw z?K=vxG^zD#O*zrZT+7NU%4kg+9$Pdfp0?9T!P>r7=y?kj^`iM;uWc3gS{#$}S2YJKhdWUKyn>{LXUb0TT4C5CKdyR2 zdc<-H7TpKpU~~I?;cQ^$0gZ%~&bB55uB&`v8kVkV|#@-FYOZ_r08coU91iuuB9-1wlxfP=TZu7v^1Ec&v~ zTQa8Zo}#}3)KFjYll^y$DCJqaPZVE^<&1sX`eAoXYdMSxyg8roH7Z}7H|TuF$c>FT zbQGhT3?w414ymG!$>Cf$xEgqkd_-lC`%VMl5vpMFNsw4t(K@~GLFk&_x$5q`dn+J~ zsD`8B=_5V?%q6~7^UT-S>?#YCFOM6&%#29OcB;#?kkw2;sbJq#ckc-VNhc9Esv+mq zWt;u5L#)>GW+TFq?j5B``%Z*z_HsA*C?dl){w7OL_$jydKF^C0Z(rBctidTI*0+-r;C98 a{~zx8AQ~@QpVc)@>jaL;zWu-FgZ~G)K5jz* literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8796012fdfeaada28369f28bebc12d4372943d50 GIT binary patch literal 6878 zcmb7pc|4Tu_y4(PFo?!hM9j2VN~n~g%t)b>C6yG0M~kv1vJ`X6qfLcUC}r%W)f&q3 zP)NlnQe;hGWXl>e=66l?dA{G@@AdkpQZ)tgdvI846q%xh=ltRHpax>uTJX*yr7s-@55s$Wr#wI4>#Vie-tvIrbr9 zVr3)oOmy7W+RuiqJ!3z4nd&cCoEFag!J+o{4Z-?CXyyOS&#cL+#h~nsWc!Ipu`06b zfuY)G4Ob<8zLUwQCT@_O`)(O5-I7JxPL)eF0@vs>WU5r|R&b3vL)KZVeCDtyL~BgL z8a0U*OJGa#FtNc@>TV)fE!j$;8alI72y{ug5d51MAxs)XSi{6zOR2a8n3D)1j)@Z9 zFM%Cl<>V+sBNmN7rywF%lyFTJRFj5@-Ih`c?GMC495=qCu$1iUsxpY)icD)LGS};{ z(FYgxS!n&H&(}}}<|4S*@ND!^vYXS0G8~m+?Wmai9K2ZWK`&TM$C9mzI?wdeqX#0d zmU=^_ZF3a;xH7z?;3F}6DHp{dEw~VN)Oj2ECC7o)yI67L3He>x`m{Fd8hJ4~FG%^0 zJiBi$-bKdsckKf!E@ovZJ_jz+fV15hqmPj2!5W%?jJdleTo|c^@$@Ianj*3W<>rGN z6B-f;L;$OO;5|vE-#WX-(PLc;M2#AeSO#a;JT$cv!R^WGpj9`)*`Y=Yx&xxH-jdEY z9GjCEfVw7h-*^P-<`#a-Z$Z?1BVm?(MSR13O#)quBnv+G4%ne!vz|bd@kIjunPdA7 zqmGA>pzuRh>hDF(Hmt?ygX5a7Ad2%30e{qxxAf10Rm(7X6X$@>?`2YNTmja7KdpP{ z;2^tqZQly)@u1ip=oW4EUg{!HPa9k&4S<&u=;9ipu>C&sA?erJ?32?NzN?=#7fG5S z214F^$mL_y+`VK!#6W*21pa(rdpa~==%E<$1*?*XPtYKjLHWgi66UwyT1B|TC(;%D zb|KB;O9AMiXnX2bE?C(V4N`5i|8MqRs!RyD7!FZ;TmRKgMLYU0VRk~kKcxTo$G@bX z8l9F?$}i6M$5eIY7LSTj-zR;1@3$FyUysg?tKWVSzE@)^X4c=4%34W)%Qj=q+)*P1 z);9FtdMc^(#1<)*7y+y`rgc6BE|HLm0J@wwt>KiG7@^^a%Q>9!i)L#I2I2xF(K9#jh4B%Ps7n@0SqMk=2JC5nbdy zpODqc1PDHHJalY>WnV1N8TPxWlv>;AMWC}xUbZ@N^u|3=*io}p=*L&?q16PC^%=pr z8zSS+Ls@~nF3TXH?vg2Vtg+W*ol0m*uz(IZEg7~$tyiUtAQS{h^!A6=o{c?AG2B{9 z+-h!*!8{D)iRw{OI106}42bt=LCsgL_N_m+syyN&;#Wlfno6=K*Qovq3Qj1TA=%4I z{njanp8BN;TE{eft04YjAbfn1TwvjS^|ube!t_PxNHop)j~RagjyQE@$pxO?`v1o4 zTW&E?>OE~^fY;R1cE0}4Fm7)<-(_ELXP4F0(wRsmne5KonEHL3IUW1NsrGZeKQWn& z;Xw3nt*cFKR%-k3Y$&la`pC(=VkN7M7|0xFcFA^eepF=ShgVcx z`44sE$FrfoO&LL}d>xLCDpU6$@$T6x@>P1Y@8JyE(~N;u5CSvvaeMBFtlDPVO$EFQ z-ruK2kiAksUFkJuC|Vj=^31<_a^C8CX&)z1kU7CHr9_|lTI^qua_Ska3iDY6^fNi# zk-pwHPOtqrwB_76J*pmB=36Uoreevdljk1w9XmqwS%$cZCOq*YeD2|ovz!$kLn1n7 z0mPuUpy{`I z&25q$x@d(=4(z^-f3`z%OM#x8xd9sO@y~w2fiBh8k>=ODqd4cQ$*J~W!7eM2ndO<| zkm-l{_%Test)k0|LDZ9DTg#3eDqFZmdu^uUEHQN(;4i)wopBs*Zbzc@n?j&aW;fb% zPTTS1)&`e-#mYKWn-yIq_zcI8Lvfp4ADYAI*l`Av(B3CCSkjl@`*4huPgC(xDw?wX zJ1Jr)cqYI83npO!ICk2*!>M4pYngM~_fOW5E}oI^c-;7DHEpdp13gU@!xkmsBvk@P zIPs$^oNFK0Ilt{%W*zy(5%e91rsfY z`MeW8cGg23(Mvir3=%VgpWTYpA`dB7Pez-WT?QadcXWj6Wb1mu-EJ9}J<(+2+}=4o z)S$;G(tOVBpbxZ!?-BzYR_02Fc^RsXnK?ZV^^7@x314@+@lbR~46Kwe5NR;*Rex|G zTX%X(*}@`d^sZ(Owaxo_G@?;{<=i1k1BMrgA{A{(VJR<>m1#+1{zPs-)kJ-)LgTOca@$NZ+Im2wzt$jBSy-uOLKGGKC;;T;Glcg`m%=+=e=E_=~^=P%+p)aQI z4WGInT2i&T(HVa_IWVX6^Qdo(2jgj1c0H4+;Ow&~W;=31aKO}XZz-)bIUcl=l`HyRwZj`u;=3OqnIMwN3VBA#6uQM2Z<6V)tHu2}r&Xj?=K>U#u z+V7XvP}|5yD-FD?GukDs-f|z`f%Bc|VRZhcCCzx%Aci{&Ej!JcbL+te%)L)&8=&P5p@cHe+A;647*yu3L=`)K!68|CP-k_+6y@E(&Ueq`r|owq8! z+kIi&+_isJ!AA|s@GBPC3YIq4Fv_Q#Q@##(`ilgA?-ro}erCTFkJw-=2=wiP_Cc?! z^HN5!n+j*|?iF$8)XjvcC7#b7Ft6LBecJhF&2fI>Gv?h`lnVMm+>fck9^YTP` z*B4UQ(>FX(5?ntxt0ul`6vE?N;@v; zykO0pYqf%3pMC4xw!SX6>EW?xTEQj@w~+72rnFDeJr}}OXcxGSd0eEgLB)(cqA@?? z-rl?!&K_GmpM;@^0Ds58fXORtgMcSlY0S_7#A8Ksd4It4rk!j@RAdHMTmUiAFlPySO@0j^C~)LR@&4<0SxB)_!Im%L;NxQIS9x_Ol5 zg}8jMB<^R9khx@u7)&l9H@7J8J&sz@Gt zoqqVkHn2c1ny~zJx$5jSUd7UPf(9(}JcioLM#T?HN`@a;ca(3egiK)Pz25ux>$*}_ zbY13sSKIq@W+%dr7)vmUzF+hn8UBR!ume@?vmULV-yAF}QXSazgg4^tepOyYviv*K zH4;EsS>)r)pg|{ck*dKA6P4`FZ<0#{ayyaS9=A5H65qi2Xy^CE_uijV>CMYr>s%i{ zC&gVU?k2!WSB_*T+4I!tv1LYKLgz#F>L*-iS;?F8PzG4SjbHFam(Z+Y$DJLgWt8DU;hWVMm+gy{rCrfMUD@2sRw6K;yPR4dFlN?&jlcYX1ci1h1tkquG-D~ptSe#g=`O}AmsA;=bv&a-YTtI`@?S1YiQ`v zY<;oz6)ljf*x9dlWi~!^0);i(Kru+Ktm2vf*)Y>|6_Kfbu32trA?5HFp>8wxLC})6 zWjzT>yc56rCzOIU?UP?J@=bqoBWe{r-3N$H#}J#ltE;;E(PKODjQpL`{KTpEuT!+8 zFLvIzyyWtO5Ye%!Pol!bN*zhJ4a)1cIS$OEXb{R-uPMYn)bC*Zv6_-@^BD_yC)xxj zZ#KL~mCGj&G)sRyp}lGHxp(Wu*U2b?3osBrAlngrq%x5-+&nXMPWf@M;=o|3A_v}~ zHe+$VXGwna;tloLWn@}wyFRwYd4iUT-WFRlXMfTPh z1TXHOIJPMTk;o=LHIbJDPY+yAdZ=1Zu&mqPp174Hxq)Y2nbH%_SEDq(E z_(GAphi7b85byX-N6{T}`HLI`4O^w3Qh9mcXiA7lo&uZV7;<#1sc{BVX_@&ercN5D zzj!Vcj+z+L-PMR?{9^8|^}b`S11+T|KV5wCSN+dCFL}WmUYJbW9isQD1`qqV`ma8Q zSv=~+ib5K1dUx7YSkqjp`X67OJ(ZmndgRy#HoI3=l3s*5Pon1L~_ky3%gArT9jp`wh^*-PCP4y)5HNP92zB}P@i%aT5UbANG z%Ub>5(#8ve4CV;Kx=3En>wHTwh^V2yFW=Min`Efp^sPO%;iK|e?x*x{G#gq?y$8Sd zv*$L7qDHV$UaOX~GibEujad8sr&*|DoTdW3E8paf#J-F_8Rm-Q3=wm|F{9g@!s&&m zo*5#e?8i6qVzn9bcKLsM9@wd5``Asuhq}v9gVk{oQ*ZN>Oxj(b+X!%p)2AxZ>%~rL zzP-KEI^hZLkuj>7 zbLoB=w-=*GqdY&De3NL^dNdv&&mE;l2aemdJj34L0P)-UTH&e3b5)5$**jKw zqZjIGn_}fBLFP{4=!`IsoU0q*8m3L-pV7(o{UsFzu%Nl9kmroIm^PCamAygRPP!gZ zOHO2zWH$zz9bLUx2i2d0nQyhq&`VYfW!LBC9%2{@n9Wm<=7ryKb0P8>vpNy2UW6L6 z&gngF`+vHlEi#dmyZIbTqWv@Xm%R&xs9uQhJ>5^KV!?@?wbSvDH%%`58KY#4OMo^FnRpo`JyJ}uvJGDSsXAR;Uynp}k+#YTFkJU#!vZlzsX!?l1 zuOj^XP{lVB!yZ(<>uDrcL{w(K&?&QhWZ$|k13Qo2WOEBVUmkM)85ofqa`qFSy^=t$ zy3OMa*wA^PU(ck)@Td>3H$UkkMdx%~tJ+^ok!v#@2JL?5@uRZP z-1KL9|L;?O6j;HR0K8u$L-xOB)L)}%xDFY-i=8z89XN&XGYTSLueN^M@7XoHUKs2t zcSR9S@drtetJZ;zA{547^Ksf7C{T&n3F>iDP%t;f{EyVN*(Cc~ezpLUKw|!w71f_F z3YUCV;Xdoz(ac_;PB)TcTKpNqGX!J!SMFg=46pN!#s`l(p3ukeV0id46a)f(VYU3O&7^(RU8$?Fb?WuCmi??g1=7nng6Z-u*)_GgO3K=Bsvp{EdEiOM}Sw1h2aFBw2#+TWB3E@HXneuMX;F&GaJBLJL}?c})rAQSf`75||`Ccf~RiCBX_JRNb zWw{}1OfmF$*KT5Va5ykcz8CGqzmpW)hJugAm`teZba7 z5N{v>9_I7sG4YcM1lByH^O-NWCUlY6NJ7yKc?$a27)u)I-tPF#CoTUQE@-s`D@*zR0f&lnK$SqC>yQ6GKx7J5 zwh_QA#e(w-21D2+D$^i*Y$L=W#B!j?RQX@hGC)-YKiQuwu;FeVu;u_Ho$31D3|uuqB;@OK8b?WQ_+Op?VHTk{pQZk>U*}Igz$v9%i$2`(C& zgi$kmh3OlD;u|x*6X;h+3vkc=L;GU0C7@8n9f~qyk8Y}22i>RHSnpZKN1I zE+021bu3xd6pFH?k6}oE6%@|kN6c0v)QR7wcmWeiW6LL6oU)J@|2F;O5QVsplD-6gOh z8UZU%obM!rDgB%Zia9JrM3Ub_h zQ4o_B#bpXAEW;pbeG*j1|COkPQC-ThD8g11