From 1fafe7e1534bcd8e7c3a0b2e366da68e04d217c3 Mon Sep 17 00:00:00 2001 From: Alejandro Padilla Date: Mon, 17 Aug 2026 15:21:08 -0500 Subject: [PATCH 1/4] chore: ignore codegraph index --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4064f37..cc6d43b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ build/ # Frontend coverage output siged/frontend/coverage/ + +# CodeGraph index — machine-local, per-worktree, regenerable. +.codegraph/ From 189bbea02bfc930d4e1e6df89387bc4246a8efa3 Mon Sep 17 00:00:00 2001 From: Alejandro Padilla Date: Mon, 17 Aug 2026 15:21:23 -0500 Subject: [PATCH 2/4] feat(organizacion): seed educacion niveles catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load the LOEI education levels and subniveles as reference data so any fresh database can build a planning tree without manual setup. EducacionNivel.nombre is unique, so the planificacion model fixture can no longer create "Educación General Básica" by hand; it now uses a test-scoped name and leaves the real curriculum names to the seeded catalog. --- .../migrations/0005_seed_educacion_niveles.py | 74 +++++++++++++++++++ .../tests/test_planificacion_models.py | 3 +- .../organizacion/tests/test_seed_catalogo.py | 41 ++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 siged/backend/apps/organizacion/migrations/0005_seed_educacion_niveles.py create mode 100644 siged/backend/apps/organizacion/tests/test_seed_catalogo.py diff --git a/siged/backend/apps/organizacion/migrations/0005_seed_educacion_niveles.py b/siged/backend/apps/organizacion/migrations/0005_seed_educacion_niveles.py new file mode 100644 index 0000000..be579c8 --- /dev/null +++ b/siged/backend/apps/organizacion/migrations/0005_seed_educacion_niveles.py @@ -0,0 +1,74 @@ +from django.db import migrations + +# Reference data from the Ecuadorian LOEI curriculum. "pp" is a pedagogical period: +# pp_minutos is how long one period lasts, pp_semana_minimo the weekly minimum. +CATALOGO = [ + { + "nombre": "Educación Inicial", + "pp_minutos": 45, + "pp_semana_minimo": 25, + "subniveles": [ + ("Inicial 1", 25), + ("Inicial 2", 25), + ], + }, + { + "nombre": "Educación General Básica", + "pp_minutos": 40, + "pp_semana_minimo": 35, + "subniveles": [ + ("Preparatoria", 25), + ("Básica Elemental", 35), + ("Básica Media", 35), + ("Básica Superior", 35), + ], + }, + { + "nombre": "Bachillerato General Unificado", + "pp_minutos": 40, + "pp_semana_minimo": 40, + "subniveles": [], + }, +] + + +def seed_catalogo(apps, schema_editor): + EducacionNivel = apps.get_model("organizacion", "EducacionNivel") + EducacionSubnivel = apps.get_model("organizacion", "EducacionSubnivel") + + for entrada in CATALOGO: + nivel, _ = EducacionNivel.objects.get_or_create( + nombre=entrada["nombre"], + defaults={ + "pp_minutos": entrada["pp_minutos"], + "pp_semana_minimo": entrada["pp_semana_minimo"], + }, + ) + for nombre, pp_semana_minimo in entrada["subniveles"]: + EducacionSubnivel.objects.get_or_create( + educacion_nivel=nivel, + nombre=nombre, + defaults={"pp_semana_minimo": pp_semana_minimo}, + ) + + +def unseed_catalogo(apps, schema_editor): + """Only remove catalog rows that nothing else depends on, so the reverse never breaks.""" + EducacionNivel = apps.get_model("organizacion", "EducacionNivel") + EducacionSubnivel = apps.get_model("organizacion", "EducacionSubnivel") + + nombres = [entrada["nombre"] for entrada in CATALOGO] + EducacionSubnivel.objects.filter( + educacion_nivel__nombre__in=nombres, grados_escolares__isnull=True + ).delete() + EducacionNivel.objects.filter( + nombre__in=nombres, grados_escolares__isnull=True, subniveles__isnull=True + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("organizacion", "0004_asignatura_educacionnivel_educacionsubnivel_and_more") + ] + + operations = [migrations.RunPython(seed_catalogo, unseed_catalogo)] diff --git a/siged/backend/apps/organizacion/tests/test_planificacion_models.py b/siged/backend/apps/organizacion/tests/test_planificacion_models.py index 4a25909..097dcaa 100644 --- a/siged/backend/apps/organizacion/tests/test_planificacion_models.py +++ b/siged/backend/apps/organizacion/tests/test_planificacion_models.py @@ -22,8 +22,9 @@ def institucion(): @pytest.fixture def nivel(): + # Test-scoped name: the seeded catalog owns the real curriculum names. return EducacionNivel.objects.create( - nombre="Educación General Básica", pp_minutos=40, pp_semana_minimo=30 + nombre="Nivel Educativo de Prueba", pp_minutos=40, pp_semana_minimo=30 ) diff --git a/siged/backend/apps/organizacion/tests/test_seed_catalogo.py b/siged/backend/apps/organizacion/tests/test_seed_catalogo.py new file mode 100644 index 0000000..1451e18 --- /dev/null +++ b/siged/backend/apps/organizacion/tests/test_seed_catalogo.py @@ -0,0 +1,41 @@ +import pytest + +from apps.organizacion.models import ( + EducacionNivel, + EducacionSubnivel, +) + + +@pytest.mark.django_db +class TestCatalogoEducativo: + """The education-level catalog is reference data: it must exist on any fresh database.""" + + def test_niveles_are_seeded_by_migration(self): + assert EducacionNivel.objects.exists() + + def test_expected_niveles_are_present(self): + nombres = set(EducacionNivel.objects.values_list("nombre", flat=True)) + assert { + "Educación Inicial", + "Educación General Básica", + "Bachillerato General Unificado", + } <= nombres + + def test_niveles_declare_positive_pedagogical_load(self): + for nivel in EducacionNivel.objects.all(): + assert nivel.pp_minutos > 0 + assert nivel.pp_semana_minimo > 0 + + def test_educacion_general_basica_exposes_its_subniveles(self): + egb = EducacionNivel.objects.get(nombre="Educación General Básica") + nombres = set(egb.subniveles.values_list("nombre", flat=True)) + assert { + "Preparatoria", + "Básica Elemental", + "Básica Media", + "Básica Superior", + } <= nombres + + def test_subniveles_declare_positive_weekly_minimum(self): + for subnivel in EducacionSubnivel.objects.all(): + assert subnivel.pp_semana_minimo > 0 From 045053ebc43b20d88a5dc5c65a5a97450fb8d981 Mon Sep 17 00:00:00 2001 From: Alejandro Padilla Date: Mon, 17 Aug 2026 15:21:50 -0500 Subject: [PATCH 3/4] feat(core): seed a walkable planificacion tree in seed_demo seed_demo stopped at users, instituciones and roles, so a fresh local environment had no plan de estudio to open. It now builds one active plan with grados and asignaturas on the first demo institution, and dev.sh runs it as part of the backend bootstrap. The grados are chosen so RF-017 is demonstrable both ways: one meets its subnivel weekly minimum and one falls short and raises the alert. An existing active plan is reused instead of duplicated, because the institution allows only one, and the command fails with a clear error when the education catalog migration has not run. --- scripts/dev.sh | 1 + .../core/management/commands/seed_demo.py | 93 ++++++++++++++++++- siged/backend/apps/core/tests.py | 28 +++++- .../organizacion/tests/test_seed_catalogo.py | 63 +++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 02b2450..3770403 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -70,6 +70,7 @@ print_step "Preparing backend database" cd "$BACKEND_DIR" .venv/bin/python manage.py migrate --noinput .venv/bin/python manage.py seed_superuser + .venv/bin/python manage.py seed_demo ) print_step "Starting backend on http://$BACKEND_HOST:$BACKEND_PORT" diff --git a/siged/backend/apps/core/management/commands/seed_demo.py b/siged/backend/apps/core/management/commands/seed_demo.py index 96b82e0..785ad0f 100644 --- a/siged/backend/apps/core/management/commands/seed_demo.py +++ b/siged/backend/apps/core/management/commands/seed_demo.py @@ -3,7 +3,16 @@ from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from apps.organizacion.models import Institucion, Rol, UsuarioRol +from apps.organizacion.models import ( + Asignatura, + EducacionNivel, + EducacionSubnivel, + GradoEscolar, + Institucion, + PlanEstudio, + Rol, + UsuarioRol, +) from apps.organizacion.servicios.institucion_servicio import InstitucionServicio @@ -20,6 +29,32 @@ ("INST-DEMO-003", "Escuela Comunitaria Vilcabamba", "1190001003001"), ) +DEMO_NIVEL = "Educación General Básica" +DEMO_PLAN = "Plan de Estudio 2026-2027" +# Weekly load adds up to the "Básica Elemental" minimum, so this grado shows no RF-017 alert. +ASIGNATURAS_ELEMENTAL = ( + ("Lengua y Literatura", 10), + ("Matemática", 8), + ("Educación Física", 5), + ("Ciencias Naturales", 3), + ("Estudios Sociales", 3), + ("Educación Cultural y Artística", 3), + ("Lengua Extranjera", 3), +) +# Deliberately below the "Básica Superior" minimum, so this grado does raise the RF-017 alert. +ASIGNATURAS_SUPERIOR = ( + ("Lengua y Literatura", 6), + ("Matemática", 6), + ("Ciencias Naturales", 4), + ("Estudios Sociales", 4), +) +DEMO_GRADOS = ( + ("1ro de Básica", 1, "Preparatoria", ()), + ("2do de Básica", 2, "Básica Elemental", ()), + ("4to de Básica", 4, "Básica Elemental", ASIGNATURAS_ELEMENTAL), + ("8vo de Básica", 8, "Básica Superior", ASIGNATURAS_SUPERIOR), +) + class Command(BaseCommand): help = "Crea datos locales repetibles para demostrar SIGED" @@ -79,11 +114,67 @@ def handle(self, *args, **options): ) assignments_created += int(created) + plan, grados_created, asignaturas_created = self._seed_planificacion(institutions[0]) + self.stdout.write(self.style.SUCCESS( f"Demo lista: usuarios {users_created} creados/{len(DEMO_USERS) - users_created} reutilizados; " f"instituciones {institutions_created} creadas/{len(DEMO_INSTITUTIONS) - institutions_created} reutilizadas; " f"asignaciones {assignments_created} creadas/{len(assignments) - assignments_created} reutilizadas." )) + self.stdout.write(self.style.SUCCESS( + f"Planificación lista: plan '{plan.nombre}' en {institutions[0].nombre}; " + f"grados {grados_created} creados/{len(DEMO_GRADOS) - grados_created} reutilizados; " + f"asignaturas {asignaturas_created} creadas." + )) self.stdout.write(f"Contraseña común para usuarios demo nuevos: {DEMO_PASSWORD}") for identification, first_name, last_name, role_name in DEMO_USERS: self.stdout.write(f"- {identification}: {first_name} {last_name} ({role_name})") + + def _seed_planificacion(self, institucion): + try: + nivel = EducacionNivel.objects.get(nombre=DEMO_NIVEL) + except EducacionNivel.DoesNotExist: + raise CommandError( + f"No se encontró el nivel educativo '{DEMO_NIVEL}'. " + "Ejecute las migraciones para cargar el catálogo educativo." + ) + + # The institution allows a single active plan, so an existing one is reused even if it + # was renamed from the UI. Creating another active plan would break that constraint. + plan = ( + PlanEstudio.objects.filter(institucion=institucion, nombre=DEMO_PLAN).first() + or PlanEstudio.objects.filter(institucion=institucion, es_activo=True).first() + ) + if plan is None: + plan = PlanEstudio.objects.create( + institucion=institucion, nombre=DEMO_PLAN, es_activo=True + ) + + grados_created = asignaturas_created = 0 + for nombre, orden, subnivel_nombre, asignaturas in DEMO_GRADOS: + try: + subnivel = EducacionSubnivel.objects.get( + educacion_nivel=nivel, nombre=subnivel_nombre + ) + except EducacionSubnivel.DoesNotExist: + raise CommandError( + f"No se encontró el subnivel educativo '{subnivel_nombre}'. " + "Ejecute las migraciones para cargar el catálogo educativo." + ) + + grado, created = GradoEscolar.objects.get_or_create( + plan_estudio=plan, + nombre=nombre, + defaults={"orden": orden, "nivel": nivel, "subnivel": subnivel}, + ) + grados_created += int(created) + + for asignatura_nombre, pp_semana_minimo in asignaturas: + _, created = Asignatura.objects.get_or_create( + grado_escolar=grado, + nombre=asignatura_nombre, + defaults={"pp_semana_minimo": pp_semana_minimo}, + ) + asignaturas_created += int(created) + + return plan, grados_created, asignaturas_created diff --git a/siged/backend/apps/core/tests.py b/siged/backend/apps/core/tests.py index c5785bd..1ae60a8 100644 --- a/siged/backend/apps/core/tests.py +++ b/siged/backend/apps/core/tests.py @@ -32,7 +32,13 @@ from apps.core.servicios.autenticacion_servicio import ( AutenticacionServicio, ) -from apps.organizacion.models import Institucion, Rol, UsuarioRol +from apps.organizacion.models import ( + GradoEscolar, + Institucion, + PlanEstudio, + Rol, + UsuarioRol, +) Usuario = get_user_model() @@ -194,6 +200,26 @@ def test_primera_ejecucion_crea_demo_y_segunda_es_idempotente(self): codes, ) + @override_settings(DEBUG=True) + def test_reutiliza_el_plan_activo_aunque_haya_sido_renombrado(self): + call_command("seed_demo") + + institucion = Institucion.objects.get(codigo=DEMO_INSTITUTIONS[0][0]) + plan = PlanEstudio.objects.get(institucion=institucion) + plan.nombre = "Plan de Estudio 2026-2028" + plan.save(update_fields=["nombre"]) + grados_antes = GradoEscolar.objects.filter(plan_estudio=plan).count() + + call_command("seed_demo") + + self.assertEqual(PlanEstudio.objects.filter(institucion=institucion).count(), 1) + plan.refresh_from_db() + self.assertEqual(plan.nombre, "Plan de Estudio 2026-2028") + self.assertTrue(plan.es_activo) + self.assertEqual( + GradoEscolar.objects.filter(plan_estudio=plan).count(), grados_antes + ) + @override_settings(DEBUG=True) def test_rechaza_colision_de_ruc_con_codigo_o_nombre_distinto_antes_de_asignar_roles(self): identification, first_name, last_name, _ = DEMO_USERS[0] diff --git a/siged/backend/apps/organizacion/tests/test_seed_catalogo.py b/siged/backend/apps/organizacion/tests/test_seed_catalogo.py index 1451e18..6a900b6 100644 --- a/siged/backend/apps/organizacion/tests/test_seed_catalogo.py +++ b/siged/backend/apps/organizacion/tests/test_seed_catalogo.py @@ -1,8 +1,13 @@ import pytest +from django.core.management import call_command +from django.db.models import Sum from apps.organizacion.models import ( + Asignatura, EducacionNivel, EducacionSubnivel, + GradoEscolar, + PlanEstudio, ) @@ -39,3 +44,61 @@ def test_educacion_general_basica_exposes_its_subniveles(self): def test_subniveles_declare_positive_weekly_minimum(self): for subnivel in EducacionSubnivel.objects.all(): assert subnivel.pp_semana_minimo > 0 + + +@pytest.fixture +def debug_enabled(settings): + """seed_demo is guarded behind DEBUG so it can never run against production.""" + settings.DEBUG = True + return settings + + +@pytest.mark.django_db +@pytest.mark.usefixtures("debug_enabled") +class TestSeedDemo: + """seed_demo must leave a fully walkable planning tree behind.""" + + def test_creates_an_active_plan_with_grados_and_asignaturas(self): + call_command("seed_demo") + + plan = PlanEstudio.objects.get(es_activo=True) + assert plan.grados_escolares.count() >= 4 + assert Asignatura.objects.filter(grado_escolar__plan_estudio=plan).exists() + + def test_every_grado_resolves_a_subnivel_when_its_nivel_has_one(self): + call_command("seed_demo") + + for grado in GradoEscolar.objects.select_related("nivel"): + if grado.nivel.subniveles.exists(): + assert grado.subnivel_id is not None + + def test_seeds_one_grado_that_meets_its_minimum_and_one_that_does_not(self): + call_command("seed_demo") + + alerts = set() + for grado in GradoEscolar.objects.select_related("nivel", "subnivel"): + actual = grado.asignaturas.aggregate(total=Sum("pp_semana_minimo"))["total"] or 0 + minimo = ( + grado.subnivel.pp_semana_minimo + if grado.subnivel_id + else grado.nivel.pp_semana_minimo + ) + alerts.add(actual < minimo) + + assert alerts == {True, False}, "RF-017 needs both a satisfied and an alerting grado" + + def test_is_idempotent(self): + call_command("seed_demo") + counts = ( + PlanEstudio.objects.count(), + GradoEscolar.objects.count(), + Asignatura.objects.count(), + ) + + call_command("seed_demo") + + assert counts == ( + PlanEstudio.objects.count(), + GradoEscolar.objects.count(), + Asignatura.objects.count(), + ) From 99c53be50c59a77ab2afb4716319bc682f16afea Mon Sep 17 00:00:00 2001 From: Alejandro Padilla Date: Mon, 17 Aug 2026 15:21:55 -0500 Subject: [PATCH 4/4] test(frontend): install an in-memory storage fallback in the vitest setup Node 26 ships an experimental global localStorage that stays undefined unless the process gets --localstorage-file. Under jsdom, globalThis and window are the same object, so that undefined global shadows the jsdom storage and leaves nothing to fall back to. Define an in-memory Storage for localStorage and sessionStorage whenever they are missing. --- siged/frontend/src/testSetup.ts | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/siged/frontend/src/testSetup.ts b/siged/frontend/src/testSetup.ts index d0de870..cc98436 100644 --- a/siged/frontend/src/testSetup.ts +++ b/siged/frontend/src/testSetup.ts @@ -1 +1,42 @@ import "@testing-library/jest-dom"; + +/** + * Node 26 ships an experimental global `localStorage` that stays `undefined` unless the + * process is started with `--localstorage-file`. In the jsdom environment `globalThis` and + * `window` are the same object, so that undefined global clobbers the jsdom storage and + * leaves nothing to fall back to. Install an in-memory Storage when it is missing. + */ +function createMemoryStorage(): Storage { + let entries = new Map(); + + return { + get length() { + return entries.size; + }, + clear() { + entries = new Map(); + }, + getItem(key: string) { + return entries.has(String(key)) ? entries.get(String(key))! : null; + }, + key(index: number) { + return [...entries.keys()][index] ?? null; + }, + removeItem(key: string) { + entries.delete(String(key)); + }, + setItem(key: string, value: string) { + entries.set(String(key), String(value)); + }, + }; +} + +for (const name of ["localStorage", "sessionStorage"] as const) { + if (typeof globalThis[name] === "undefined") { + Object.defineProperty(globalThis, name, { + configurable: true, + value: createMemoryStorage(), + writable: true, + }); + } +}