diff --git a/app/__init__.py b/app/__init__.py index 1ac45e1..90cbb36 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -31,7 +31,7 @@ def create_app(config_class=Config): # 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 +43,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) diff --git a/app/models/__init__.py b/app/models/__init__.py index ead7a2d..cba75ad 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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' ] \ No newline at end of file diff --git a/app/models/enrollment.py b/app/models/enrollment.py new file mode 100644 index 0000000..fa52b80 --- /dev/null +++ b/app/models/enrollment.py @@ -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'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 0003b60..0ead1c8 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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'] \ No newline at end of file +__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp', 'my_subjects_bp'] \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py index 82fa356..3fd0f69 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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('academic', 'read') or current_user.can_manage()): + 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/') +@login_required +def commission_detail(id): + """Detail view of a commission: enrolled students and teacher assignment""" + if not (current_user.has_permission('academic', 'read') or current_user.can_manage()): + 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//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//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//unenroll-student/', 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//update-enrollment/', 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)) diff --git a/app/routes/my_subjects.py b/app/routes/my_subjects.py new file mode 100644 index 0000000..ffbb046 --- /dev/null +++ b/app/routes/my_subjects.py @@ -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('/') +@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 + ) diff --git a/app/templates/admin/commissions/detail.html b/app/templates/admin/commissions/detail.html new file mode 100644 index 0000000..5805373 --- /dev/null +++ b/app/templates/admin/commissions/detail.html @@ -0,0 +1,282 @@ +{% extends 'base.html' %} +{% block title %}Comisión {{ commission.get_full_code() }} — Edu-Space{% endblock %} + +{% block content %} +
+ + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+ + +
+
+
+ +
+
{{ commission.get_full_code() }}
+ {{ commission.semester }} {{ commission.year }} +
+
+
+
+
{{ commission.subject.name }}
+

{{ commission.subject.code }} + {% if commission.subject.career_obj %}— {{ commission.subject.career_obj.name }}{% endif %} +

+ +
+ {% if commission.shift %} + {% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %} + + {{ commission.shift }} + + {% endif %} + + {{ 'Activa' if commission.active else 'Inactiva' }} + +
+ +
    +
  • + Cupo máximo + {{ commission.max_students }} +
  • +
  • + Inscriptos + + {{ commission.current_students or 0 }} + +
  • + {% if commission.schedule %} +
  • + Horario + {{ commission.schedule }} +
  • + {% endif %} + {% if commission.virtual_link %} +
  • + + Acceso Virtual + +
  • + {% endif %} +
+
+
+ + +
+
+ + Docente Asignado +
+
+ {% if commission.teacher %} +
+
+ +
+
+
{{ commission.teacher.name }}
+
{{ commission.teacher.email }}
+
+
+ {% else %} +
+ Sin docente asignado +
+ {% endif %} + + {% if can_edit %} +
+
+ + +
+ +
+ {% endif %} +
+
+ +
+ + +
+ + +
+
+
+ + Alumnos Inscriptos + {{ enrollments | length }} +
+ {% if can_edit %} + + {% endif %} +
+ + + {% if can_edit %} +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ {% endif %} + +
+ {% if enrollments %} +
+ + + + + + + + + {% if can_edit %}{% endif %} + + + + {% for e in enrollments %} + + + + + + + {% if can_edit %} + + {% endif %} + + {% endfor %} + +
#AlumnoEstadoFecha InscripciónNotasAcciones
{{ loop.index }} +
{{ e.student.name if e.student else '—' }}
+
{{ e.student.email if e.student else '' }}
+
+ {% set st_colors = {'activo': 'success', 'retirado': 'danger', 'condicional': 'warning'} %} + + {{ e.status | capitalize }} + + + {{ e.enrolled_at.strftime('%d/%m/%Y') if e.enrolled_at else '—' }} + {{ e.notes or '—' }} +
+ +
+ +
+ +
+ +
+
+
+
+ {% else %} +
+ +

No hay alumnos inscriptos en esta comisión.

+ {% if can_edit %} + + {% endif %} +
+ {% endif %} +
+
+ + + {% 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 %} +
+
+
+ Ocupación del cupo + {{ enrolled_count }} / {{ max_students }} ({{ cap_pct }}%) +
+
+
+
+
+
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/admin/commissions/list.html b/app/templates/admin/commissions/list.html new file mode 100644 index 0000000..73bde13 --- /dev/null +++ b/app/templates/admin/commissions/list.html @@ -0,0 +1,209 @@ +{% extends 'base.html' %} +{% block title %}Gestión de Comisiones — Edu-Space{% endblock %} + +{% block content %} +
+ +
+
+

+ Gestión de Comisiones +

+

Asignación de docentes e inscripción de alumnos por comisión

+
+ + Ver Asignaturas + +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ + +
+
+ + + {{ pagination.total }} comisión(es) encontrada(s) + + Página {{ pagination.page }} / {{ pagination.pages or 1 }} +
+
+ {% if commissions %} +
+ + + + + + + + + + + + + + {% for comm in commissions %} + + + + + + + + + + {% endfor %} + +
ComisiónAsignaturaDocenteTurnoAlumnosEstadoAcción
+ + {{ comm.get_full_code() }} + + +
{{ comm.subject.name }}
+ {{ comm.subject.code }} — {{ comm.semester }} {{ comm.year }} +
+ {% if comm.teacher %} +
+
+ +
+ {{ comm.teacher.name }} +
+ {% else %} + Sin asignar + {% endif %} +
+ {% if comm.shift %} + {% set shift_colors = {'Mañana': 'warning', 'Tarde': 'info', 'Vespertino': 'secondary', 'Noche': 'dark'} %} + + {{ comm.shift }} + + {% else %} + — + {% endif %} + + {{ comm.current_students or 0 }} + / {{ comm.max_students }} + + {% if comm.active %} + Activa + {% else %} + Inactiva + {% endif %} + + + Gestionar + +
+
+ + + {% if pagination.pages > 1 %} +
+ +
+ {% endif %} + + {% else %} +
+ +

No se encontraron comisiones con los filtros aplicados.

+ + Limpiar filtros + +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index caf4290..6540b8b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -90,6 +90,7 @@ @@ -147,6 +148,7 @@ + +