Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b50ede7e | ||
|
|
865538b727 | ||
|
|
1fda344248 |
+571
-564
File diff suppressed because it is too large
Load Diff
+17
-31
@@ -24,14 +24,15 @@ def create_app(config_class=Config):
|
||||
cors.init_app(app)
|
||||
|
||||
# Login manager configuration
|
||||
from flask_babel import lazy_gettext
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Por favor inicia sesión para acceder a esta página.'
|
||||
login_manager.login_message = lazy_gettext('Por favor inicia sesión para acceder a esta página.')
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
# Import models to ensure they are registered
|
||||
from app.models import user
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp, my_subjects_bp
|
||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp, api_optimizer_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
@@ -43,6 +44,7 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(my_subjects_bp)
|
||||
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
app.register_blueprint(api_auth_bp)
|
||||
@@ -55,46 +57,30 @@ def create_app(config_class=Config):
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
# 1. Parámetro explícito en la URL (?lang=es o ?lang=en)
|
||||
# 1. Si el usuario está autenticado y tiene idioma configurado explícitamente en su perfil, prevalece.
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if getattr(current_user, 'preferred_language', None) and current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
# El idioma por defecto siempre será español a menos que se configure a mano
|
||||
return app.config['BABEL_DEFAULT_LOCALE']
|
||||
|
||||
# 2. Parámetro explícito en la URL (?lang=es o ?lang=en) (para usuarios no autenticados o login)
|
||||
lang = request.args.get('lang')
|
||||
if lang and lang in app.config['LANGUAGES']:
|
||||
session['language'] = lang
|
||||
return lang
|
||||
|
||||
# 2. Si el usuario está autenticado y tiene idioma configurado en su perfil, prevalece en toda la sesión
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated and getattr(current_user, 'preferred_language', None):
|
||||
if current_user.preferred_language in app.config['LANGUAGES']:
|
||||
return current_user.preferred_language
|
||||
|
||||
# 3. En la pantalla de Login (usuario no autenticado):
|
||||
# El idioma predeterminado del login debe ser español o respetar el idioma del navegador.
|
||||
# No debe verse afectado por cookies o sesiones residuales de otros usuarios.
|
||||
is_login = (request.endpoint == 'auth.login' or request.path.rstrip('/') == '/login')
|
||||
if is_login:
|
||||
# Solo si el usuario cambió el idioma explícitamente en el selector del login
|
||||
if session.get('login_lang_explicit') in app.config['LANGUAGES']:
|
||||
return session['login_lang_explicit']
|
||||
|
||||
# Detectar según el navegador del usuario (soporta es-419, es-AR, es-ES, etc.)
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
return primary
|
||||
|
||||
# Idioma predeterminado por defecto del login: Español
|
||||
return 'es'
|
||||
|
||||
# 4. Para otras rutas: verificar si hay idioma en sesión (ej. selector manual)
|
||||
# 3. Si ya hay un idioma guardado en la sesión
|
||||
if 'language' in session and session['language'] in app.config['LANGUAGES']:
|
||||
return session['language']
|
||||
|
||||
# 5. Detección automática del navegador por defecto (soporta subtags regionales)
|
||||
|
||||
# 4. Primera vez que se accede a la web (sin sesión): respetar el idioma del navegador
|
||||
if request.accept_languages:
|
||||
for accepted_lang, _quality in request.accept_languages:
|
||||
primary = accepted_lang.split('-')[0].lower()
|
||||
if primary in app.config['LANGUAGES']:
|
||||
session['language'] = primary # Se guarda para la sesión actual
|
||||
return primary
|
||||
|
||||
# Fallback del sistema: Español
|
||||
|
||||
@@ -9,6 +9,7 @@ from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
from .enrollment import StudentEnrollment
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -29,5 +30,6 @@ __all__ = [
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog'
|
||||
'AuditLog',
|
||||
'StudentEnrollment'
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'commission_id': self.commission_id,
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -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.'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
from .my_subjects import my_subjects_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp']
|
||||
@@ -1165,3 +1165,255 @@ def audit_logs():
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 5. GESTION DE COMISIONES (BEDELIA / ADMIN)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/commissions')
|
||||
@login_required
|
||||
def commissions_list():
|
||||
"""List all commissions with filters - accessible by Bedelia and Admin"""
|
||||
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
|
||||
from app.models.user import User
|
||||
|
||||
search = request.args.get('search', '').strip()
|
||||
subject_filter = request.args.get('subject_id', type=int)
|
||||
teacher_filter = request.args.get('teacher_id', type=int)
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
active_filter = request.args.get('active', 'true').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
query = Commission.query.join(Subject, Commission.subject_id == Subject.id)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Commission.code.ilike(f'%{search}%'),
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
)
|
||||
)
|
||||
if subject_filter:
|
||||
query = query.filter(Commission.subject_id == subject_filter)
|
||||
if teacher_filter:
|
||||
query = query.filter(Commission.teacher_id == teacher_filter)
|
||||
if shift_filter:
|
||||
query = query.filter(Commission.shift.ilike(f'%{shift_filter}%'))
|
||||
if active_filter in ['true', 'false']:
|
||||
query = query.filter(Commission.active == (active_filter == 'true'))
|
||||
|
||||
pagination = query.order_by(Subject.name.asc(), Commission.code.asc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
teachers = User.query.filter(
|
||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'), User.role.ilike('%ADMIN%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
can_edit = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/list.html',
|
||||
commissions=pagination.items,
|
||||
pagination=pagination,
|
||||
subjects=subjects,
|
||||
teachers=teachers,
|
||||
search=search,
|
||||
subject_filter=subject_filter,
|
||||
teacher_filter=teacher_filter,
|
||||
shift_filter=shift_filter,
|
||||
active_filter=active_filter,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>')
|
||||
@login_required
|
||||
def commission_detail(id):
|
||||
"""Detail view of a commission: enrolled students and teacher assignment"""
|
||||
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
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
teachers = User.query.filter(
|
||||
or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
students = User.query.filter(
|
||||
or_(User.role.ilike('%ALUMNO%'), User.role.ilike('%alumn%'), User.role.ilike('%estud%'))
|
||||
).filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
enrolled_ids = {e.student_id for e in enrollments}
|
||||
can_edit = (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'admin/commissions/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
teachers=teachers,
|
||||
students=students,
|
||||
enrolled_ids=enrolled_ids,
|
||||
can_edit=can_edit
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||
@login_required
|
||||
def commission_assign_teacher(id):
|
||||
"""Assign or change the teacher of a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
teacher_id = request.form.get('teacher_id', type=int)
|
||||
|
||||
if teacher_id:
|
||||
teacher = User.query.get(teacher_id)
|
||||
if not teacher or not teacher.is_active:
|
||||
flash(_('El docente seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
commission.teacher_id = teacher_id
|
||||
db.session.commit()
|
||||
flash(_('Docente asignado exitosamente a la comision.'), 'success')
|
||||
else:
|
||||
commission.teacher_id = None
|
||||
db.session.commit()
|
||||
flash(_('Se removio la asignacion de docente.'), 'warning')
|
||||
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/enroll-student', methods=['POST'])
|
||||
@login_required
|
||||
def commission_enroll_student(id):
|
||||
"""Enroll a student into a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.user import User
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
student_id = request.form.get('student_id', type=int)
|
||||
notes = request.form.get('notes', '').strip()
|
||||
|
||||
if not student_id:
|
||||
flash(_('Debes seleccionar un alumno.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
student = User.query.get(student_id)
|
||||
if not student or not student.is_active:
|
||||
flash(_('El alumno seleccionado no existe o esta inactivo.'), 'error')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
existing = StudentEnrollment.query.filter_by(student_id=student_id, commission_id=id).first()
|
||||
if existing:
|
||||
flash(_('El alumno ya esta inscripto en esta comision.'), 'warning')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
commission_id=id,
|
||||
status='activo',
|
||||
notes=notes or None
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno inscripto exitosamente en la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/unenroll-student/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_unenroll_student(id, student_id):
|
||||
"""Remove a student from a commission"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
commission = Commission.query.get_or_404(id)
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
db.session.delete(enrollment)
|
||||
if commission.current_students and commission.current_students > 0:
|
||||
commission.current_students -= 1
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Alumno dado de baja de la comision.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
|
||||
@admin_bp.route('/commissions/<int:id>/update-enrollment/<int:student_id>', methods=['POST'])
|
||||
@login_required
|
||||
def commission_update_enrollment(id, student_id):
|
||||
"""Update enrollment status for a student"""
|
||||
if not (
|
||||
current_user.has_permission('academic', 'read_write') or
|
||||
current_user.is_admin() or
|
||||
current_user.is_bedelia()
|
||||
):
|
||||
abort(403)
|
||||
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id, commission_id=id
|
||||
).first_or_404()
|
||||
|
||||
new_status = request.form.get('status', 'activo')
|
||||
if new_status not in ['activo', 'retirado', 'condicional']:
|
||||
new_status = 'activo'
|
||||
|
||||
enrollment.status = new_status
|
||||
enrollment.notes = request.form.get('notes', enrollment.notes or '')
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Estado de inscripcion actualizado.'), 'success')
|
||||
return redirect(url_for('admin.commission_detail', id=id))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, render_template, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.subject import Commission
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
my_subjects_bp = Blueprint('my_subjects', __name__, url_prefix='/mis-materias')
|
||||
|
||||
|
||||
@my_subjects_bp.route('/')
|
||||
@login_required
|
||||
def my_subjects_list():
|
||||
"""Show the commissions assigned to the current user (teacher or student)."""
|
||||
role = current_user.get_institutional_role()
|
||||
|
||||
commissions = []
|
||||
|
||||
if role in ['admin', 'bedelia']:
|
||||
# Admin/Bedelia: see all active commissions
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'all'
|
||||
|
||||
elif role == 'docente':
|
||||
# Teacher: only commissions where they are assigned as teacher
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter_by(teacher_id=current_user.id, active=True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'teacher'
|
||||
|
||||
elif role == 'alumno':
|
||||
# Student: only commissions where they are enrolled
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(student_id=current_user.id, status='activo')
|
||||
.all()
|
||||
)
|
||||
commission_ids = [e.commission_id for e in enrollments]
|
||||
commissions = (
|
||||
Commission.query
|
||||
.filter(Commission.id.in_(commission_ids), Commission.active == True)
|
||||
.order_by(Commission.year.desc(), Commission.semester.asc(), Commission.code.asc())
|
||||
.all()
|
||||
)
|
||||
view_mode = 'student'
|
||||
else:
|
||||
commissions = []
|
||||
view_mode = 'none'
|
||||
|
||||
return render_template(
|
||||
'my_subjects/list.html',
|
||||
commissions=commissions,
|
||||
view_mode=view_mode,
|
||||
role=role
|
||||
)
|
||||
|
||||
|
||||
@my_subjects_bp.route('/<int:commission_id>')
|
||||
@login_required
|
||||
def my_subject_detail(commission_id):
|
||||
"""Detail view of a single commission for the current user."""
|
||||
role = current_user.get_institutional_role()
|
||||
commission = Commission.query.get_or_404(commission_id)
|
||||
|
||||
# Access control: make sure the user is allowed to see this commission
|
||||
if role in ['admin', 'bedelia']:
|
||||
pass # full access
|
||||
elif role == 'docente':
|
||||
if commission.teacher_id != current_user.id:
|
||||
abort(403)
|
||||
elif role == 'alumno':
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=current_user.id,
|
||||
commission_id=commission_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
abort(403)
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
# Get enrolled students (visible to teachers and admin/bedelia)
|
||||
enrollments = []
|
||||
if role in ['admin', 'bedelia', 'docente']:
|
||||
enrollments = (
|
||||
StudentEnrollment.query
|
||||
.filter_by(commission_id=commission_id)
|
||||
.order_by(StudentEnrollment.enrolled_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# Try to get classroom info from reservations linked to this commission
|
||||
from app.models.reservation import Reservation
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
|
||||
# Recent or upcoming reservation linked to this commission
|
||||
recent_reservation = (
|
||||
Reservation.query
|
||||
.filter(
|
||||
Reservation.commission_id == commission_id,
|
||||
Reservation.start_time >= datetime.utcnow()
|
||||
)
|
||||
.order_by(Reservation.start_time.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
classroom = None
|
||||
if recent_reservation and recent_reservation.classroom_id:
|
||||
classroom = Classroom.query.get(recent_reservation.classroom_id)
|
||||
|
||||
return render_template(
|
||||
'my_subjects/detail.html',
|
||||
commission=commission,
|
||||
enrollments=enrollments,
|
||||
classroom=classroom,
|
||||
recent_reservation=recent_reservation,
|
||||
role=role
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Comisión {{ commission.get_full_code() }} — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Inicio</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.commissions_list') }}">Comisiones</a></li>
|
||||
<li class="breadcrumb-item active">{{ commission.get_full_code() }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left: Commission Info + Teacher Assignment -->
|
||||
<div class="col-12 col-lg-4">
|
||||
|
||||
<!-- Commission Card -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header border-0 bg-primary text-white rounded-top py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-diagram-3 fs-4"></i>
|
||||
<div>
|
||||
<div class="fw-bold font-monospace">{{ commission.get_full_code() }}</div>
|
||||
<small class="opacity-75">{{ commission.semester }} {{ commission.year }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="fw-bold mb-1">{{ commission.subject.name }}</h5>
|
||||
<p class="text-muted small mb-3">{{ commission.subject.code }}
|
||||
{% if commission.subject.career_obj %}— {{ commission.subject.career_obj.name }}{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% if commission.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle text-{{ shift_colors.get(commission.shift, 'secondary') }} border border-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{{ commission.shift }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge {% if commission.active %}bg-success-subtle text-success{% else %}bg-secondary-subtle text-secondary{% endif %}">
|
||||
{{ 'Activa' if commission.active else 'Inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Cupo máximo</span>
|
||||
<strong>{{ commission.max_students }}</strong>
|
||||
</li>
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Inscriptos</span>
|
||||
<strong class="{% if (commission.current_students or 0) >= commission.max_students %}text-danger{% else %}text-success{% endif %}">
|
||||
{{ commission.current_students or 0 }}
|
||||
</strong>
|
||||
</li>
|
||||
{% if commission.schedule %}
|
||||
<li class="d-flex justify-content-between py-1 border-bottom">
|
||||
<span class="text-muted">Horario</span>
|
||||
<span>{{ commission.schedule }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if commission.virtual_link %}
|
||||
<li class="py-1">
|
||||
<a href="{{ commission.virtual_link }}" target="_blank" class="btn btn-sm btn-outline-info w-100">
|
||||
<i class="bi bi-camera-video me-1"></i>Acceso Virtual
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teacher Assignment -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-person-video3 text-info"></i>
|
||||
<span class="fw-semibold">Docente Asignado</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if commission.teacher %}
|
||||
<div class="d-flex align-items-center gap-3 mb-3 p-2 rounded bg-info-subtle border border-info-subtle">
|
||||
<div class="avatar-sm bg-info rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px;flex-shrink:0;">
|
||||
<i class="bi bi-person-fill text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold small">{{ commission.teacher.name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Sin docente asignado
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="POST" action="{{ url_for('admin.commission_assign_teacher', id=commission.id) }}" id="assign-teacher-form">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Cambiar / Asignar Docente</label>
|
||||
<select name="teacher_id" class="form-select form-select-sm" id="teacher-select">
|
||||
<option value="">— Sin asignar —</option>
|
||||
{% for teacher in teachers %}
|
||||
<option value="{{ teacher.id }}" {% if commission.teacher_id == teacher.id %}selected{% endif %}>
|
||||
{{ teacher.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-info btn-sm w-100" id="assign-teacher-btn">
|
||||
<i class="bi bi-person-check me-1"></i>Guardar Docente
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right: Students -->
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
<!-- Enrolled Students -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-mortarboard text-success"></i>
|
||||
<span class="fw-semibold">Alumnos Inscriptos</span>
|
||||
<span class="badge bg-success-subtle text-success">{{ enrollments | length }}</span>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="collapse" data-bs-target="#enroll-form" id="add-student-btn">
|
||||
<i class="bi bi-person-plus me-1"></i>Inscribir Alumno
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Enroll Student Form (collapsible) -->
|
||||
{% if can_edit %}
|
||||
<div class="collapse" id="enroll-form">
|
||||
<div class="card-body border-top bg-success-subtle py-3">
|
||||
<form method="POST" action="{{ url_for('admin.commission_enroll_student', id=commission.id) }}" id="enroll-student-form">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small fw-semibold mb-1">Seleccionar Alumno</label>
|
||||
<select name="student_id" class="form-select form-select-sm" required id="student-select">
|
||||
<option value="">— Elegir alumno —</option>
|
||||
{% for student in students %}
|
||||
{% if student.id not in enrolled_ids %}
|
||||
<option value="{{ student.id }}">{{ student.name }} — {{ student.email }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small fw-semibold mb-1">Observaciones (opcional)</label>
|
||||
<input type="text" name="notes" class="form-control form-control-sm" placeholder="Notas…" id="enroll-notes">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button type="submit" class="btn btn-success btn-sm w-100" id="enroll-submit-btn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Inscribir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body p-0">
|
||||
{% if enrollments %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="enrollments-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">#</th>
|
||||
<th>Alumno</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-center">Fecha Inscripción</th>
|
||||
<th class="text-center">Notas</th>
|
||||
{% if can_edit %}<th class="text-end pe-3">Acciones</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enrollments %}
|
||||
<tr id="enrollment-row-{{ e.student_id }}">
|
||||
<td class="ps-3 text-muted small">{{ loop.index }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">{{ e.student.name if e.student else '—' }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ e.student.email if e.student else '' }}</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% set st_colors = {'activo': 'success', 'retirado': 'danger', 'condicional': 'warning'} %}
|
||||
<span class="badge bg-{{ st_colors.get(e.status, 'secondary') }}-subtle text-{{ st_colors.get(e.status, 'secondary') }} border border-{{ st_colors.get(e.status, 'secondary') }}-subtle">
|
||||
{{ e.status | capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center small text-muted">
|
||||
{{ e.enrolled_at.strftime('%d/%m/%Y') if e.enrolled_at else '—' }}
|
||||
</td>
|
||||
<td class="text-center small text-muted">{{ e.notes or '—' }}</td>
|
||||
{% if can_edit %}
|
||||
<td class="text-end pe-3">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<!-- Quick status update -->
|
||||
<form method="POST" action="{{ url_for('admin.commission_update_enrollment', id=commission.id, student_id=e.student_id) }}" class="d-inline">
|
||||
<select name="status" class="form-select form-select-sm d-inline-block w-auto"
|
||||
onchange="this.form.submit()" title="Cambiar estado"
|
||||
id="status-select-{{ e.student_id }}">
|
||||
<option value="activo" {% if e.status == 'activo' %}selected{% endif %}>Activo</option>
|
||||
<option value="condicional" {% if e.status == 'condicional' %}selected{% endif %}>Condicional</option>
|
||||
<option value="retirado" {% if e.status == 'retirado' %}selected{% endif %}>Retirado</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Remove button -->
|
||||
<form method="POST" action="{{ url_for('admin.commission_unenroll_student', id=commission.id, student_id=e.student_id) }}"
|
||||
onsubmit="return confirm('¿Dar de baja a {{ e.student.name if e.student else 'este alumno' }} de la comisión?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Dar de baja"
|
||||
id="unenroll-btn-{{ e.student_id }}">
|
||||
<i class="bi bi-person-dash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people display-5 text-muted opacity-40"></i>
|
||||
<p class="text-muted mt-3 small">No hay alumnos inscriptos en esta comisión.</p>
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#enroll-form">
|
||||
<i class="bi bi-person-plus me-1"></i>Inscribir primer alumno
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capacity Bar -->
|
||||
{% set enrolled_count = enrollments | length %}
|
||||
{% set max_students = commission.max_students or 1 %}
|
||||
{% set cap_pct = [((enrolled_count / max_students) * 100) | round | int, 100] | min %}
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex justify-content-between small fw-semibold mb-1">
|
||||
<span><i class="bi bi-bar-chart-fill me-1 text-primary"></i>Ocupación del cupo</span>
|
||||
<span>{{ enrolled_count }} / {{ max_students }} ({{ cap_pct }}%)</span>
|
||||
</div>
|
||||
<div class="progress" style="height:10px;">
|
||||
<div class="progress-bar {% if cap_pct >= 90 %}bg-danger{% elif cap_pct >= 70 %}bg-warning{% else %}bg-success{% endif %}"
|
||||
style="width:{{ cap_pct }}%;" role="progressbar"
|
||||
aria-valuenow="{{ cap_pct }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,209 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Gestión de Comisiones — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 class="h3 fw-bold mb-1">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>Gestión de Comisiones
|
||||
</h1>
|
||||
<p class="text-muted mb-0 small">Asignación de docentes e inscripción de alumnos por comisión</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.subjects_list') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-book me-1"></i>Ver Asignaturas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end" id="filter-form">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Buscar</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control" placeholder="Código o asignatura…"
|
||||
value="{{ search or '' }}" id="search-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small fw-semibold mb-1">Asignatura</label>
|
||||
<select name="subject_id" class="form-select form-select-sm" id="subject-filter">
|
||||
<option value="">Todas</option>
|
||||
{% for subj in subjects %}
|
||||
<option value="{{ subj.id }}" {% if subject_filter == subj.id %}selected{% endif %}>
|
||||
{{ subj.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-semibold mb-1">Turno</label>
|
||||
<select name="shift" class="form-select form-select-sm" id="shift-filter">
|
||||
<option value="">Todos</option>
|
||||
{% for shift in ['Mañana', 'Tarde', 'Vespertino', 'Noche'] %}
|
||||
<option value="{{ shift }}" {% if shift_filter == shift %}selected{% endif %}>{{ shift }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small fw-semibold mb-1">Estado</label>
|
||||
<select name="active" class="form-select form-select-sm" id="active-filter">
|
||||
<option value="true" {% if active_filter == 'true' %}selected{% endif %}>Activas</option>
|
||||
<option value="false" {% if active_filter == 'false' %}selected{% endif %}>Inactivas</option>
|
||||
<option value="" {% if not active_filter %}selected{% endif %}>Todas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm flex-fill" id="filter-btn">
|
||||
<i class="bi bi-funnel me-1"></i>Filtrar
|
||||
</button>
|
||||
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-x"></i>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commissions Table -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-transparent border-bottom d-flex align-items-center justify-content-between py-2">
|
||||
<span class="fw-semibold small">
|
||||
<i class="bi bi-list-ul me-1 text-primary"></i>
|
||||
{{ pagination.total }} comisión(es) encontrada(s)
|
||||
</span>
|
||||
<span class="badge bg-primary-subtle text-primary">Página {{ pagination.page }} / {{ pagination.pages or 1 }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if commissions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="commissions-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Comisión</th>
|
||||
<th>Asignatura</th>
|
||||
<th>Docente</th>
|
||||
<th class="text-center">Turno</th>
|
||||
<th class="text-center">Alumnos</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-end pe-3">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for comm in commissions %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<span class="badge bg-primary-subtle text-primary fw-semibold font-monospace">
|
||||
{{ comm.get_full_code() }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ comm.subject.name }}</div>
|
||||
<small class="text-muted">{{ comm.subject.code }} — {{ comm.semester }} {{ comm.year }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if comm.teacher %}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar-xs bg-info-subtle rounded-circle d-flex align-items-center justify-content-center" style="width:28px;height:28px;">
|
||||
<i class="bi bi-person-video3 small text-info"></i>
|
||||
</div>
|
||||
<span class="small">{{ comm.teacher.name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small fst-italic"><i class="bi bi-exclamation-circle me-1 text-warning"></i>Sin asignar</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if comm.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle text-{{ shift_colors.get(comm.shift, 'secondary') }} border border-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle">
|
||||
{{ comm.shift }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="fw-semibold">{{ comm.current_students or 0 }}</span>
|
||||
<span class="text-muted small">/ {{ comm.max_students }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if comm.active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">Activa</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Inactiva</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="{{ url_for('admin.commission_detail', id=comm.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Ver y gestionar comisión">
|
||||
<i class="bi bi-gear me-1"></i>Gestionar
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pagination.prev_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for pg in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if pg %}
|
||||
<li class="page-item {% if pg == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pg, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">{{ pg }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.commissions_list', page=pagination.next_num, search=search, subject_id=subject_filter, shift=shift_filter, active=active_filter) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-diagram-3 display-4 text-muted opacity-50"></i>
|
||||
<p class="mt-3 text-muted">No se encontraron comisiones con los filtros aplicados.</p>
|
||||
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Limpiar filtros
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -90,6 +90,7 @@
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Comisiones{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -147,6 +148,7 @@
|
||||
<ul class="dropdown-menu shadow-sm">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.careers_list') }}"><i class="bi bi-mortarboard me-2 text-primary"></i>{% trans %}Carreras{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.subjects_list') }}"><i class="bi bi-book me-2 text-info"></i>{% trans %}Asignaturas{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.commissions_list') }}"><i class="bi bi-diagram-3 me-2 text-success"></i>{% trans %}Gestión de Comisiones{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.academic_terms_list') }}"><i class="bi bi-calendar-range me-2 text-warning"></i>{% trans %}Ciclo Lectivo{% endtrans %}</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-secondary"></i>{% trans %}Tipificaciones de Hitos{% endtrans %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@@ -171,6 +173,11 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('schedule.commissions_view') }}"><i class="bi bi-diagram-3 me-2 text-info"></i>{% trans %}Mis Comisiones & Cursadas{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
|
||||
<i class="bi bi-mortarboard me-1 text-info"></i>{% trans %}Mis Materias{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-building me-1"></i>{% trans %}Campus & Horarios{% endtrans %}
|
||||
@@ -191,6 +198,11 @@
|
||||
<i class="bi bi-house-door me-1"></i>{% trans %}Mi Portal{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link fw-semibold" href="{{ url_for('my_subjects.my_subjects_list') }}">
|
||||
<i class="bi bi-mortarboard me-1 text-success"></i>{% trans %}Mis Materias{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('schedule.today_schedule') }}">
|
||||
<i class="bi bi-geo-alt me-1"></i>{% trans %}Cartelera de Hoy{% endtrans %}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ commission.subject.name }} — {{ commission.get_full_code() }} — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">Inicio</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('my_subjects.my_subjects_list') }}">Mis Materias</a></li>
|
||||
<li class="breadcrumb-item active">{{ commission.get_full_code() }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column: Commission Info -->
|
||||
<div class="col-12 col-lg-4">
|
||||
|
||||
<!-- Header Card -->
|
||||
{% set shift_gradient = {
|
||||
'Mañana': 'linear-gradient(135deg, #f59e0b 0%, #fcd34d 100%)',
|
||||
'Tarde': 'linear-gradient(135deg, #3b82f6 0%, #93c5fd 100%)',
|
||||
'Vespertino':'linear-gradient(135deg, #8b5cf6 0%, #c4b5fd 100%)',
|
||||
'Noche': 'linear-gradient(135deg, #1e293b 0%, #475569 100%)'
|
||||
} %}
|
||||
<div class="card border-0 shadow-sm mb-4 overflow-hidden">
|
||||
<div style="background:{{ shift_gradient.get(commission.shift or '', 'linear-gradient(135deg, #6366f1, #a5b4fc)') }}; padding:1.25rem 1.25rem .5rem; color:white;">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="bi bi-book-fill fs-5 opacity-75"></i>
|
||||
<span class="fw-bold font-monospace">{{ commission.get_full_code() }}</span>
|
||||
</div>
|
||||
<h4 class="fw-bold mb-0">{{ commission.subject.name }}</h4>
|
||||
{% if commission.subject.career_obj %}
|
||||
<small class="opacity-75">{{ commission.subject.career_obj.name }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body pt-3">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% if commission.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle text-{{ shift_colors.get(commission.shift, 'secondary') }} border border-{{ shift_colors.get(commission.shift, 'secondary') }}-subtle">
|
||||
<i class="bi bi-sun me-1"></i>{{ commission.shift }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
<i class="bi bi-calendar3 me-1"></i>{{ commission.semester }} {{ commission.year }}
|
||||
</span>
|
||||
{% if commission.active %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">Activa</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary">Inactiva</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Details list -->
|
||||
<ul class="list-unstyled small">
|
||||
{% if commission.schedule %}
|
||||
<li class="d-flex align-items-start gap-2 py-2 border-bottom">
|
||||
<i class="bi bi-clock-history text-primary mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size:.72rem;">Horario</div>
|
||||
<div class="fw-semibold">{{ commission.schedule }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="d-flex align-items-start gap-2 py-2 border-bottom">
|
||||
<i class="bi bi-person-video3 text-info mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size:.72rem;">Docente</div>
|
||||
{% if commission.teacher %}
|
||||
<div class="fw-semibold">{{ commission.teacher.name }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ commission.teacher.email }}</div>
|
||||
{% else %}
|
||||
<div class="text-warning fst-italic">Sin asignar</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% if role in ['docente', 'admin', 'bedelia'] %}
|
||||
<li class="d-flex align-items-start gap-2 py-2 border-bottom">
|
||||
<i class="bi bi-people text-success mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size:.72rem;">Alumnos</div>
|
||||
<div class="fw-semibold">{{ enrollments | length }} / {{ commission.max_students }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if commission.subject.code %}
|
||||
<li class="d-flex align-items-start gap-2 py-2 border-bottom">
|
||||
<i class="bi bi-upc text-secondary mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size:.72rem;">Código asignatura</div>
|
||||
<div class="fw-semibold font-monospace">{{ commission.subject.code }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if commission.subject.credits %}
|
||||
<li class="d-flex align-items-start gap-2 py-2">
|
||||
<i class="bi bi-star text-warning mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size:.72rem;">Créditos</div>
|
||||
<div class="fw-semibold">{{ commission.subject.credits }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% if commission.virtual_link %}
|
||||
<a href="{{ commission.virtual_link }}" target="_blank"
|
||||
class="btn btn-outline-info btn-sm w-100 mt-2" id="virtual-link-detail">
|
||||
<i class="bi bi-camera-video me-1"></i>Acceso Virtual (Zoom / Meet / Teams)
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if role in ['admin', 'bedelia'] %}
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('admin.commission_detail', id=commission.id) }}"
|
||||
class="btn btn-warning btn-sm w-100" id="manage-commission-btn">
|
||||
<i class="bi bi-gear me-1"></i>Gestionar Comisión
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classroom Info -->
|
||||
{% if classroom %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 bg-transparent py-2">
|
||||
<i class="bi bi-door-open text-primary me-2"></i>
|
||||
<span class="fw-semibold small">Aula Asignada</span>
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<div class="fw-bold">{{ classroom.room_number }}</div>
|
||||
<div class="text-muted small">{{ classroom.building or '—' }}{% if classroom.floor %}, Piso {{ classroom.floor }}{% endif %}</div>
|
||||
{% if recent_reservation %}
|
||||
<div class="mt-2 small text-muted">
|
||||
<i class="bi bi-calendar-event me-1"></i>
|
||||
Próxima clase: {{ recent_reservation.start_time.strftime('%d/%m/%Y %H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2 mt-2 flex-wrap">
|
||||
{% if classroom.capacity %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
<i class="bi bi-people me-1"></i>Capacidad: {{ classroom.capacity }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if classroom.has_projector %}
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle"><i class="bi bi-projector me-1"></i>Proyector</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Students list (for docente/admin/bedelia) or My info (for alumno) -->
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
{% if role in ['docente', 'admin', 'bedelia'] and enrollments %}
|
||||
<!-- Students Table -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 bg-transparent py-2 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-mortarboard text-success"></i>
|
||||
<span class="fw-semibold">Alumnos Inscriptos</span>
|
||||
<span class="badge bg-success-subtle text-success">{{ enrollments | length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle" id="students-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">#</th>
|
||||
<th>Alumno</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th class="text-center">Desde</th>
|
||||
<th>Notas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in enrollments %}
|
||||
<tr>
|
||||
<td class="ps-3 text-muted small">{{ loop.index }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">{{ e.student.name if e.student else '—' }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ e.student.email if e.student else '' }}</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% set st_colors = {'activo': 'success', 'retirado': 'danger', 'condicional': 'warning'} %}
|
||||
<span class="badge bg-{{ st_colors.get(e.status, 'secondary') }}-subtle text-{{ st_colors.get(e.status, 'secondary') }} border border-{{ st_colors.get(e.status, 'secondary') }}-subtle">
|
||||
{{ e.status | capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center small text-muted">
|
||||
{{ e.enrolled_at.strftime('%d/%m/%Y') if e.enrolled_at else '—' }}
|
||||
</td>
|
||||
<td class="small text-muted">{{ e.notes or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif role in ['docente', 'admin', 'bedelia'] and not enrollments %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-people display-4 text-muted opacity-40"></i>
|
||||
<p class="text-muted mt-3">No hay alumnos inscriptos en esta comisión todavía.</p>
|
||||
{% if role in ['admin', 'bedelia'] %}
|
||||
<a href="{{ url_for('admin.commission_detail', id=commission.id) }}" class="btn btn-outline-success btn-sm">
|
||||
<i class="bi bi-person-plus me-1"></i>Inscribir Alumnos
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif role == 'alumno' %}
|
||||
<!-- Alumno: show their own enrollment status -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 bg-transparent py-2">
|
||||
<i class="bi bi-person-check text-success me-2"></i>
|
||||
<span class="fw-semibold small">Tu inscripción</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3 p-3 rounded bg-success-subtle border border-success-subtle">
|
||||
<div class="rounded-circle bg-success d-flex align-items-center justify-content-center"
|
||||
style="width:48px;height:48px;flex-shrink:0;">
|
||||
<i class="bi bi-check-lg text-white fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">Inscripto en esta comisión</div>
|
||||
<div class="text-muted small">{{ commission.subject.name }} — {{ commission.get_full_code() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if commission.subject.description %}
|
||||
<div class="mt-4">
|
||||
<h6 class="fw-semibold mb-2">Sobre la asignatura</h6>
|
||||
<p class="text-muted small">{{ commission.subject.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,189 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Mis Materias — Edu-Space{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
{% if role == 'docente' %}
|
||||
<h1 class="h3 fw-bold mb-1"><i class="bi bi-person-video3 me-2 text-info"></i>Mis Materias</h1>
|
||||
<p class="text-muted mb-0 small">Comisiones que tenés asignadas como docente este ciclo</p>
|
||||
{% elif role == 'alumno' %}
|
||||
<h1 class="h3 fw-bold mb-1"><i class="bi bi-mortarboard me-2 text-success"></i>Mis Materias</h1>
|
||||
<p class="text-muted mb-0 small">Comisiones en las que estás inscripto</p>
|
||||
{% else %}
|
||||
<h1 class="h3 fw-bold mb-1"><i class="bi bi-diagram-3 me-2 text-primary"></i>Todas las Comisiones</h1>
|
||||
<p class="text-muted mb-0 small">Vista global de comisiones activas</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if role in ['admin', 'bedelia'] %}
|
||||
<a href="{{ url_for('admin.commissions_list') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-gear me-1"></i>Gestionar Comisiones
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if commissions %}
|
||||
<!-- Stats summary -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="display-6 fw-bold text-primary">{{ commissions | length }}</div>
|
||||
<div class="text-muted small">Comisiones</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if role == 'docente' %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="display-6 fw-bold text-info">
|
||||
{{ commissions | sum(attribute='current_students') or 0 }}
|
||||
</div>
|
||||
<div class="text-muted small">Alumnos totales</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="display-6 fw-bold text-success">
|
||||
{{ commissions | selectattr('active') | list | length }}
|
||||
</div>
|
||||
<div class="text-muted small">Activas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commission Cards Grid -->
|
||||
<div class="row g-3" id="commissions-grid">
|
||||
{% for comm in commissions %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card border-0 shadow-sm h-100 commission-card" style="transition:transform .15s,box-shadow .15s;">
|
||||
<!-- Color top band based on shift -->
|
||||
{% set shift_gradient = {
|
||||
'Mañana': 'linear-gradient(135deg, #f59e0b, #fcd34d)',
|
||||
'Tarde': 'linear-gradient(135deg, #3b82f6, #93c5fd)',
|
||||
'Vespertino':'linear-gradient(135deg, #8b5cf6, #c4b5fd)',
|
||||
'Noche': 'linear-gradient(135deg, #1e293b, #475569)'
|
||||
} %}
|
||||
<div style="height:5px; background:{{ shift_gradient.get(comm.shift or '', 'linear-gradient(135deg, #6366f1, #a5b4fc)') }}; border-radius:.5rem .5rem 0 0;"></div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Subject + commission code -->
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0">{{ comm.subject.name }}</h6>
|
||||
<span class="badge bg-primary-subtle text-primary font-monospace small mt-1">
|
||||
{{ comm.get_full_code() }}
|
||||
</span>
|
||||
</div>
|
||||
{% if not comm.active %}
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2">Inactiva</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% if comm.shift %}
|
||||
{% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %}
|
||||
<span class="badge bg-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle text-{{ shift_colors.get(comm.shift, 'secondary') }} border border-{{ shift_colors.get(comm.shift, 'secondary') }}-subtle">
|
||||
<i class="bi bi-clock me-1"></i>{{ comm.shift }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-secondary-subtle text-secondary">
|
||||
<i class="bi bi-calendar3 me-1"></i>{{ comm.semester }} {{ comm.year }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled small mb-3">
|
||||
{% if comm.teacher %}
|
||||
<li class="d-flex align-items-center gap-2 py-1">
|
||||
<i class="bi bi-person-video3 text-info flex-shrink-0"></i>
|
||||
<span>{{ comm.teacher.name }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="d-flex align-items-center gap-2 py-1 text-warning">
|
||||
<i class="bi bi-exclamation-circle flex-shrink-0"></i>
|
||||
<span class="fst-italic">Sin docente asignado</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if comm.schedule %}
|
||||
<li class="d-flex align-items-center gap-2 py-1">
|
||||
<i class="bi bi-clock-history text-muted flex-shrink-0"></i>
|
||||
<span class="text-muted">{{ comm.schedule }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if role in ['docente', 'admin', 'bedelia'] %}
|
||||
<li class="d-flex align-items-center gap-2 py-1">
|
||||
<i class="bi bi-people text-muted flex-shrink-0"></i>
|
||||
<span class="text-muted">
|
||||
{{ comm.current_students or 0 }} / {{ comm.max_students }} alumnos
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% if comm.virtual_link %}
|
||||
<a href="{{ comm.virtual_link }}" target="_blank" class="btn btn-sm btn-outline-info w-100 mb-2"
|
||||
id="virtual-link-{{ comm.id }}">
|
||||
<i class="bi bi-camera-video me-1"></i>Acceso Virtual
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer border-0 bg-transparent pt-0 pb-3 px-3">
|
||||
<a href="{{ url_for('my_subjects.my_subject_detail', commission_id=comm.id) }}"
|
||||
class="btn btn-primary btn-sm w-100" id="view-commission-{{ comm.id }}">
|
||||
<i class="bi bi-eye me-1"></i>Ver detalle
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-5 mt-4">
|
||||
<div class="mb-4">
|
||||
{% if role == 'docente' %}
|
||||
<i class="bi bi-person-video3 display-2 text-muted opacity-30"></i>
|
||||
<h5 class="mt-3 fw-semibold">No tenés comisiones asignadas</h5>
|
||||
<p class="text-muted small">Cuando Bedelía te asigne una comisión, aparecerá aquí.</p>
|
||||
{% elif role == 'alumno' %}
|
||||
<i class="bi bi-mortarboard display-2 text-muted opacity-30"></i>
|
||||
<h5 class="mt-3 fw-semibold">No estás inscripto en ninguna comisión</h5>
|
||||
<p class="text-muted small">Cuando Bedelía te inscriba en una comisión, aparecerá aquí.</p>
|
||||
{% else %}
|
||||
<i class="bi bi-diagram-3 display-2 text-muted opacity-30"></i>
|
||||
<h5 class="mt-3 fw-semibold">No hay comisiones activas</h5>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-house me-1"></i>Volver al Inicio
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.commission-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1.5rem rgba(0,0,0,.15) !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
+102
-58
@@ -21,60 +21,75 @@ def init_database():
|
||||
db.create_all()
|
||||
|
||||
# 2. Migraciones incrementales seguras para bases de datos existentes
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
code VARCHAR(50) UNIQUE,
|
||||
address VARCHAR(255),
|
||||
floors INTEGER DEFAULT 1 NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
user_email VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
module VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs (action);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.commit()
|
||||
if db.engine.url.drivername != 'sqlite':
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme_preference VARCHAR(10) DEFAULT 'auto' NOT NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ADD COLUMN IF NOT EXISTS career_id INTEGER REFERENCES careers(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE subjects ALTER COLUMN code TYPE VARCHAR(50);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
code VARCHAR(50) UNIQUE,
|
||||
address VARCHAR(255),
|
||||
floors INTEGER DEFAULT 1 NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
user_email VARCHAR(255),
|
||||
action VARCHAR(50) NOT NULL,
|
||||
module VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
details TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_action ON audit_logs (action);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_module ON audit_logs (module);
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_logs_created_at ON audit_logs (created_at);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE classrooms ADD COLUMN IF NOT EXISTS building_id INTEGER REFERENCES buildings(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE commissions ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE reservations ADD COLUMN IF NOT EXISTS shift VARCHAR(30);
|
||||
"""))
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS student_enrollments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
commission_id INTEGER NOT NULL REFERENCES commissions(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'activo',
|
||||
enrolled_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
CONSTRAINT uq_student_commission UNIQUE (student_id, commission_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_student_id ON student_enrollments (student_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_student_enrollments_commission_id ON student_enrollments (commission_id);
|
||||
"""))
|
||||
db.session.commit()
|
||||
print("[OK] Esquema y tablas actualizadas con éxito.")
|
||||
|
||||
# 3. Crear o sincronizar roles predeterminados y matriz de permisos
|
||||
@@ -93,7 +108,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'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -109,7 +126,9 @@ def init_database():
|
||||
'import': 'none',
|
||||
'optimizer': 'read',
|
||||
'users': 'none',
|
||||
'milestone_types': 'read'
|
||||
'milestone_types': 'read',
|
||||
'metrics': 'none',
|
||||
'enrollment': 'read'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -125,7 +144,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'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -141,7 +162,9 @@ def init_database():
|
||||
'import': 'none',
|
||||
'optimizer': 'none',
|
||||
'users': 'none',
|
||||
'milestone_types': 'read'
|
||||
'milestone_types': 'read',
|
||||
'metrics': 'none',
|
||||
'enrollment': 'read'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -174,6 +197,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...")
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user