From f3a4b5875efbd8e44d04e8e71c1ee65994b30803 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Thu, 3 Sep 2026 04:34:54 -0300 Subject: [PATCH] feat: abm comisiones, hitos, links virtuales, eliminacion usuarios y roles bedelia y alumno --- CHANGELOG_SESSION.md | 24 + app/models/milestone.py | 6 +- app/models/subject.py | 3 + app/routes/admin.py | 51 + app/routes/schedule.py | 235 +++- app/templates/admin/milestone_types/list.html | 174 ++- app/templates/admin/users/list.html | 65 + app/templates/base.html | 4 + app/templates/schedule/commissions.html | 1090 ++++++++++++----- init_db.py | 6 +- 10 files changed, 1297 insertions(+), 361 deletions(-) diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md index 94c7ead..359392d 100644 --- a/CHANGELOG_SESSION.md +++ b/CHANGELOG_SESSION.md @@ -284,6 +284,30 @@ * Se trasladó el modal de confirmación de eliminación `#deleteRoleModal` fuera de la celda `` y de la tabla ``, situándolo a nivel raíz de la plantilla. * Se corrigió el error visual donde el cuadro emergente temblaba, saltaba o se desplazaba por el recálculo de padding/scrollbar de Bootstrap en la celda de la tabla. +--- + +## 18. ABM de Comisiones con Hitos y Aula Virtual, Eliminación de Usuarios y Renombrado de Roles (Bedelía y Alumno) +* **Renombrado de Roles Institucionales ([init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py), base de datos PostgreSQL):** + * `Operador` pasó a denominarse **`Bedelía`** con foco en la gestión operativa de aulas, sedes, reservas, comisiones, importación y cartelera. + * `Consulta` pasó a denominarse **`Alumno`** con acceso de solo lectura al cronograma general, asignaturas y aulas. + * Se ejecutó la actualización en caliente de los registros existentes en PostgreSQL y se actualizaron los sembrados en `init_db.py`. +* **Corrección Visual de Tipificaciones de Hitos ([app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):** + * Se ajustó el contenedor a `pt-5 mt-4` evitando que el encabezado quede solapado bajo la barra de navegación fija. + * Se limpió la duplicación del nombre en la tabla (se eliminó la repetición del texto dentro de la celda). + * Se agregó la paleta rápida de colores institucionales UniCABA al modal de edición. + * Se agregó el botón y modal global de confirmación de eliminación `#deleteMilestoneTypeModal` sin saltos visuales. +* **Eliminación de Usuarios ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/users/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/users/list.html)):** + * Se implementó el endpoint `POST /admin/users/delete/` protegido contra la eliminación del usuario en sesión activa y del administrador único. + * Desvinculación automática de comisiones como docente para prevenir violaciones de integridad referencial. + * Botón rojo de eliminación con ícono de papelera y modal global `#deleteUserModal` fuera de la tabla para evitar desplazamientos. +* **ABM Integral de Comisiones con Aulas Virtuales e Hitos ([app/models/subject.py](file:///c:/Workspace/admin-edu-space/app/models/subject.py), [app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/routes/schedule.py](file:///c:/Workspace/admin-edu-space/app/routes/schedule.py), [app/templates/schedule/commissions.html](file:///c:/Workspace/admin-edu-space/app/templates/schedule/commissions.html), [app/templates/base.html](file:///c:/Workspace/admin-edu-space/app/templates/base.html)):** + * **Columna y Enlace de Aula Virtual:** Se añadió el campo `virtual_link` (VARCHAR 500) a `commissions`. Permite registrar enlaces directos a Zoom, Microsoft Teams, Google Meet o Campus Virtual. + * **Hitos de Evaluación vinculados a Comisiones:** Se añadió `commission_id` a `academic_milestones`. Permite asociar parciales, TPs y recuperatorios directamente a la comisión de cursada. + * **Permisos para Profesores, Bedelía y Admin:** Los roles `Docente`, `Bedelía` y `Admin` pueden configurar el aula virtual y registrar hitos académicos en sus comisiones. + * **Navegación:** Acceso directo a *Comisiones y Cursadas* disponible en la barra superior (menús *Schedule* y *Management*). + * **Modales interactivos:** Creación de comisión (`#modalAddCommission`), edición (`#modalEditCommission`), configuración de aula virtual (`#modalVirtualLink`), programación de hitos (`#modalAddMilestone`) y eliminación segura (`#deleteCommissionModal`). + + diff --git a/app/models/milestone.py b/app/models/milestone.py index f17b0d5..0e2c401 100644 --- a/app/models/milestone.py +++ b/app/models/milestone.py @@ -47,6 +47,7 @@ class AcademicMilestone(db.Model): title = db.Column(db.String(150), nullable=True) materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003) subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True) + commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), nullable=True) milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False) date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado @@ -56,8 +57,9 @@ class AcademicMilestone(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - # Relación con Asignatura + # Relación con Asignatura y Comisión subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic')) + commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan')) @property def tipo(self): @@ -72,6 +74,8 @@ class AcademicMilestone(db.Model): 'id': self.id, 'title': self.title or self.tipo, 'materiaCodigo': self.materia_codigo, + 'subject_id': self.subject_id, + 'commission_id': self.commission_id, 'tipo': self.tipo, 'milestone_type_id': self.milestone_type_id, 'tipo_info': self.type_obj.to_dict() if self.type_obj else None, diff --git a/app/models/subject.py b/app/models/subject.py index 95dab31..39cfa91 100644 --- a/app/models/subject.py +++ b/app/models/subject.py @@ -50,6 +50,7 @@ class Commission(db.Model): current_students = db.Column(db.Integer, default=0) schedule = db.Column(db.String(255)) shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche + virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle active = db.Column(db.Boolean, default=True) # Changed from is_active to active created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -87,6 +88,8 @@ class Commission(db.Model): 'max_students': self.max_students, 'current_students': self.current_students, 'schedule': self.schedule, + 'shift': self.shift, + 'virtual_link': self.virtual_link, 'active': self.active, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None, diff --git a/app/routes/admin.py b/app/routes/admin.py index fa8a276..27816a3 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -179,6 +179,36 @@ def user_toggle(id): return redirect(url_for('admin.users_list')) +@admin_bp.route('/users/delete/', methods=['POST']) +@admin_required +def user_delete(id): + """Delete a user account""" + if id == current_user.id: + flash(_('No puedes eliminar tu propia cuenta mientras estás en sesión activa.'), 'error') + return redirect(url_for('admin.users_list')) + + user = User.query.get_or_404(id) + + # Protect sole system administrator + if user.is_admin(): + admin_count = User.query.filter(User.role.ilike('%ADMIN%'), User.is_active == True).count() + if admin_count <= 1: + flash(_('No es posible eliminar al único administrador activo del sistema.'), 'error') + return redirect(url_for('admin.users_list')) + + # Unassign user from commissions as teacher + from app.models.subject import Commission + Commission.query.filter_by(teacher_id=id).update({'teacher_id': None}) + + name = user.name + email = user.email + db.session.delete(user) + db.session.commit() + + flash(_('El usuario "%(name)s" (%(email)s) ha sido eliminado del sistema.', name=name, email=email), 'success') + return redirect(url_for('admin.users_list')) + + # --------------------------------------------------------- # ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix) # --------------------------------------------------------- @@ -467,6 +497,27 @@ def milestone_type_toggle(id): return redirect(url_for('admin.milestone_types_list')) +@admin_bp.route('/milestone-types/delete/', methods=['POST']) +@permission_required('milestone_types', 'read_write') +def milestone_type_delete(id): + """Delete a milestone type if unused, or deactivate with notice""" + item = MilestoneType.query.get_or_404(id) + from app.models.milestone import AcademicMilestone + milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count() + + if milestone_count > 0: + item.is_active = False + db.session.commit() + flash(_('La tipificación "%(name)s" tiene %(count)d hitos registrados. Se ha desactivado para conservar el historial académico.', name=item.name, count=milestone_count), 'warning') + else: + name = item.name + db.session.delete(item) + db.session.commit() + flash(_('Tipificación "%(name)s" eliminada permanentemente.', name=name), 'success') + + return redirect(url_for('admin.milestone_types_list')) + + @admin_bp.route('/api/milestone-types') @login_required def api_milestone_types(): diff --git a/app/routes/schedule.py b/app/routes/schedule.py index 9b304ba..33d3d88 100644 --- a/app/routes/schedule.py +++ b/app/routes/schedule.py @@ -157,19 +157,242 @@ def calendar_view(): available_floors=available_floors ) -@schedule_bp.route('/commissions') -def commissions_view(): - subjects = Subject.query.all() +def user_can_manage_commission_milestones(user, commission=None): + """Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales""" + if user.is_admin(): + return True + role_name = (user.role_obj.name if user.role_obj else user.role or '').lower() + if role_name in ['admin', 'bedelia', 'bedelía', 'operador']: + return True + if role_name in ['docente', 'profesor']: + return True + return user.has_permission('schedule', 'read_write') - # Calculamos el total de capacidad sumando todas las comisiones de todas las materias + +@schedule_bp.route('/commissions') +@login_required +def commissions_view(): + """Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos""" + from app.models.subject import Commission, Subject + from app.models.classroom import Classroom + from app.models.user import User + from app.models.milestone import MilestoneType + + subjects = Subject.query.order_by(Subject.code.asc()).all() + classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all() + teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all() + milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all() + + # Totales para indicadores superiores total_capacity = sum(c.max_students for s in subjects for c in s.commissions) - # Calculamos el total de comisiones total_commissions = sum(len(s.commissions) for s in subjects) + can_manage_milestones = user_can_manage_commission_milestones(current_user) + role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower() + can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write') + return render_template('schedule/commissions.html', subjects=subjects, + classrooms=classrooms, + teachers=teachers, + milestone_types=milestone_types, total_capacity=total_capacity, - total_commissions=total_commissions) + total_commissions=total_commissions, + can_manage_milestones=can_manage_milestones, + can_edit_commissions=can_edit_commissions) + + +@schedule_bp.route('/commissions/add', methods=['POST']) +@login_required +def commission_add(): + """Crear una nueva comisión de cursada""" + role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower() + if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')): + flash(_('No tienes permisos para crear comisiones.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + from app.models.subject import Commission + subject_id = request.form.get('subject_id', type=int) + code = request.form.get('code', '').strip() + semester = request.form.get('semester', '1C').strip() + year = request.form.get('year', datetime.now().year, type=int) + teacher_id = request.form.get('teacher_id', type=int) or None + max_students = request.form.get('max_students', 40, type=int) + schedule = request.form.get('schedule', '').strip() + shift = request.form.get('shift', 'Tarde').strip() + virtual_link = request.form.get('virtual_link', '').strip() + + if not subject_id or not code: + flash(_('La asignatura y el código de comisión son obligatorios.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + # Verificar existencia duplicada + existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first() + if existing: + flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error') + return redirect(url_for('schedule.commissions_view')) + + new_comm = Commission( + subject_id=subject_id, + code=code, + semester=semester, + year=year, + teacher_id=teacher_id, + max_students=max_students, + schedule=schedule, + shift=shift, + virtual_link=virtual_link or None, + active=True + ) + db.session.add(new_comm) + db.session.commit() + + flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success') + return redirect(url_for('schedule.commissions_view')) + + +@schedule_bp.route('/commissions//edit', methods=['POST']) +@login_required +def commission_edit(id): + """Editar datos de una comisión""" + role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower() + if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')): + flash(_('No tienes permisos para modificar comisiones.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + from app.models.subject import Commission + commission = Commission.query.get_or_404(id) + + commission.code = request.form.get('code', commission.code).strip() + commission.semester = request.form.get('semester', commission.semester).strip() + commission.year = request.form.get('year', commission.year, type=int) + commission.teacher_id = request.form.get('teacher_id', type=int) or None + commission.max_students = request.form.get('max_students', commission.max_students, type=int) + commission.schedule = request.form.get('schedule', commission.schedule).strip() + commission.shift = request.form.get('shift', commission.shift).strip() + commission.virtual_link = request.form.get('virtual_link', '').strip() or None + commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False + + db.session.commit() + flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success') + return redirect(url_for('schedule.commissions_view')) + + +@schedule_bp.route('/commissions//delete', methods=['POST']) +@login_required +def commission_delete(id): + """Eliminar una comisión""" + role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower() + if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')): + flash(_('No tienes permisos para eliminar comisiones.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + from app.models.subject import Commission + commission = Commission.query.get_or_404(id) + full_code = commission.get_full_code() + + db.session.delete(commission) + db.session.commit() + + flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success') + return redirect(url_for('schedule.commissions_view')) + + +@schedule_bp.route('/commissions//virtual-link', methods=['POST']) +@login_required +def commission_virtual_link(id): + """Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía""" + from app.models.subject import Commission + commission = Commission.query.get_or_404(id) + + if not user_can_manage_commission_milestones(current_user, commission): + flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + link = request.form.get('virtual_link', '').strip() + commission.virtual_link = link or None + db.session.commit() + + flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success') + return redirect(url_for('schedule.commissions_view')) + + +@schedule_bp.route('/commissions//milestones/add', methods=['POST']) +@login_required +def commission_milestone_add(id): + """Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía""" + from app.models.subject import Commission + from app.models.milestone import AcademicMilestone, MilestoneType + commission = Commission.query.get_or_404(id) + + if not user_can_manage_commission_milestones(current_user, commission): + flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + milestone_type_id = request.form.get('milestone_type_id', type=int) + title = request.form.get('title', '').strip() + date_str = request.form.get('date', '').strip() + time_str = request.form.get('time', '18:00').strip() + classroom = request.form.get('classroom', '').strip() + link = request.form.get('link', '').strip() or commission.virtual_link or '' + notes = request.form.get('notes', '').strip() + + if not milestone_type_id or not date_str: + flash(_('Debes especificar el tipo de hito y la fecha.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + m_type = MilestoneType.query.get(milestone_type_id) + if not m_type: + flash(_('Tipo de hito inválido.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + try: + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") + except ValueError: + try: + dt = datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + flash(_('Formato de fecha inválido.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + milestone = AcademicMilestone( + title=title or m_type.name, + materia_codigo=commission.subject.code if commission.subject else 'ASIG', + subject_id=commission.subject_id, + commission_id=commission.id, + milestone_type_id=m_type.id, + date=dt, + classroom=classroom or 'Campus Virtual', + link=link or None, + notes=notes or None, + is_active=True + ) + db.session.add(milestone) + db.session.commit() + + flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success') + return redirect(url_for('schedule.commissions_view')) + + +@schedule_bp.route('/commissions//milestones//delete', methods=['POST']) +@login_required +def commission_milestone_delete(commission_id, milestone_id): + """Eliminar un hito académico de una comisión""" + from app.models.subject import Commission + from app.models.milestone import AcademicMilestone + commission = Commission.query.get_or_404(commission_id) + + if not user_can_manage_commission_milestones(current_user, commission): + flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error') + return redirect(url_for('schedule.commissions_view')) + + milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404() + title = milestone.title + db.session.delete(milestone) + db.session.commit() + + flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success') + return redirect(url_for('schedule.commissions_view')) @schedule_bp.route('/add', methods=['GET', 'POST']) @login_required diff --git a/app/templates/admin/milestone_types/list.html b/app/templates/admin/milestone_types/list.html index 41ee5cf..fd7f867 100644 --- a/app/templates/admin/milestone_types/list.html +++ b/app/templates/admin/milestone_types/list.html @@ -3,7 +3,7 @@ {% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %} {% block content %} -
+
@@ -16,7 +16,7 @@

