diff --git a/app/models/role.py b/app/models/role.py index 9a6d748..050cf03 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -72,6 +72,13 @@ SYSTEM_MODULES = [ 'name_en': 'Occupancy Metrics', 'icon': 'bi-graph-up', 'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).' + }, + { + 'id': 'enrollment', + 'name': 'Inscripciones y Cursadas', + 'name_en': 'Enrollments & Courses', + 'icon': 'bi-diagram-3', + 'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.' } ] diff --git a/app/routes/admin.py b/app/routes/admin.py index 3fd0f69..95a37b4 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1174,7 +1174,7 @@ def audit_logs(): @login_required def commissions_list(): """List all commissions with filters - accessible by Bedelia and Admin""" - if not (current_user.has_permission('academic', 'read') or current_user.can_manage()): + if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()): abort(403) from app.models.subject import Subject, Commission @@ -1241,7 +1241,7 @@ def commissions_list(): @login_required def commission_detail(id): """Detail view of a commission: enrolled students and teacher assignment""" - if not (current_user.has_permission('academic', 'read') or current_user.can_manage()): + if not (current_user.has_permission('enrollment', 'read_write') or current_user.is_admin() or current_user.is_bedelia()): abort(403) from app.models.subject import Commission diff --git a/init_db.py b/init_db.py index d1fc1cb..6cc144b 100644 --- a/init_db.py +++ b/init_db.py @@ -107,7 +107,9 @@ def init_database(): 'import': 'read_write', 'optimizer': 'read_write', 'users': 'read_write', - 'milestone_types': 'read_write' + 'milestone_types': 'read_write', + 'metrics': 'read_write', + 'enrollment': 'read_write' } }, { @@ -123,7 +125,9 @@ def init_database(): 'import': 'none', 'optimizer': 'read', 'users': 'none', - 'milestone_types': 'read' + 'milestone_types': 'read', + 'metrics': 'none', + 'enrollment': 'read' } }, { @@ -139,7 +143,9 @@ def init_database(): 'import': 'read_write', 'optimizer': 'none', 'users': 'none', - 'milestone_types': 'read_write' + 'milestone_types': 'read_write', + 'metrics': 'read_write', + 'enrollment': 'read_write' } }, { @@ -155,7 +161,9 @@ def init_database(): 'import': 'none', 'optimizer': 'none', 'users': 'none', - 'milestone_types': 'read' + 'milestone_types': 'read', + 'metrics': 'none', + 'enrollment': 'read' } } ] @@ -188,6 +196,27 @@ def init_database(): db.session.commit() print("[OK] Roles y matriz de permisos configurados correctamente.") + + # 3b. Deduplicar rol Bedelia con acento (Bedelía) si existe + print("[*] Verificando duplicados del rol Bedelia...") + bedelia_accented = Role.query.filter( + Role.name.in_(['Bedelía', 'Bedel\u00eda', 'BEDELÍA']) + ).first() + bedelia_canonical = Role.query.filter_by(name='Bedelia').first() + if bedelia_accented and bedelia_canonical and bedelia_accented.id != bedelia_canonical.id: + # Migrate all users from accented role to canonical + from app.models.user import User + affected = User.query.filter_by(role_id=bedelia_accented.id).all() + for u in affected: + u.role_id = bedelia_canonical.id + u.role = 'BEDELIA' + print(f" [~] Usuario {u.email} migrado de '{bedelia_accented.name}' a 'Bedelia'") + db.session.flush() + db.session.delete(bedelia_accented) + db.session.commit() + print(f" [OK] Rol duplicado '{bedelia_accented.name}' eliminado. {len(affected)} usuario(s) migrado(s).") + else: + print(" [OK] Sin duplicados de Bedelia.") # 4. Verificar o crear usuarios institucionales de cada rol print("[*] Verificando usuarios institucionales y credenciales...") diff --git a/tests/test_enrollment_and_commissions.py b/tests/test_enrollment_and_commissions.py new file mode 100644 index 0000000..297894f --- /dev/null +++ b/tests/test_enrollment_and_commissions.py @@ -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)