Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ build/

# Frontend coverage output
siged/frontend/coverage/

# CodeGraph index — machine-local, per-worktree, regenerable.
.codegraph/
1 change: 1 addition & 0 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
93 changes: 92 additions & 1 deletion siged/backend/apps/core/management/commands/seed_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"
Expand Down Expand Up @@ -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
28 changes: 27 additions & 1 deletion siged/backend/apps/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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)]
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
104 changes: 104 additions & 0 deletions siged/backend/apps/organizacion/tests/test_seed_catalogo.py
Original file line number Diff line number Diff line change
@@ -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(),
)
Loading
Loading