feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+26
View File
@@ -0,0 +1,26 @@
import pytest
from app import create_app, db
@pytest.fixture
def app():
app = create_app()
# Configure testing DB
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def app_context(app):
with app.app_context():
yield
@pytest.fixture
def init_database(app, app_context):
db.create_all()
yield db
db.session.remove()
db.drop_all()
+30
View File
@@ -0,0 +1,30 @@
import pytest
from app.services.jwt_service import JWTService
from app.models.user import User
def test_get_dashboard_stats(client, app_context, init_database):
"""Prueba que el endpoint del dashboard devuelva las métricas correctas en formato JSON"""
# Create a test admin user if not exists
admin = User.query.filter_by(email="admin@edu-space.com").first()
if not admin:
admin = User(email="admin@edu-space.com", name="Admin Test", role_id=1, role="admin", is_active=True)
admin.set_password("admin123")
from app import db
db.session.add(admin)
db.session.commit()
access_token = JWTService.create_access_token(admin)
headers = {
'Authorization': f'Bearer {access_token}'
}
response = client.get('/api/v1/dashboard/stats', headers=headers)
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'success'
assert 'stats' in data
assert 'total_classrooms' in data['stats']
assert 'monthly_data' in data
assert 'audit_logs' in data
@@ -0,0 +1,539 @@
"""
test_enrollment_and_commissions.py
Tests for the enrollment management system:
- StudentEnrollment model CRUD
- Commission management routes (Bedelia/Admin)
- /mis-materias routes per role (Docente, Alumno, Admin, Bedelia)
- RBAC enrollment permissions
- Bedelia role deduplication (no accented duplicate)
"""
import unittest
from app import create_app, db
from app.models.user import User
from app.models.role import Role, SYSTEM_MODULES
from app.models.subject import Subject, Commission
from app.models.enrollment import StudentEnrollment
def _login_as(client, user_id):
"""Helper: inject Flask-Login session for the given user id."""
with client.session_transaction() as sess:
sess['_user_id'] = str(user_id)
sess['_fresh'] = True
class TestStudentEnrollmentModel(unittest.TestCase):
"""Unit tests for the StudentEnrollment SQLAlchemy model."""
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.ctx = self.app.app_context()
self.ctx.push()
def tearDown(self):
self.ctx.pop()
def test_table_exists(self):
"""student_enrollments table must exist in the database."""
result = db.session.execute(
db.text("SELECT to_regclass('public.student_enrollments')")
).scalar()
self.assertIsNotNone(result, "Tabla student_enrollments no existe en la BD")
def test_model_columns(self):
"""StudentEnrollment must expose all required columns."""
cols = {c.name for c in StudentEnrollment.__table__.columns}
for expected in ('id', 'student_id', 'commission_id', 'status', 'enrolled_at', 'notes'):
self.assertIn(expected, cols, f"Columna faltante: {expected}")
def test_unique_constraint_name(self):
"""Unique constraint uq_student_commission must exist."""
constraint_names = {c.name for c in StudentEnrollment.__table__.constraints}
self.assertIn('uq_student_commission', constraint_names)
def test_valid_statuses(self):
"""to_dict must return a status in the accepted set."""
alumno = User.query.filter(
User.role.ilike('%ALUMNO%'), User.is_active == True
).first()
comm = Commission.query.filter_by(active=True).first()
if not alumno or not comm:
self.skipTest("Se requiere al menos un alumno y una comision activa en la BD")
# Check if already enrolled to avoid integrity error
existing = StudentEnrollment.query.filter_by(
student_id=alumno.id, commission_id=comm.id
).first()
if not existing:
enr = StudentEnrollment(
student_id=alumno.id,
commission_id=comm.id,
status='activo',
notes='Test enrollment'
)
db.session.add(enr)
db.session.commit()
enrolled = enr
else:
enrolled = existing
d = enrolled.to_dict()
self.assertIn(d['status'], ['activo', 'retirado', 'condicional'])
self.assertEqual(d['student_id'], alumno.id)
self.assertEqual(d['commission_id'], comm.id)
# Cleanup (only if we created it)
if not existing:
db.session.delete(enrolled)
# Restore current_students counter
comm.current_students = max(0, (comm.current_students or 1) - 1)
db.session.commit()
def test_relationships_accessible(self):
"""student and commission relationships must resolve without error."""
enr = StudentEnrollment.query.first()
if enr:
_ = enr.student
_ = enr.commission
class TestRBACEnrollmentModule(unittest.TestCase):
"""Tests for the enrollment RBAC module and role permissions."""
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.ctx = self.app.app_context()
self.ctx.push()
def tearDown(self):
self.ctx.pop()
def test_enrollment_module_in_system_modules(self):
"""SYSTEM_MODULES must contain an 'enrollment' module."""
module_ids = [m['id'] for m in SYSTEM_MODULES]
self.assertIn('enrollment', module_ids,
"Modulo 'enrollment' no encontrado en SYSTEM_MODULES")
def test_bedelia_has_enrollment_read_write(self):
"""Bedelia role must have read_write on enrollment."""
bedelia = Role.query.filter_by(name='Bedelia').first()
self.assertIsNotNone(bedelia, "Rol 'Bedelia' no existe en la BD")
self.assertEqual(bedelia.get_permission('enrollment'), 'read_write')
self.assertTrue(bedelia.has_permission('enrollment', 'read_write'))
def test_admin_has_enrollment_read_write(self):
"""Admin role must have read_write on enrollment."""
admin = Role.query.filter_by(name='Admin').first()
self.assertIsNotNone(admin)
self.assertEqual(admin.get_permission('enrollment'), 'read_write')
def test_docente_has_enrollment_read(self):
"""Docente role must have read on enrollment (not write)."""
docente = Role.query.filter_by(name='Docente').first()
self.assertIsNotNone(docente)
self.assertTrue(docente.has_permission('enrollment', 'read'))
self.assertFalse(docente.has_permission('enrollment', 'read_write'))
def test_alumno_has_enrollment_read(self):
"""Alumno role must have read on enrollment."""
alumno_role = Role.query.filter_by(name='Alumno').first()
self.assertIsNotNone(alumno_role)
self.assertTrue(alumno_role.has_permission('enrollment', 'read'))
def test_no_duplicate_bedelia_roles(self):
"""There must be exactly one Bedelia role (no accented duplicate)."""
bedelia_variants = Role.query.filter(
Role.name.in_(['Bedelia', 'Bedelía', 'BEDELÍA'])
).all()
names = [r.name for r in bedelia_variants]
self.assertNotIn('Bedelía', names,
"Rol duplicado 'Bedelía' (con acento) todavía existe en la BD")
self.assertEqual(len(bedelia_variants), 1,
f"Se encontraron {len(bedelia_variants)} variantes de Bedelia: {names}")
class TestCommissionsManagementRoutes(unittest.TestCase):
"""Integration tests for /admin/commissions/ routes (Bedelia/Admin)."""
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
# Login as admin (first active user)
self.admin = User.query.filter_by(email='admin@edu-space.com').first()
self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first()
self.docente = User.query.filter_by(email='docente@edu-space.com').first()
self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first()
def tearDown(self):
self.ctx.pop()
def _login(self, user):
_login_as(self.client, user.id)
def test_commissions_list_accessible_by_admin(self):
"""Admin can access /admin/commissions."""
self._login(self.admin)
res = self.client.get('/admin/commissions')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Gestión de Comisiones', html)
def test_commissions_list_accessible_by_bedelia(self):
"""Bedelia can access /admin/commissions."""
if not self.bedelia:
self.skipTest("No bedelia user in DB")
self._login(self.bedelia)
res = self.client.get('/admin/commissions')
self.assertEqual(res.status_code, 200)
def test_commissions_list_forbidden_for_docente(self):
"""Docente without manage permissions should get 403 on /admin/commissions."""
if not self.docente:
self.skipTest("No docente user in DB")
self._login(self.docente)
res = self.client.get('/admin/commissions')
# Docente has 'academic: read' → can_manage() is False → 403
self.assertIn(res.status_code, [403, 302])
def test_commission_detail_accessible_by_admin(self):
"""Commission detail page returns 200 for an existing commission."""
self._login(self.admin)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
res = self.client.get(f'/admin/commissions/{comm.id}')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Alumnos Inscriptos', html)
self.assertIn('Docente Asignado', html)
def test_commission_detail_accessible_by_bedelia(self):
"""Bedelia can view commission detail."""
if not self.bedelia:
self.skipTest("No bedelia user in DB")
self._login(self.bedelia)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
res = self.client.get(f'/admin/commissions/{comm.id}')
self.assertEqual(res.status_code, 200)
def test_commission_detail_nonexistent_returns_404(self):
"""Non-existent commission ID must return 404."""
self._login(self.admin)
res = self.client.get('/admin/commissions/999999')
self.assertEqual(res.status_code, 404)
def test_assign_teacher_post(self):
"""Bedelia can POST to assign a teacher to a commission."""
if not self.bedelia or not self.docente:
self.skipTest("Need bedelia and docente users")
self._login(self.bedelia)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
res = self.client.post(
f'/admin/commissions/{comm.id}/assign-teacher',
data={'teacher_id': self.docente.id},
follow_redirects=True
)
self.assertEqual(res.status_code, 200)
# Verify assignment persisted
db.session.refresh(comm)
self.assertEqual(comm.teacher_id, self.docente.id)
def test_enroll_student_and_unenroll(self):
"""Bedelia can enroll and unenroll an alumno in a commission."""
if not self.bedelia or not self.alumno_user:
self.skipTest("Need bedelia and alumno users")
self._login(self.bedelia)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Clean up any prior enrollment to ensure idempotent test
prior = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
if prior:
db.session.delete(prior)
db.session.commit()
prev_count = comm.current_students or 0
# Enroll
res = self.client.post(
f'/admin/commissions/{comm.id}/enroll-student',
data={'student_id': self.alumno_user.id, 'notes': 'Test'},
follow_redirects=True
)
self.assertEqual(res.status_code, 200)
enr = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
self.assertIsNotNone(enr, "Inscripción no encontrada tras POST enroll")
self.assertEqual(enr.status, 'activo')
db.session.refresh(comm)
self.assertEqual(comm.current_students, prev_count + 1)
# Unenroll
res2 = self.client.post(
f'/admin/commissions/{comm.id}/unenroll-student/{self.alumno_user.id}',
follow_redirects=True
)
self.assertEqual(res2.status_code, 200)
enr_after = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
self.assertIsNone(enr_after, "Inscripción debería haber sido eliminada")
db.session.refresh(comm)
self.assertEqual(comm.current_students, prev_count)
def test_update_enrollment_status(self):
"""Bedelia can change enrollment status (activo→condicional→retirado)."""
if not self.bedelia or not self.alumno_user:
self.skipTest("Need bedelia and alumno users")
self._login(self.bedelia)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Ensure enrollment exists
enr = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
if not enr:
enr = StudentEnrollment(
student_id=self.alumno_user.id,
commission_id=comm.id,
status='activo'
)
db.session.add(enr)
db.session.commit()
for new_status in ['condicional', 'retirado', 'activo']:
res = self.client.post(
f'/admin/commissions/{comm.id}/update-enrollment/{self.alumno_user.id}',
data={'status': new_status},
follow_redirects=True
)
self.assertEqual(res.status_code, 200)
db.session.refresh(enr)
self.assertEqual(enr.status, new_status)
# Cleanup
db.session.delete(enr)
db.session.commit()
def test_duplicate_enrollment_rejected(self):
"""Enrolling the same student twice in the same commission is rejected."""
if not self.bedelia or not self.alumno_user:
self.skipTest("Need bedelia and alumno users")
self._login(self.bedelia)
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Ensure a clean start
prior = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
if not prior:
enr = StudentEnrollment(
student_id=self.alumno_user.id,
commission_id=comm.id,
status='activo'
)
db.session.add(enr)
db.session.commit()
# Second enroll attempt
res = self.client.post(
f'/admin/commissions/{comm.id}/enroll-student',
data={'student_id': self.alumno_user.id},
follow_redirects=True
)
self.assertEqual(res.status_code, 200)
# Should see a warning message, and only one enrollment should exist
count = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).count()
self.assertEqual(count, 1)
# Cleanup
StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).delete()
db.session.commit()
class TestMySubjectsRoutes(unittest.TestCase):
"""/mis-materias route tests per role."""
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.client = self.app.test_client()
self.ctx = self.app.app_context()
self.ctx.push()
self.admin = User.query.filter_by(email='admin@edu-space.com').first()
self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first()
self.docente = User.query.filter_by(email='docente@edu-space.com').first()
self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first()
def tearDown(self):
self.ctx.pop()
def _login(self, user):
_login_as(self.client, user.id)
def test_mis_materias_requires_login(self):
"""Unauthenticated request to /mis-materias/ must redirect to login."""
res = self.client.get('/mis-materias/')
self.assertIn(res.status_code, [302, 401])
def test_mis_materias_admin_ok(self):
"""Admin sees all commissions at /mis-materias/."""
self._login(self.admin)
res = self.client.get('/mis-materias/')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Todas las Comisiones', html)
def test_mis_materias_bedelia_ok(self):
"""Bedelia sees all commissions at /mis-materias/."""
if not self.bedelia:
self.skipTest("No bedelia user")
self._login(self.bedelia)
res = self.client.get('/mis-materias/')
self.assertEqual(res.status_code, 200)
def test_mis_materias_docente_ok(self):
"""Docente gets 200 at /mis-materias/ (even with no assigned commissions)."""
if not self.docente:
self.skipTest("No docente user")
self._login(self.docente)
res = self.client.get('/mis-materias/')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Mis Materias', html)
def test_mis_materias_alumno_ok(self):
"""Alumno gets 200 at /mis-materias/ (even with no enrollments)."""
if not self.alumno_user:
self.skipTest("No alumno user")
self._login(self.alumno_user)
res = self.client.get('/mis-materias/')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Mis Materias', html)
def test_mis_materias_detail_enrolled_alumno(self):
"""Alumno enrolled in a commission can see its detail page."""
if not self.alumno_user:
self.skipTest("No alumno user")
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Create temporary enrollment
enr = StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).first()
cleanup_needed = False
if not enr:
enr = StudentEnrollment(
student_id=self.alumno_user.id,
commission_id=comm.id,
status='activo'
)
db.session.add(enr)
db.session.commit()
cleanup_needed = True
self._login(self.alumno_user)
res = self.client.get(f'/mis-materias/{comm.id}')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Tu inscripción', html)
if cleanup_needed:
db.session.delete(enr)
db.session.commit()
def test_mis_materias_detail_forbidden_for_unenrolled_alumno(self):
"""Alumno NOT enrolled in a commission gets 403 on its detail page."""
if not self.alumno_user:
self.skipTest("No alumno user")
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Ensure alumno is NOT enrolled
StudentEnrollment.query.filter_by(
student_id=self.alumno_user.id, commission_id=comm.id
).delete()
db.session.commit()
self._login(self.alumno_user)
res = self.client.get(f'/mis-materias/{comm.id}')
self.assertEqual(res.status_code, 403)
def test_mis_materias_detail_docente_own_commission(self):
"""Docente can see detail of their own assigned commission."""
if not self.docente:
self.skipTest("No docente user")
comm = Commission.query.filter_by(active=True).first()
if not comm:
self.skipTest("No active commission in DB")
# Assign commission to docente
prev_teacher = comm.teacher_id
comm.teacher_id = self.docente.id
db.session.commit()
self._login(self.docente)
res = self.client.get(f'/mis-materias/{comm.id}')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn(comm.subject.name, html)
# Restore
comm.teacher_id = prev_teacher
db.session.commit()
def test_mis_materias_detail_docente_unauthorized_commission(self):
"""Docente cannot see a commission they are not assigned to."""
if not self.docente:
self.skipTest("No docente user")
# Find a commission where docente is NOT the teacher
comm = Commission.query.filter(
Commission.active == True,
Commission.teacher_id != self.docente.id
).first()
if not comm:
self.skipTest("No commission without this docente in DB")
self._login(self.docente)
res = self.client.get(f'/mis-materias/{comm.id}')
self.assertEqual(res.status_code, 403)
if __name__ == '__main__':
unittest.main(verbosity=2)
+219
View File
@@ -0,0 +1,219 @@
import unittest
from datetime import datetime, timedelta
from app.models.genetic_algorithm import GeneticAlgorithm, ReservationOptimizer, ReservationRequest, Gene, Individual
from app.models.classroom import Classroom
from app.models.subject import Commission
class TestGeneticAlgorithm(unittest.TestCase):
"""Test cases for the Genetic Algorithm implementation"""
def setUp(self):
"""Set up test fixtures"""
self.ga = GeneticAlgorithm(population_size=10, generations=5)
# Mock classrooms
self.classrooms = [
Classroom(id=1, code="A101", building="A", capacity=30, is_active=True),
Classroom(id=2, code="A102", building="A", capacity=50, is_active=True),
Classroom(id=3, code="B101", building="B", capacity=25, is_active=True),
Classroom(id=4, code="B102", building="B", capacity=40, is_active=True),
]
# Mock reservation requests
start_time = datetime(2024, 1, 15, 9, 0, 0)
end_time = datetime(2024, 1, 15, 11, 0, 0)
self.requests = [
ReservationRequest(
commission_id=1,
expected_attendees=25,
purpose="Math Class",
preferred_start_time=start_time,
preferred_end_time=end_time,
priority=1,
flexibility_hours=2
),
ReservationRequest(
commission_id=2,
expected_attendees=35,
purpose="Physics Lab",
preferred_start_time=start_time,
preferred_end_time=end_time,
priority=1,
flexibility_hours=2
),
ReservationRequest(
commission_id=3,
expected_attendees=20,
purpose="Chemistry Class",
preferred_start_time=start_time,
preferred_end_time=end_time,
priority=1,
flexibility_hours=2
),
]
def test_individual_fitness_calculation(self):
"""Test fitness calculation for an individual"""
# Create an individual with some genes
genes = [
Gene(
commission_id=1,
classroom_id=1, # Capacity 30 for 25 students
start_time=datetime(2024, 1, 15, 9, 0, 0),
end_time=datetime(2024, 1, 15, 11, 0, 0),
expected_attendees=25,
purpose="Math Class"
),
Gene(
commission_id=2,
classroom_id=2, # Capacity 50 for 35 students
start_time=datetime(2024, 1, 15, 9, 0, 0),
end_time=datetime(2024, 1, 15, 11, 0, 0),
expected_attendees=35,
purpose="Physics Lab"
)
]
individual = Individual(genes)
classrooms_dict = {c.id: c for c in self.classrooms}
requests_dict = {r.commission_id: r for r in self.requests}
# Calculate fitness
fitness = individual.calculate_fitness(classrooms_dict, requests_dict)
# Fitness should be positive
self.assertGreater(fitness, 0)
self.assertEqual(individual.fitness, fitness)
def test_conflict_detection(self):
"""Test time conflict detection"""
genes = [
Gene(
commission_id=1,
classroom_id=1,
start_time=datetime(2024, 1, 15, 9, 0, 0),
end_time=datetime(2024, 1, 15, 11, 0, 0),
expected_attendees=25,
purpose="Math Class"
),
Gene(
commission_id=2,
classroom_id=1, # Same classroom
start_time=datetime(2024, 1, 15, 10, 0, 0), # Overlapping time
end_time=datetime(2024, 1, 15, 12, 0, 0),
expected_attendees=35,
purpose="Physics Lab"
)
]
individual = Individual(genes)
classrooms_dict = {c.id: c for c in self.classrooms}
requests_dict = {r.commission_id: r for r in self.requests[:2]}
# Calculate fitness - should detect conflicts
fitness = individual.calculate_fitness(classrooms_dict, requests_dict)
# Should have conflicts
self.assertGreater(len(individual.conflicts), 0)
self.assertLess(fitness, 2.0) # Lower fitness due to conflicts
def test_crossover_operation(self):
"""Test crossover operation"""
parent1_genes = [
Gene(1, 1, datetime.now(), datetime.now() + timedelta(hours=2), 25, "Class 1"),
Gene(2, 2, datetime.now(), datetime.now() + timedelta(hours=2), 30, "Class 2")
]
parent2_genes = [
Gene(3, 3, datetime.now(), datetime.now() + timedelta(hours=2), 20, "Class 3"),
Gene(4, 4, datetime.now(), datetime.now() + timedelta(hours=2), 35, "Class 4")
]
parent1 = Individual(parent1_genes)
parent2 = Individual(parent2_genes)
child1, child2 = self.ga._crossover(parent1, parent2)
# Children should have genes from both parents
self.assertGreater(len(child1.genes), 0)
self.assertGreater(len(child2.genes), 0)
# Total genes should equal sum of parents
total_genes = len(child1.genes) + len(child2.genes)
self.assertEqual(total_genes, len(parent1_genes) + len(parent2_genes))
def test_tournament_selection(self):
"""Test tournament selection"""
population = []
for i in range(5):
genes = [
Gene(i, 1, datetime.now(), datetime.now() + timedelta(hours=2), 25, f"Class {i}")
]
individual = Individual(genes)
individual.fitness = i * 0.1 # Different fitness values
population.append(individual)
selected = self.ga._tournament_selection(population)
# Should return an individual from the population
self.assertIn(selected, population)
def test_capacity_score_calculation(self):
"""Test capacity score calculation"""
# Perfect match
individual = Individual([])
score = individual._calculate_capacity_score(self.classrooms[0], 30) #capacity 30, students 30
self.assertEqual(score, 1.0)
# Underfilled but efficient (27/30 = 11.1% waste, goes to next category)
score = individual._calculate_capacity_score(self.classrooms[0], 27) # capacity 30, students 27
self.assertEqual(score, 0.8)
# Overfilled
score = individual._calculate_capacity_score(self.classrooms[0], 35) # capacity 30, students 35
self.assertEqual(score, 0.0)
def test_optimization_process(self):
"""Test the complete optimization process"""
result = self.ga.optimize_reservations(
self.requests,
self.classrooms,
datetime(2024, 1, 15),
datetime(2024, 1, 20)
)
# Should return best individual
self.assertIsInstance(result, Individual)
self.assertGreaterEqual(result.fitness, 0)
self.assertGreater(len(result.genes), 0)
def test_reservation_optimizer_interface(self):
"""Test the high-level optimizer interface"""
optimizer = ReservationOptimizer()
# Test the interface methods exist
self.assertTrue(hasattr(optimizer, 'optimize_schedule'))
self.assertTrue(hasattr(optimizer, 'apply_optimized_reservations'))
self.assertTrue(hasattr(optimizer, '_calculate_preferred_time'))
def test_reservation_request_validation(self):
"""Test reservation request validation"""
request = ReservationRequest(
commission_id=1,
expected_attendees=25,
purpose="Test Class",
preferred_start_time=datetime.now(),
preferred_end_time=datetime.now() + timedelta(hours=2)
)
# Should have required attributes
self.assertEqual(request.commission_id, 1)
self.assertEqual(request.expected_attendees, 25)
self.assertEqual(request.purpose, "Test Class")
self.assertEqual(request.priority, 1) # Default value
self.assertEqual(request.flexibility_hours, 2) # Default value
if __name__ == '__main__':
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
import unittest
import json
from app import create_app, db
from app.models.user import User
from app.services.jwt_service import JWTService
class TestJWTAndAPI(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# Buscar usuario activo o crear de prueba
self.test_user = User.query.filter_by(is_active=True).first()
if not self.test_user:
self.test_user = User(
email="test_api_admin@unicaba.edu.ar",
name="Test API Admin",
role="ADMIN",
is_active=True
)
self.test_user.set_password("Test1234!")
db.session.add(self.test_user)
db.session.commit()
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_jwt_generation_and_decoding(self):
tokens = JWTService.generate_tokens(self.test_user)
self.assertIn('access_token', tokens)
self.assertIn('refresh_token', tokens)
payload = JWTService.decode_token(tokens['access_token'], expected_type='access')
self.assertEqual(payload['sub'], str(self.test_user.id))
self.assertEqual(payload['email'], self.test_user.email)
def test_jwt_revocation(self):
tokens = JWTService.generate_tokens(self.test_user)
token = tokens['access_token']
# Antes de revocar: válido
payload = JWTService.decode_token(token, expected_type='access')
self.assertIsNotNone(payload)
# Revocar
revoked = JWTService.revoke_token(token)
self.assertTrue(revoked)
# Después de revocar: debe lanzar InvalidTokenError
with self.assertRaises(Exception):
JWTService.decode_token(token, expected_type='access')
def test_protected_endpoints_require_token(self):
# Sin token debe dar 401
res = self.client.get('/api/v1/auth/me')
self.assertEqual(res.status_code, 401)
res_classrooms = self.client.get('/api/v1/classrooms')
self.assertEqual(res_classrooms.status_code, 401)
def test_authorized_endpoint_access(self):
tokens = JWTService.generate_tokens(self.test_user)
headers = {'Authorization': f"Bearer {tokens['access_token']}"}
res = self.client.get('/api/v1/auth/me', headers=headers)
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertEqual(data['email'], self.test_user.email)
res_classrooms = self.client.get('/api/v1/classrooms', headers=headers)
self.assertEqual(res_classrooms.status_code, 200)
def test_token_refresh_flow(self):
tokens = JWTService.generate_tokens(self.test_user)
res = self.client.post('/api/v1/auth/refresh', json={'refresh_token': tokens['refresh_token']})
self.assertEqual(res.status_code, 200)
data = res.get_json()
self.assertIn('access_token', data)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,103 @@
import unittest
from app import create_app, db
from app.models.user import User
class TestRoleDashboardAndMenu(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
# Usar usuario administrador para autenticación en sesión de pruebas
self.user = User.query.filter_by(is_active=True).first()
if self.user:
with self.client.session_transaction() as sess:
sess['_user_id'] = str(self.user.id)
sess['_fresh'] = True
def tearDown(self):
self.app_context.pop()
def test_user_role_methods(self):
"""Verifica la normalización y detección de roles en el modelo User"""
u = User(name="Test Teacher", email="prof@test.edu", role="Docente")
self.assertEqual(u.get_institutional_role(), "docente")
self.assertTrue(u.is_docente())
self.assertFalse(u.is_admin())
self.assertFalse(u.is_alumno())
self.assertFalse(u.is_bedelia())
u_bedelia = User(name="Test Bedelia", email="bedelia@test.edu", role="Bedelia")
self.assertEqual(u_bedelia.get_institutional_role(), "bedelia")
self.assertTrue(u_bedelia.is_bedelia())
u_student = User(name="Test Student", email="student@test.edu", role="Alumno")
self.assertEqual(u_student.get_institutional_role(), "alumno")
self.assertTrue(u_student.is_alumno())
u_admin = User(name="Test Admin", email="admin@test.edu", role="ADMIN")
self.assertEqual(u_admin.get_institutional_role(), "admin")
self.assertTrue(u_admin.is_admin())
badge = u_admin.role_badge_display
self.assertIn("badge-admin", badge["class"])
self.assertIn("Administrador", badge["label"])
def test_admin_dashboard_rendered(self):
"""Verifica que el dashboard administrativo renderiza KPIs y accesos de administración"""
res = self.client.get('/?view_as=admin')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Aulas Totales', html)
self.assertIn('Oferta de Materias', html)
self.assertIn('Usuarios Activos', html)
self.assertIn('Módulos de Gestión & Visualización de Menús', html)
self.assertIn('Espacios & Sedes', html)
self.assertIn('Auditoría & RBAC', html)
def test_bedelia_dashboard_rendered(self):
"""Verifica que el dashboard de bedelía renderiza métricas operativas de campus"""
res = self.client.get('/?view_as=bedelia')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Aulas Presenciales', html)
self.assertIn('Salas Virtuales', html)
self.assertIn('Acciones Rápidas & Menús Operativos de Bedelía', html)
self.assertIn('Cartelera del Día', html)
self.assertIn('Sincronizar Sheets', html)
def test_docente_dashboard_rendered(self):
"""Verifica que el dashboard del docente renderiza sus clases y accesos directos"""
res = self.client.get('/?view_as=docente')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Mis Clases Hoy', html)
self.assertIn('Mis Reservas Activas', html)
self.assertIn('Reservar Aula', html)
self.assertIn('Accesos Directos de Docencia & Visualización de Menús', html)
self.assertIn('Mis Clases Programadas para Hoy', html)
def test_alumno_dashboard_rendered(self):
"""Verifica que el dashboard del alumno muestra la brújula diaria y herramientas de cursada"""
res = self.client.get('/?view_as=alumno')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('¿Dónde curso hoy? - Cartelera del Alumno', html)
self.assertIn('Menú del Alumno & Herramientas de Cursada', html)
self.assertIn('Cartelera de Hoy', html)
self.assertIn('Cronograma Semanal', html)
self.assertIn('Aulas Virtuales', html)
def test_audit_logs_view(self):
"""Verifica que la vista de auditoría institucional responde correctamente"""
res = self.client.get('/admin/audit-logs')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
self.assertIn('Registro de Auditoría Institucional', html)
self.assertIn('Trazabilidad completa de operaciones', html)
if __name__ == '__main__':
unittest.main()
+42
View File
@@ -0,0 +1,42 @@
import unittest
from app import create_app, db
from app.models.audit_log import AuditLog
from app.services.audit_service import AuditService
class TestSecurityChainAndAudit(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_security_headers_injected(self):
res = self.client.get('/login')
self.assertIn('X-Content-Type-Options', res.headers)
self.assertEqual(res.headers['X-Content-Type-Options'], 'nosniff')
self.assertIn('X-Frame-Options', res.headers)
self.assertEqual(res.headers['X-Frame-Options'], 'SAMEORIGIN')
self.assertIn('X-XSS-Protection', res.headers)
self.assertEqual(res.headers['X-XSS-Protection'], '1; mode=block')
def test_audit_log_creation(self):
entry = AuditService.log(
action='TEST_ACTION',
module='classrooms',
entity_id=999,
details={'test': True, 'desc': 'Prueba unitaria de auditoría'}
)
self.assertIsNotNone(entry.id)
self.assertEqual(entry.action, 'TEST_ACTION')
self.assertEqual(entry.module, 'classrooms')
recent = AuditService.get_recent_logs(limit=5, module='classrooms')
self.assertTrue(any(e.id == entry.id for e in recent))
if __name__ == '__main__':
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
import unittest
from datetime import datetime, timedelta
from app import create_app, db
from app.models.user import User
from app.models.classroom import Classroom
from app.models.building import Building
from app.models.subject import Commission
from app.schemas.classroom_dto import ClassroomCreateDTO
from app.schemas.reservation_dto import ReservationCreateDTO
from app.services.classroom_service import ClassroomService
from app.services.reservation_service import ReservationService
from app.services.user_service import UserService
class TestServicesAndDTO(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.app_context = self.app.app_context()
self.app_context.push()
self.classroom_service = ClassroomService()
self.reservation_service = ReservationService()
self.user_service = UserService()
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_dto_validation(self):
# Valid ClassroomCreateDTO
dto = ClassroomCreateDTO(code="Aula Test 101", floor="1", capacity=40, building_name="Edificio Central")
self.assertEqual(dto.code, "Aula Test 101")
self.assertEqual(dto.capacity, 40)
# Invalid Reservation range (end before start)
with self.assertRaises(Exception):
ReservationCreateDTO(
classroom_id=1,
commission_id=1,
user_id=1,
start_time=datetime(2026, 9, 10, 10, 0),
end_time=datetime(2026, 9, 10, 9, 0),
purpose="Clase Test"
)
def test_classroom_service_listing(self):
classrooms = self.classroom_service.list_classrooms()
self.assertIsInstance(classrooms, list)
def test_user_service_profile(self):
user = User.query.first()
if user:
profile = self.user_service.get_profile_data(user)
self.assertIn('email', profile)
self.assertIn('role', profile)
self.assertIn('permissions', profile)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,275 @@
import unittest
import time
from datetime import datetime, timedelta
from app import create_app, db
from app.models.user import User
from app.models.role import Role
from app.models.classroom import Classroom
from app.models.genetic_algorithm import (
GeneticAlgorithm,
ReservationRequest,
Individual,
Gene
)
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
from app.services.jwt_service import JWTService
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.app_context = self.app.app_context()
self.app_context.push()
self.client = self.app.test_client()
# Obtener usuario existente o crear
self.user = User.query.filter_by(is_active=True).first()
if not self.user:
admin_role = Role.query.filter_by(name='Admin').first()
if not admin_role:
admin_role = Role(name='Admin', description='Administrador')
db.session.add(admin_role)
db.session.commit()
self.user = User(
name='Sprint 4 Admin',
email='admin_sprint4@edu-space.com',
role_id=admin_role.id,
is_active=True
)
self.user.set_password('admin123')
db.session.add(self.user)
db.session.commit()
# Obtener aulas activas de la base de datos
self.classrooms = Classroom.query.filter_by(is_active=True).all()
if not self.classrooms:
for i in range(1, 11):
room = Classroom(
room_number=f"AULA-{100 + i}",
capacity=30 + (i * 5),
is_virtual=(i == 10),
is_active=True
)
db.session.add(room)
db.session.commit()
self.classrooms = Classroom.query.filter_by(is_active=True).all()
# Token JWT para el usuario de prueba
token_pair = JWTService.generate_tokens(self.user)
self.access_token = token_pair['access_token']
self.auth_headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json'
}
def tearDown(self):
db.session.rollback()
self.app_context.pop()
def test_01_fast_fitness_and_conflict_detection(self):
"""Verifica la detección de solapamiento O(K log K) y cálculo de fitness."""
now = datetime(2026, 3, 10, 18, 0, 0)
c1 = self.classrooms[0]
# Gene 1: 18:00 a 20:00 en AULA-101
g1 = Gene(
commission_id=1,
classroom_id=c1.id,
start_time=now,
end_time=now + timedelta(hours=2),
expected_attendees=30,
purpose="Clase Algoritmos"
)
# Gene 2: 19:00 a 21:00 en la MISMA aula (conflicto directo de 19:00 a 20:00)
g2 = Gene(
commission_id=2,
classroom_id=c1.id,
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=3),
expected_attendees=30,
purpose="Clase Base de Datos"
)
ind = Individual([g1, g2])
classrooms_dict = {c.id: c for c in self.classrooms}
req1 = ReservationRequest(1, 30, "Clase Algoritmos", now, now + timedelta(hours=2))
req2 = ReservationRequest(2, 30, "Clase Base de Datos", now + timedelta(hours=1), now + timedelta(hours=3))
requests_dict = {1: req1, 2: req2}
fit = ind.calculate_fitness(classrooms_dict, requests_dict)
self.assertGreater(len(ind.conflicts), 0, "Debe detectar conflicto de solapamiento de horario")
def test_02_benchmark_200_commissions_under_5_seconds(self):
"""Hito de Evaluación Sprint 4: Optimización de 200+ comisiones en menos de 5 segundos."""
base_time = datetime(2026, 3, 16, 8, 0, 0)
requests = []
# Crear 200 peticiones de comisión distribuidas en turnos
for i in range(1, 201):
day_offset = (i % 5) # Lun a Vie
slot_offset = (i % 4) * 3 # 08:00, 11:00, 14:00, 18:00
start = base_time + timedelta(days=day_offset, hours=slot_offset)
req = ReservationRequest(
commission_id=i,
expected_attendees=25 + (i % 25),
purpose=f"Comisión {i}",
preferred_start_time=start,
preferred_end_time=start + timedelta(hours=2),
priority=1,
flexibility_hours=2
)
requests.append(req)
# Configurar optimizador con paralelización (4 workers)
ga = GeneticAlgorithm(
population_size=30,
generations=25,
workers=4
)
t_start = time.perf_counter()
best = ga.optimize_reservations(
requests=requests,
available_classrooms=self.classrooms,
start_date=base_time,
end_date=base_time + timedelta(days=7)
)
t_elapsed = time.perf_counter() - t_start
self.assertIsNotNone(best)
self.assertEqual(len(best.genes), 200, "Debe haber asignado las 200 comisiones")
self.assertLess(t_elapsed, 5.0, f"El algoritmo debe resolver 200 comisiones en < 5s (demoró {t_elapsed:.2f}s)")
def test_03_optimizer_service_async_queue(self):
"""Verifica la cola de trabajos asíncrona de OptimizerService."""
# Enviar un job en segundo plano con app_context
job = OptimizerService.submit_job(
commission_ids=[1, 2],
admin_user_id=self.user.id,
generations=10,
population_size=10,
workers=2,
app=self.app
)
self.assertIsNotNone(job.job_id)
self.assertIn(job.status, [OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING, OptimizerJobStatus.COMPLETED])
# Esperar a que el worker complete la ejecución (máximo 4 segundos)
waited = 0.0
while job.status in (OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING) and waited < 4.0:
time.sleep(0.1)
waited += 0.1
self.assertEqual(job.status, OptimizerJobStatus.COMPLETED, f"El trabajo debió completar con éxito, error: {job.error}")
self.assertEqual(job.progress, 100)
self.assertIsNotNone(job.completed_at)
self.assertIsNotNone(job.execution_time_ms)
# Consultar via list_jobs y get_job
found = OptimizerService.get_job(job.job_id)
self.assertIsNotNone(found)
self.assertEqual(found.job_id, job.job_id)
all_jobs = OptimizerService.list_jobs()
self.assertTrue(any(j['job_id'] == job.job_id for j in all_jobs))
def test_04_spring_boot_sync_payload_endpoint(self):
"""Verifica el endpoint de interoperabilidad REST/JWT con Spring Boot."""
now = datetime(2026, 4, 1, 18, 30, 0)
payload = {
"requestId": "REQ-SPRING-BOOT-2026-001",
"campusCode": "SEDE-CENTRAL",
"academicTerm": "2026-1C",
"commissions": [
{
"commissionId": 101,
"subjectCode": "INFO-101",
"subjectName": "Programación Orientada a Objetos",
"expectedAttendees": 35,
"preferredShift": "NOCHE",
"preferredStart": now.isoformat(),
"preferredEnd": (now + timedelta(hours=3)).isoformat()
},
{
"commissionId": 102,
"subjectCode": "MATH-201",
"subjectName": "Análisis Matemático II",
"expectedAttendees": 40,
"preferredShift": "NOCHE",
"preferredStart": now.isoformat(),
"preferredEnd": (now + timedelta(hours=3)).isoformat()
}
],
"options": {
"generations": 15,
"populationSize": 15,
"workers": 2
}
}
response = self.client.post(
'/api/v1/optimizer/spring-boot/sync',
json=payload,
headers=self.auth_headers
)
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertEqual(data.get('requestId'), "REQ-SPRING-BOOT-2026-001")
self.assertEqual(data.get('status'), "SUCCESS")
self.assertEqual(data.get('totalCommissions'), 2)
self.assertIn('scheduledAssignments', data)
self.assertIn('executionTimeMs', data)
self.assertEqual(len(data['scheduledAssignments']), 2)
def test_05_api_async_job_submission_and_polling(self):
"""Verifica la API REST de despacho asíncrono y sondeo (polling)."""
payload = {
"commission_ids": [1, 2],
"generations": 10,
"population_size=10": None, # será ignorado o testeado con defaults
"generations": 10,
"population_size": 10,
"workers": 2
}
# 1. Enviar job (debe retornar 202 Accepted)
post_res = self.client.post(
'/api/v1/optimizer/jobs',
json=payload,
headers=self.auth_headers
)
self.assertEqual(post_res.status_code, 202)
post_data = post_res.get_json()
self.assertIn('job_id', post_data)
job_id = post_data['job_id']
self.assertEqual(post_data['poll_url'], f'/api/v1/optimizer/jobs/{job_id}')
# 2. Consultar estado del job
get_res = self.client.get(
f'/api/v1/optimizer/jobs/{job_id}',
headers=self.auth_headers
)
self.assertEqual(get_res.status_code, 200)
get_data = get_res.get_json()
self.assertEqual(get_data['job_id'], job_id)
self.assertIn(get_data['status'], ['PENDING', 'RUNNING', 'COMPLETED'])
# 3. Listar trabajos
list_res = self.client.get(
'/api/v1/optimizer/jobs',
headers=self.auth_headers
)
self.assertEqual(list_res.status_code, 200)
list_data = list_res.get_json()
self.assertGreaterEqual(list_data['total'], 1)
if __name__ == '__main__':
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
import unittest
from app import create_app, db
from app.models.user import User
class TestViewSwitcher(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['WTF_CSRF_ENABLED'] = False
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
# Login con usuario admin para acceder a las vistas protegidas
self.user = User.query.filter_by(is_active=True).first()
if self.user:
with self.client.session_transaction() as sess:
sess['_user_id'] = str(self.user.id)
sess['_fresh'] = True
def tearDown(self):
self.app_context.pop()
def test_classrooms_view_switcher_rendered(self):
res = self.client.get('/classrooms/?view_mode=physical')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
# Verificar que el selector de vista está presente
self.assertIn('view-switcher-group', html)
self.assertIn('data-context="classrooms"', html)
self.assertIn('data-view-container="cards"', html)
self.assertIn('data-view-container="table"', html)
def test_reservations_view_switcher_rendered(self):
res = self.client.get('/schedule/list')
self.assertEqual(res.status_code, 200)
html = res.get_data(as_text=True)
# Verificar que el selector de vista está presente
self.assertIn('view-switcher-group', html)
self.assertIn('data-context="reservations"', html)
self.assertIn('data-view-container="table"', html)
self.assertIn('data-view-container="cards"', html)
if __name__ == '__main__':
unittest.main()