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 %}
+ {% set ns = namespace(virtual_count=0) %}
+ {% for s in subjects %}
+ {% for c in s.commissions %}
+ {% if c.virtual_link %}{% set ns.virtual_count = ns.virtual_count + 1 %}{% endif %}
+ {% endfor %}
+ {% endfor %}
+
{{ ns.virtual_count }}
+
{% trans %}Aulas Virtuales Config.{% endtrans %}
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {% trans %}Profesores, Bedelía y Administradores pueden actualizar el enlace de videollamada y agendar hitos de examen.{% endtrans %}
+