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/ 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/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..6a900b6 --- /dev/null +++ b/siged/backend/apps/organizacion/tests/test_seed_catalogo.py @@ -0,0 +1,104 @@ +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, +) + + +@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 + + +@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(), + ) 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, + }); + } +}