{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}

-

+

{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}

@@ -43,9 +43,9 @@
- - + + @@ -56,19 +56,24 @@ {% for item in types %} - + @@ -94,20 +99,30 @@ {% endif %} {% else %} @@ -151,41 +166,39 @@
-
- -
+
- -
- - - - - - - - + +
+ + + + + + + +
- - + +
- +
- +
@@ -193,7 +206,7 @@ @@ -229,6 +242,21 @@
+ +
+ +
+ + + + + + + + +
+
+
@@ -257,6 +285,35 @@
+ + + {% endif %} {% endblock %} @@ -281,7 +338,7 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Color preset clicks + // Color preset clicks for Add Modal document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) { btn.addEventListener('click', function() { var color = this.getAttribute('data-color'); @@ -290,6 +347,15 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // Color preset clicks for Edit Modal + document.querySelectorAll('#presetColorsEdit button').forEach(function(btn) { + btn.addEventListener('click', function() { + var color = this.getAttribute('data-color'); + var input = document.getElementById('edit_color'); + if (input) input.value = color; + }); + }); + // Edit modal bindings var modalEditEl = document.getElementById('modalEditType'); var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null; @@ -317,6 +383,22 @@ document.addEventListener('DOMContentLoaded', function() { } }); }); + + // Delete modal bindings + var deleteModalEl = document.getElementById('deleteMilestoneTypeModal'); + var deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null; + var deleteNameEl = document.getElementById('deleteMilestoneTypeName'); + var deleteFormEl = document.getElementById('deleteMilestoneTypeForm'); + + document.querySelectorAll('.btn-delete-type').forEach(function(btn) { + btn.addEventListener('click', function() { + var name = this.getAttribute('data-name'); + var actionUrl = this.getAttribute('data-action-url'); + if (deleteNameEl) deleteNameEl.textContent = name; + if (deleteFormEl) deleteFormEl.action = actionUrl; + if (deleteModal) deleteModal.show(); + }); + }); }); {% endblock %} diff --git a/app/templates/admin/users/list.html b/app/templates/admin/users/list.html index 88870f1..e212beb 100644 --- a/app/templates/admin/users/list.html +++ b/app/templates/admin/users/list.html @@ -136,6 +136,14 @@ + {% endif %} @@ -179,4 +187,61 @@ {% endif %} + + + +{% endblock %} + +{% block extra_js %} + {% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 2209268..971c8bd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -90,6 +90,9 @@
  • {% trans %}Today's Schedule{% endtrans %}
  • +
  • + {% trans %}Comisiones y Cursadas{% endtrans %} +
  • {% if current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role|upper == 'ADMIN') %}
  • @@ -106,6 +109,7 @@
  • {% trans %}Color{% endtrans %}{% trans %}Nombre Visible{% endtrans %}{% trans %}Tipificación e Insignia{% endtrans %} {% trans %}Código / Slug{% endtrans %}{% trans %}Color Hex{% endtrans %} {% trans %}Descripción{% endtrans %} {% trans %}Orden{% endtrans %} {% trans %}Estado{% endtrans %}
    - - -
    {{ item.name }}
    - - {{ item.name }} - +
    + +
    + {{ item.name }} + + {{ item.name }} + +
    +
    {{ item.code }} + {{ item.color or '#0d6efd' }} + {{ item.description or '—' }} - {% if can_edit %} - - {% else %} - {% trans %}Solo lectura{% endtrans %} - {% endif %} +
    + {% if can_edit %} + + + {% else %} + {% trans %}Solo lectura{% endtrans %} + {% endif %} +
    - - - - - - - - - - - - {% for commission in subject.commissions %} - - + + + + {% endfor %} + +
    CommissionSemesterTeacherStudentsStatusActions
    - {{ commission.code }} +
    + + + + + + + + + + + + + + {% for commission in subject.commissions %} + + - - + + + - - + - - - {% endfor %} - -
    {% trans %}Comisión / Turno{% endtrans %}{% trans %}Ciclo Lectivo{% endtrans %}{% trans %}Docente{% endtrans %}{% trans %}Aula Virtual{% endtrans %}{% trans %}Hitos Académicos{% endtrans %}{% trans %}Cupo{% endtrans %}{% trans %}Acciones{% endtrans %}
    +
    {{ commission.code }}
    +
    + {% if commission.shift %} + + {{ commission.shift }} + + {% endif %} {% if commission.schedule %} -
    {{ commission.schedule }} + + {{ commission.schedule|truncate(15) }} + {% endif %} -
    - {{ commission.semester }} {{ commission.year }} - - {% if commission.teacher %} - {{ commission.teacher.first_name }} {{ commission.teacher.last_name }} + + + + {{ commission.semester }} {{ commission.year }} + + + {% if commission.teacher %} +
    {{ commission.teacher.name }}
    +
    {{ commission.teacher.email }}
    + {% else %} + + {% trans %}Sin asignar{% endtrans %} + + {% endif %} +
    + +
    + {% if commission.virtual_link %} + + {% trans %}Unirse{% endtrans %} + {% else %} - Not assigned + {% trans %}Sin enlace{% endtrans %} {% endif %} -
    -
    - {{ commission.current_students }}/{{ commission.max_students }} - {% set fill_percentage = (commission.current_students / commission.max_students * 100) %} -
    -
    -
    -
    -
    - {% if commission.active %} - Active + + {% if can_manage_milestones %} + + {% endif %} + + + +
    + {% for m in commission.academic_milestones %} + + + {{ m.title or m.tipo }} + ({{ m.date.strftime('%d/%m') if m.date else '' }}) + {% if can_manage_milestones %} +
    + +
    + {% endif %} +
    {% else %} - Inactive + {% trans %}Sin hitos{% endtrans %} + {% endfor %} + + {% if can_manage_milestones %} + {% endif %} -
    -
    - - -
    -
    -
    + +
    +
    {{ commission.current_students }}/{{ commission.max_students }}
    + {% set fill_pct = (commission.current_students / commission.max_students * 100) if commission.max_students > 0 else 0 %} +
    +
    +
    +
    +
    + + {% if can_edit_commissions %} + + + {% endif %} +
    +
    + {% else %} -
    - - No commissions registered for this subject -
    +
    + + {% trans %}No hay comisiones registradas para esta asignatura.{% endtrans %} +
    {% endif %} - {% if subject.description %} - - {% endif %} - - - {% else %} -
    -
    - -

    No Subjects Found

    -

    There are no active subjects in the system.

    - - View Classrooms -
    {% endfor %} - -