feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+10
View File
@@ -0,0 +1,10 @@
from .main import main_bp
from .auth import auth_bp
from .classrooms import classrooms_bp
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', 'my_subjects_bp']
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
"""Paquete de rutas API RESTful (v1) con soporte Stateless JWT."""
from app.routes.api.auth import api_auth_bp
from app.routes.api.classrooms import api_classrooms_bp
from app.routes.api.reservations import api_reservations_bp
from app.routes.api.optimizer import api_optimizer_bp
__all__ = [
'api_auth_bp',
'api_classrooms_bp',
'api_reservations_bp',
'api_optimizer_bp'
]
+930
View File
@@ -0,0 +1,930 @@
from flask import Blueprint, jsonify, request
from datetime import datetime
from app.utils.jwt_decorators import jwt_required
from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES
from app.models.subject import Subject, Commission
from app.models.career import Career
from app.models.academic_term import AcademicTerm
from app.models.milestone import MilestoneType, AcademicMilestone
from app.models.audit_log import AuditLog
from app import db
api_admin_bp = Blueprint('api_admin', __name__)
@api_admin_bp.route('/users', methods=['GET'])
@jwt_required
def get_users():
search = request.args.get('search', '').strip().lower()
role_id = request.args.get('role', type=int)
status = request.args.get('status', '').strip().lower()
query = User.query
if search:
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
if role_id:
query = query.filter(User.role_id == role_id)
if status == 'active':
query = query.filter(User.is_active == True)
elif status == 'inactive':
query = query.filter(User.is_active == False)
users = query.order_by(User.id.asc()).all()
roles = Role.query.order_by(Role.name.asc()).all()
users_data = []
for u in users:
users_data.append({
'id': u.id,
'name': u.name,
'first_name': u.name.split(' ')[0] if u.name else 'U',
'email': u.email,
'is_active': u.is_active,
'role': u.role,
'role_obj': {
'id': u.role_obj.id,
'name': u.role_obj.name
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'},
'last_login': getattr(u, 'last_login', None)
})
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
return jsonify({
'status': 'success',
'total': len(users_data),
'users': users_data,
'roles': roles_data
}), 200
@api_admin_bp.route('/roles', methods=['GET'])
@jwt_required
def get_roles():
roles = Role.query.order_by(Role.id.asc()).all()
modules = [
{'id': 'dashboard', 'name': 'Dashboard', 'icon': 'bi-speedometer2'},
{'id': 'classrooms', 'name': 'Aulas y Espacios', 'icon': 'bi-door-open'},
{'id': 'schedule', 'name': 'Cronograma', 'icon': 'bi-calendar3'},
{'id': 'subjects', 'name': 'Asignaturas', 'icon': 'bi-book'},
{'id': 'careers', 'name': 'Carreras', 'icon': 'bi-mortarboard'},
{'id': 'commissions', 'name': 'Comisiones', 'icon': 'bi-diagram-3'},
{'id': 'users', 'name': 'Usuarios', 'icon': 'bi-people'},
{'id': 'roles', 'name': 'Roles (RBAC)', 'icon': 'bi-shield-check'},
{'id': 'optimizer', 'name': 'Optimizador IA', 'icon': 'bi-cpu'},
{'id': 'audit', 'name': 'Auditoría', 'icon': 'bi-clipboard-data'}
]
roles_data = []
for r in roles:
perms_map = {}
for p in r.permissions:
perms_map[p.module] = p.access_level
roles_data.append({
'id': r.id,
'name': r.name,
'description': r.description or f'Rol institucional de {r.name}',
'is_system': getattr(r, 'is_system', True),
'users_count': len(r.users),
'users': [{'id': u.id, 'name': u.name} for u in r.users],
'permissions': perms_map
})
return jsonify({
'status': 'success',
'roles': roles_data,
'modules': modules
}), 200
@api_admin_bp.route('/subjects', methods=['GET'])
@jwt_required
def get_subjects():
search = request.args.get('search', '').strip().lower()
career_id = request.args.get('career_id', type=int)
active = request.args.get('active', '').strip().lower()
query = Subject.query
if search:
query = query.filter((Subject.name.ilike(f'%{search}%')) | (Subject.code.ilike(f'%{search}%')) | (Subject.department.ilike(f'%{search}%')))
if career_id:
query = query.filter(Subject.career_id == career_id)
if active == 'true':
query = query.filter(Subject.is_active == True)
elif active == 'false':
query = query.filter(Subject.is_active == False)
subjects = query.order_by(Subject.id.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
subjects_data = []
for s in subjects:
subjects_data.append({
'id': s.id,
'code': s.code,
'name': s.name,
'career_id': s.career_id,
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
'department': s.department or 'General',
'credits': getattr(s, 'credits', 4),
'is_active': s.is_active,
'commissions_count': len(s.commissions),
'commissions': [{'id': c.id, 'code': c.code, 'schedule': c.schedule} for c in s.commissions]
})
careers_data = [{'id': c.id, 'name': c.name} for c in careers]
return jsonify({
'status': 'success',
'total': len(subjects_data),
'subjects': subjects_data,
'careers': careers_data
}), 200
@api_admin_bp.route('/careers', methods=['GET'])
@jwt_required
def get_careers():
search = request.args.get('search', '').strip().lower()
query = Career.query
if search:
query = query.filter(Career.name.ilike(f'%{search}%'))
careers = query.order_by(Career.name.asc()).all()
careers_data = []
for c in careers:
careers_data.append({
'id': c.id,
'name': c.name,
'code': getattr(c, 'code', f'CAR-{c.id}'),
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
'is_active': getattr(c, 'is_active', True),
'subjects_count': len(c.subjects),
'subjects': [{'id': s.id, 'name': s.name, 'code': s.code} for s in c.subjects[:5]]
})
return jsonify({
'status': 'success',
'total': len(careers_data),
'careers': careers_data
}), 200
@api_admin_bp.route('/commissions', methods=['GET'])
@jwt_required
def get_commissions():
search = request.args.get('search', '').strip().lower()
subject_id = request.args.get('subject_id', type=int)
shift = request.args.get('shift', '').strip()
active = request.args.get('active', '').strip().lower()
query = Commission.query
if subject_id:
query = query.filter(Commission.subject_id == subject_id)
if shift:
query = query.filter(Commission.shift == shift)
if active == 'true':
query = query.filter(Commission.active == True)
elif active == 'false':
query = query.filter(Commission.active == False)
commissions = query.order_by(Commission.id.asc()).all()
subjects = Subject.query.order_by(Subject.name.asc()).all()
commissions_data = []
for c in commissions:
if search:
s_name = c.subject.name.lower() if c.subject else ''
c_code = c.code.lower()
if search not in s_name and search not in c_code:
continue
commissions_data.append({
'id': c.id,
'code': c.code,
'subject_id': c.subject_id,
'subject_name': c.subject.name if c.subject else 'Sin materia',
'subject_code': c.subject.code if c.subject else '-',
'schedule': c.schedule or 'A coordinar',
'shift': c.shift or 'Mañana',
'max_students': c.max_students or 35,
'current_students': getattr(c, 'current_students', 0) or 0,
'active': c.active,
'virtual_link': getattr(c, 'virtual_link', '') or ''
})
subjects_data = [{'id': s.id, 'name': s.name, 'code': s.code} for s in subjects]
return jsonify({
'status': 'success',
'total': len(commissions_data),
'commissions': commissions_data,
'subjects': subjects_data
}), 200
# ---------------------------------------------------------
# USERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/users', methods=['POST'])
@jwt_required
def create_user():
data = request.get_json(silent=True) or request.form.to_dict() or {}
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
password = data.get('password', '').strip()
role_id = data.get('role_id')
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not email or not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre y el email son obligatorios.'}), 400
if User.query.filter(User.email.ilike(email)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con el email {email}.'}), 409
role_obj = None
if role_id:
role_obj = Role.query.get(int(role_id))
role_name = role_obj.name if role_obj else data.get('role', 'Docente')
user = User(
email=email,
name=name,
role=role_name,
role_id=int(role_id) if role_id else None,
is_active=bool(is_active)
)
user.set_password(password or 'edu-space2026')
db.session.add(user)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario creado exitosamente.',
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
}), 201
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
@jwt_required
def update_user(id):
user = User.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
user.name = data['name'].strip()
if 'email' in data and data['email']:
email = data['email'].strip().lower()
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'El email {email} ya está registrado por otro usuario.'}), 409
user.email = email
if 'password' in data and data['password']:
user.set_password(data['password'].strip())
if 'role_id' in data and data['role_id']:
role_obj = Role.query.get(int(data['role_id']))
if role_obj:
user.role_id = role_obj.id
user.role = role_obj.name
if 'is_active' in data:
val = data['is_active']
user.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario actualizado correctamente.',
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
}), 200
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_user(id):
user = User.query.get_or_404(id)
user.is_active = not user.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': user.id,
'is_active': user.is_active,
'message': f'Usuario {"activado" if user.is_active else "desactivado"} correctamente.'
}), 200
@api_admin_bp.route('/users/<int:id>', methods=['DELETE'])
@jwt_required
def delete_user(id):
user = User.query.get_or_404(id)
has_reservations = len(user.reservations) if user.reservations else 0
if has_reservations > 0:
user.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'El usuario tiene reservas asociadas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(user)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario eliminado permanentemente.'
}), 200
# ---------------------------------------------------------
# ROLES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/roles', methods=['POST'])
@jwt_required
def create_role():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
description = data.get('description', '').strip()
permissions = data.get('permissions', {})
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre del rol es obligatorio.'}), 400
if Role.query.filter(Role.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe un rol llamado {name}.'}), 409
role = Role(name=name, description=description, is_system=False)
db.session.add(role)
db.session.flush()
for mod, level in permissions.items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol creado exitosamente.',
'role': role.to_dict()
}), 201
@api_admin_bp.route('/roles/<int:id>', methods=['PUT'])
@jwt_required
def update_role(id):
role = Role.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Role.query.filter(Role.name.ilike(name), Role.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro rol llamado {name}.'}), 409
role.name = name
if 'description' in data:
role.description = data['description'].strip()
if 'permissions' in data and isinstance(data['permissions'], dict):
for mod, level in data['permissions'].items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol actualizado correctamente.',
'role': role.to_dict()
}), 200
@api_admin_bp.route('/roles/<int:id>', methods=['DELETE'])
@jwt_required
def delete_role(id):
role = Role.query.get_or_404(id)
if role.is_system:
return jsonify({'error': 'Forbidden', 'message': 'No se pueden eliminar roles de sistema.'}), 403
if role.users and len(role.users) > 0:
return jsonify({'error': 'Conflict', 'message': f'El rol tiene {len(role.users)} usuarios asignados. Reasígnelos primero.'}), 409
db.session.delete(role)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol eliminado exitosamente.'
}), 200
# ---------------------------------------------------------
# CAREERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/careers', methods=['POST'])
@jwt_required
def create_career():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip()
description = data.get('description', '').strip()
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre de la carrera es obligatorio.'}), 400
if Career.query.filter(Career.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una carrera con el nombre {name}.'}), 409
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera creada exitosamente.',
'career': career.to_dict()
}), 201
@api_admin_bp.route('/careers/<int:id>', methods=['PUT'])
@jwt_required
def update_career(id):
career = Career.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra carrera con el nombre {name}.'}), 409
career.name = name
if 'code' in data:
career.code = data['code'].strip() or None
if 'description' in data:
career.description = data['description'].strip() or None
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera actualizada correctamente.',
'career': career.to_dict()
}), 200
@api_admin_bp.route('/careers/<int:id>', methods=['DELETE'])
@jwt_required
def delete_career(id):
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
return jsonify({
'error': 'Conflict',
'message': f'No se puede eliminar la carrera porque tiene {len(career.subjects)} asignaturas vinculadas.'
}), 409
db.session.delete(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# SUBJECTS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/subjects', methods=['POST'])
@jwt_required
def create_subject():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip().upper()
name = data.get('name', '').strip()
department = data.get('department', '').strip()
credits = int(data.get('credits', 4) or 4)
career_id = data.get('career_id')
career_id = int(career_id) if career_id else None
description = data.get('description', '').strip()
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'El código y nombre de la asignatura son obligatorios.'}), 400
if Subject.query.filter_by(code=code).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una asignatura con el código {code}.'}), 409
subject = Subject(
code=code,
name=name,
department=department,
credits=credits,
career_id=career_id,
description=description,
is_active=bool(is_active)
)
db.session.add(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura creada exitosamente.',
'subject': subject.to_dict()
}), 201
@api_admin_bp.route('/subjects/<int:id>', methods=['PUT'])
@jwt_required
def update_subject(id):
subject = Subject.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip().upper()
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra asignatura con el código {code}.'}), 409
subject.code = code
if 'name' in data and data['name']:
subject.name = data['name'].strip()
if 'department' in data:
subject.department = data['department'].strip()
if 'credits' in data and data['credits'] is not None:
subject.credits = int(data['credits'])
if 'career_id' in data:
c_id = data['career_id']
subject.career_id = int(c_id) if c_id else None
if 'description' in data:
subject.description = data['description'].strip()
if 'is_active' in data:
val = data['is_active']
subject.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura actualizada correctamente.',
'subject': subject.to_dict()
}), 200
@api_admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_subject(id):
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': subject.id,
'is_active': subject.is_active,
'message': f'Asignatura {"activada" if subject.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/subjects/<int:id>', methods=['DELETE'])
@jwt_required
def delete_subject(id):
subject = Subject.query.get_or_404(id)
if subject.commissions and len(subject.commissions) > 0:
subject.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# COMMISSIONS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/commissions', methods=['POST'])
@jwt_required
def create_commission():
data = request.get_json(silent=True) or request.form.to_dict() or {}
subject_id = data.get('subject_id')
code = data.get('code', '').strip()
semester = data.get('semester', '1C').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
teacher_id = data.get('teacher_id')
teacher_id = int(teacher_id) if teacher_id else None
max_students = int(data.get('max_students', 35) or 35)
schedule = data.get('schedule', '').strip()
shift = data.get('shift', 'Mañana').strip()
virtual_link = data.get('virtual_link', '').strip()
active = data.get('active', True)
if isinstance(active, str):
active = active.lower() in ['true', '1', 'on']
if not subject_id or not code:
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
commission = Commission(
subject_id=int(subject_id),
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link,
active=bool(active)
)
db.session.add(commission)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Comisión creada exitosamente.',
'commission': commission.to_dict()
}), 201
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
@jwt_required
def update_commission(id):
comm = Commission.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
comm.code = data['code'].strip()
if 'semester' in data and data['semester']:
comm.semester = data['semester'].strip()
if 'year' in data and data['year']:
comm.year = int(data['year'])
if 'teacher_id' in data:
t_id = data['teacher_id']
comm.teacher_id = int(t_id) if t_id else None
if 'max_students' in data and data['max_students']:
comm.max_students = int(data['max_students'])
if 'schedule' in data:
comm.schedule = data['schedule'].strip()
if 'shift' in data:
comm.shift = data['shift'].strip()
if 'virtual_link' in data:
comm.virtual_link = data['virtual_link'].strip()
if 'active' in data:
val = data['active']
comm.active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Comisión actualizada correctamente.',
'commission': comm.to_dict()
}), 200
@api_admin_bp.route('/commissions/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_commission(id):
comm = Commission.query.get_or_404(id)
comm.active = not comm.active
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'active': comm.active,
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
@jwt_required
def assign_commission_teacher(id):
comm = Commission.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
t_id = data.get('teacher_id')
comm.teacher_id = int(t_id) if t_id else None
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'teacher_id': comm.teacher_id,
'teacher_name': comm.teacher_name,
'message': 'Docente asignado correctamente.'
}), 200
# ---------------------------------------------------------
# ACADEMIC TERMS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/academic-terms', methods=['GET'])
@jwt_required
def get_academic_terms():
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
terms_data = []
for t in terms:
comm_count = Commission.query.filter_by(year=t.year).count()
terms_data.append({
'id': t.id,
'code': t.code,
'name': t.name,
'year': t.year,
'period_type': t.period_type,
'start_date': t.start_date.isoformat() if t.start_date else None,
'end_date': t.end_date.isoformat() if t.end_date else None,
'is_current': t.is_current,
'is_active': t.is_active,
'commissions_count': comm_count
})
return jsonify({
'status': 'success',
'total': len(terms_data),
'terms': terms_data
}), 200
@api_admin_bp.route('/academic-terms', methods=['POST'])
@jwt_required
def create_academic_term():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip()
name = data.get('name', '').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
period_type = data.get('period_type', 'Segundo Cuatrimestre').strip()
is_current = data.get('is_current', False)
if isinstance(is_current, str):
is_current = is_current.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'Código y nombre son requeridos.'}), 400
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe un ciclo con el código {code}.'}), 409
if is_current:
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type,
is_current=bool(is_current),
is_active=True
)
db.session.add(term)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo creado exitosamente.',
'term': term.to_dict()
}), 201
@api_admin_bp.route('/academic-terms/<int:id>', methods=['PUT'])
@jwt_required
def update_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip()
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro ciclo con el código {code}.'}), 409
term.code = code
if 'name' in data and data['name']:
term.name = data['name'].strip()
if 'year' in data and data['year']:
term.year = int(data['year'])
if 'period_type' in data:
term.period_type = data['period_type'].strip()
if 'is_current' in data:
is_cur = data['is_current'] in [True, 'true', '1', 'on']
if is_cur:
AcademicTerm.query.update({'is_current': False})
term.is_current = is_cur
if 'is_active' in data:
term.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo actualizado correctamente.',
'term': term.to_dict()
}), 200
@api_admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@jwt_required
def set_current_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
return jsonify({
'status': 'success',
'id': term.id,
'message': f'"{term.name}" establecido como ciclo activo.'
}), 200
# ---------------------------------------------------------
# MILESTONE TYPES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/milestone-types', methods=['GET'])
@jwt_required
def get_milestone_types():
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify({
'status': 'success',
'total': len(types),
'milestone_types': [t.to_dict() for t in types]
}), 200
@api_admin_bp.route('/milestone-types', methods=['POST'])
@jwt_required
def create_milestone_type():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip().lower().replace(' ', '_')
color = data.get('color', '#0d6efd').strip()
description = data.get('description', '').strip()
sort_order = int(data.get('sort_order', 0) or 0)
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not name or not code:
return jsonify({'error': 'ValidationError', 'message': 'Nombre y código son obligatorios.'}), 400
existing = MilestoneType.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe una tipificación con el código {code}.'}), 409
mt = MilestoneType(
name=name,
code=code,
color=color,
description=description,
sort_order=sort_order,
is_active=bool(is_active)
)
db.session.add(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación de hito creada correctamente.',
'milestone_type': mt.to_dict()
}), 201
@api_admin_bp.route('/milestone-types/<int:id>', methods=['PUT'])
@jwt_required
def update_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
mt.name = data['name'].strip()
if 'code' in data and data['code']:
code = data['code'].strip().lower().replace(' ', '_')
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra tipificación con el código {code}.'}), 409
mt.code = code
if 'color' in data:
mt.color = data['color'].strip()
if 'description' in data:
mt.description = data['description'].strip()
if 'sort_order' in data:
mt.sort_order = int(data['sort_order'])
if 'is_active' in data:
mt.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación actualizada correctamente.',
'milestone_type': mt.to_dict()
}), 200
@api_admin_bp.route('/milestone-types/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
mt.is_active = not mt.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': mt.id,
'is_active': mt.is_active,
'message': f'Tipificación {"activada" if mt.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/milestone-types/<int:id>', methods=['DELETE'])
@jwt_required
def delete_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
if milestone_count > 0:
mt.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': f'La tipificación tiene {milestone_count} hitos registrados. Se ha desactivado en su lugar.'
}), 200
db.session.delete(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación eliminada permanentemente.'
}), 200
+109
View File
@@ -0,0 +1,109 @@
from flask import Blueprint, request, jsonify, g, make_response
from pydantic import ValidationError
import jwt
from app.services.user_service import UserService
from app.services.jwt_service import JWTService
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
from app.utils.jwt_decorators import jwt_required
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
user_service = UserService()
@api_auth_bp.route('/login', methods=['POST'])
def login():
"""
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
"""
data = request.get_json(silent=True) or {}
try:
dto = LoginDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
user = user_service.authenticate(dto.email, dto.password)
if not user:
return jsonify({
'error': 'Unauthorized',
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
}), 401
tokens = JWTService.generate_tokens(user)
profile = user_service.get_profile_data(user)
response_data = {
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'token_type': tokens['token_type'],
'expires_in': tokens['expires_in'],
'user': profile
}
resp = make_response(jsonify(response_data), 200)
# Almacenar refresh token en cookie segura HttpOnly
resp.set_cookie(
'refresh_token',
tokens['refresh_token'],
httponly=True,
samesite='Lax',
max_age=7 * 24 * 3600,
path='/api/v1/auth/refresh'
)
return resp
@api_auth_bp.route('/refresh', methods=['POST'])
def refresh():
"""
Renovación silenciosa del Access Token utilizando el Refresh Token.
"""
data = request.get_json(silent=True) or {}
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
if not refresh_token:
return jsonify({
'error': 'BadRequest',
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
}), 400
try:
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
user_id = int(payload.get('sub'))
user = user_service.get_by_id(user_id)
if not user or not user.is_active:
return jsonify({
'error': 'Unauthorized',
'message': 'Usuario no encontrado o inactivo.'
}), 401
new_access_token = JWTService.create_access_token(user)
return jsonify({
'access_token': new_access_token,
'token_type': 'Bearer',
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
}), 200
except jwt.ExpiredSignatureError:
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
except jwt.InvalidTokenError as e:
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
@api_auth_bp.route('/logout', methods=['POST'])
@jwt_required
def logout():
"""
Cierre de sesión: revoca el token de acceso activo y limpia cookies.
"""
JWTService.revoke_token(g.jwt_token)
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
return resp
@api_auth_bp.route('/me', methods=['GET'])
@jwt_required
def get_current_user():
"""
Retorna el perfil y los permisos del usuario autenticado vía JWT.
"""
profile = user_service.get_profile_data(g.jwt_user)
return jsonify(profile), 200
+119
View File
@@ -0,0 +1,119 @@
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from app.services.classroom_service import ClassroomService
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
from app.utils.jwt_decorators import jwt_required, jwt_role_required
api_classrooms_bp = Blueprint('api_classrooms', __name__, url_prefix='/api/v1/classrooms')
classroom_service = ClassroomService()
@api_classrooms_bp.route('', methods=['GET'])
@jwt_required
def list_classrooms():
"""Lista aulas con filtros opcionales (building_id, floor, mode)."""
building_id = request.args.get('building_id', type=int)
floor = request.args.get('floor')
mode = request.args.get('mode', '').lower()
only_virtual = mode == 'virtual'
only_physical = mode == 'physical'
classrooms = classroom_service.list_classrooms(
building_id=building_id,
floor=floor,
only_virtual=only_virtual,
only_physical=only_physical
)
result = []
for c in classrooms:
result.append({
'id': c.id,
'code': c.code,
'building': c.building_name,
'building_id': c.building_id,
'floor': c.floor,
'floor_display': c.floor_display,
'capacity': c.capacity,
'is_virtual': c.is_virtual,
'location_display': c.location_display,
'description': c.description,
'is_active': c.is_active
})
return jsonify({'total': len(result), 'classrooms': result}), 200
@api_classrooms_bp.route('/<int:classroom_id>', methods=['GET'])
@jwt_required
def get_classroom(classroom_id: int):
"""Obtiene el detalle de un aula por ID."""
c = classroom_service.get_classroom(classroom_id)
if not c:
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
return jsonify({
'id': c.id,
'code': c.code,
'building': c.building_name,
'building_id': c.building_id,
'floor': c.floor,
'floor_display': c.floor_display,
'capacity': c.capacity,
'is_virtual': c.is_virtual,
'location_display': c.location_display,
'description': c.description,
'is_active': c.is_active
}), 200
@api_classrooms_bp.route('', methods=['POST'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
def create_classroom():
"""Crea una nueva aula validando el contrato con ClassroomCreateDTO."""
data = request.get_json(silent=True) or {}
try:
dto = ClassroomCreateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
classroom = classroom_service.create_classroom(dto)
return jsonify({
'message': 'Aula creada exitosamente.',
'id': classroom.id,
'code': classroom.code,
'location_display': classroom.location_display
}), 201
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
@api_classrooms_bp.route('/<int:classroom_id>', methods=['PUT'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR', 'BEDELIA')
def update_classroom(classroom_id: int):
"""Actualiza los datos de un aula."""
data = request.get_json(silent=True) or {}
try:
dto = ClassroomUpdateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
classroom = classroom_service.update_classroom(classroom_id, dto)
return jsonify({
'message': 'Aula actualizada exitosamente.',
'id': classroom.id,
'code': classroom.code,
'capacity': classroom.capacity
}), 200
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 400
@api_classrooms_bp.route('/<int:classroom_id>', methods=['DELETE'])
@jwt_role_required('ADMIN', 'ADMINISTRADOR')
def delete_classroom(classroom_id: int):
"""Elimina o desactiva un aula."""
deleted = classroom_service.delete_classroom(classroom_id)
if not deleted:
return jsonify({'error': 'NotFound', 'message': f'Aula {classroom_id} no encontrada.'}), 404
return jsonify({'message': f'Aula {classroom_id} eliminada/desactivada correctamente.'}), 200
+183
View File
@@ -0,0 +1,183 @@
from flask import Blueprint, jsonify, request
from app.utils.jwt_decorators import jwt_required
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.user import User
from app.models.reservation import Reservation
from app.models.audit_log import AuditLog
from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
from app.models.milestone import AcademicMilestone
from app.models.subject import Commission
api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard')
def serialize_reservation(r):
subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica'
subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '')
classroom_code = r.classroom.code if r.classroom else 'Sin Aula'
is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False)
return {
'id': r.id,
'start_time': r.start_time.strftime('%H:%M') if r.start_time else '',
'end_time': r.end_time.strftime('%H:%M') if r.end_time else '',
'date_display': r.start_time.strftime('%d/%m') if r.start_time else '',
'classroom': {
'id': r.classroom_id,
'code': classroom_code,
'is_virtual': is_virtual,
'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula'
},
'subject_name': subject_name,
'subject_code': subject_code,
'shift': r.shift or 'Mañana',
'commission': {
'id': r.commission_id,
'code': r.commission.code if r.commission else 'COM-1'
},
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'user': {
'id': r.user_id,
'name': r.user.name if r.user else 'Docente'
},
'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '')
}
def serialize_milestone(m):
return {
'id': m.id,
'title': m.title,
'materia_codigo': m.materia_codigo,
'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo,
'date': m.date.strftime('%d/%m/%Y') if m.date else '',
'type_obj': {
'name': m.type_obj.name if m.type_obj else 'Evaluación',
'color': m.type_obj.color if m.type_obj else '#0d6efd'
},
'classroom': m.classroom or 'Sede Central',
'link': m.link or '',
'notes': m.notes or ''
}
@api_dashboard_bp.route('/stats', methods=['GET'])
@jwt_required
def get_stats():
"""Retorna estadísticas clave y datos operativos para el Dashboard."""
today = datetime.utcnow().date()
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_subjects = Subject.query.filter_by(is_active=True).count()
total_users = User.query.filter_by(is_active=True).count()
# Aulas presenciales y virtuales
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).count()
physical_classrooms = max(1, total_classrooms - virtual_classrooms)
today_reservations_q = Reservation.query.filter(
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).all()
today_reservations = len(today_reservations_q)
pending_reservations_q = Reservation.query.filter(
Reservation.status == 'PENDING'
).order_by(Reservation.start_time.asc()).all()
pending_count = len(pending_reservations_q)
upcoming_reservations_q = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow()
).order_by(Reservation.start_time.asc()).limit(10).all()
milestones_q = AcademicMilestone.query.filter(
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(10).all()
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
audit_data = [{
'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None,
'action_type': a.action_type,
'ip_address': a.ip_address,
'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System'
} for a in recent_audits]
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
pending_reservations = [serialize_reservation(r) for r in pending_reservations_q]
today_schedule_list = [serialize_reservation(r) for r in today_reservations_q]
upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q]
milestones = [serialize_milestone(m) for m in milestones_q]
except Exception as e:
print(f"Error fetching stats: {e}")
total_classrooms = 0
total_subjects = 0
total_users = 0
today_reservations = 0
pending_count = 0
physical_classrooms = 0
virtual_classrooms = 0
occupancy_rate = 0
audit_data = []
pending_reservations = []
today_schedule_list = []
upcoming_reservations = []
milestones = []
# Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres)
monthly_data = [
{"month": "Ene", "reservations": 120},
{"month": "Feb", "reservations": 180},
{"month": "Mar", "reservations": 250},
{"month": "Abr", "reservations": 220},
{"month": "May", "reservations": 300},
{"month": "Jun", "reservations": 310},
{"month": "Jul", "reservations": 200},
{"month": "Ago", "reservations": 350},
{"month": "Sep", "reservations": 420},
{"month": "Oct", "reservations": 480},
{"month": "Nov", "reservations": 520},
{"month": "Dic", "reservations": 210}
]
return jsonify({
'status': 'success',
'stats': {
'total_classrooms': total_classrooms,
'active_subjects': total_subjects,
'total_active_users': total_users,
'today_reservations': today_reservations,
'pending_count': pending_count,
'physical_classrooms': physical_classrooms,
'virtual_classrooms': virtual_classrooms,
'occupancy_rate': occupancy_rate
},
'audit_logs': audit_data,
'monthly_data': monthly_data,
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list,
'upcoming_reservations': upcoming_reservations,
'milestones': milestones
}), 200
@api_dashboard_bp.route('/sync-sheets', methods=['POST'])
@jwt_required
def sync_google_sheets():
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
from app.services.sheets_importer import GoogleSheetsImporter
try:
importer = GoogleSheetsImporter()
stats = importer.sync()
return jsonify({
'status': 'success',
'stats': stats
}), 200
except Exception as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 500
+105
View File
@@ -0,0 +1,105 @@
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from app.schemas.optimizer_dto import OptimizerJobRequestDTO, SpringBootSyncPayloadDTO
from app.services.optimizer_service import OptimizerService
from app.utils.jwt_decorators import jwt_required
api_optimizer_bp = Blueprint('api_optimizer', __name__, url_prefix='/api/v1/optimizer')
@api_optimizer_bp.route('/jobs', methods=['POST'])
@jwt_required
def submit_optimization_job():
"""
Despacha un trabajo de optimización heurística asíncrono.
Retorna inmediatamente con HTTP 202 Accepted y el identificador de trabajo para sondeo.
"""
data = request.get_json(silent=True) or {}
try:
dto = OptimizerJobRequestDTO(**data)
except ValidationError as ex:
return jsonify({
'error': 'UnprocessableEntity',
'message': 'Error de validación en los parámetros del optimizador.',
'details': ex.errors()
}), 422
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
job = OptimizerService.submit_job(
commission_ids=dto.commission_ids,
admin_user_id=user_id,
start_date=dto.start_date,
end_date=dto.end_date,
generations=dto.generations,
population_size=dto.population_size,
workers=dto.workers
)
return jsonify({
'job_id': job.job_id,
'status': job.status.value,
'poll_url': f'/api/v1/optimizer/jobs/{job.job_id}',
'message': 'Trabajo de optimización puesto en cola con éxito.'
}), 202
@api_optimizer_bp.route('/jobs/<string:job_id>', methods=['GET'])
@jwt_required
def get_optimization_job(job_id: str):
"""
Obtiene el estado, progreso y resultados de un trabajo de optimización en cola.
"""
job = OptimizerService.get_job(job_id)
if not job:
return jsonify({
'error': 'NotFound',
'message': f'Trabajo de optimización {job_id} no encontrado.'
}), 404
return jsonify(job.to_dict()), 200
@api_optimizer_bp.route('/jobs', methods=['GET'])
@jwt_required
def list_optimization_jobs():
"""
Lista los trabajos de optimización más recientes.
"""
limit = request.args.get('limit', default=20, type=int)
jobs = OptimizerService.list_jobs(limit=limit)
return jsonify({
'total': len(jobs),
'jobs': jobs
}), 200
@api_optimizer_bp.route('/spring-boot/sync', methods=['POST'])
@jwt_required
def sync_with_spring_boot():
"""
Endpoint interoperable para la sincronización y resolución de horarios con microservicios Spring Boot.
Recibe el payload en formato Spring Boot (camelCase) y retorna el cronograma optimizado.
"""
data = request.get_json(silent=True) or {}
try:
dto = SpringBootSyncPayloadDTO(**data)
except ValidationError as ex:
return jsonify({
'error': 'UnprocessableEntity',
'message': 'Error de validación en el payload Spring Boot.',
'details': ex.errors()
}), 422
user_id = g.current_user.id if hasattr(g, 'current_user') and g.current_user else 1
try:
result = OptimizerService.sync_with_spring_boot(dto, admin_user_id=user_id)
return jsonify(result), 200
except Exception as ex:
return jsonify({
'requestId': dto.requestId,
'status': 'ERROR',
'error': str(ex)
}), 500
+187
View File
@@ -0,0 +1,187 @@
from flask import Blueprint, request, jsonify, g
from datetime import datetime, timedelta, date
from pydantic import ValidationError
from app.services.reservation_service import ReservationService
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
from app.utils.jwt_decorators import jwt_required, jwt_role_required
api_reservations_bp = Blueprint('api_reservations', __name__, url_prefix='/api/v1/reservations')
reservation_service = ReservationService()
@api_reservations_bp.route('', methods=['GET'])
@jwt_required
def list_reservations():
"""Consulta de reservas por rango de fechas y/o aula."""
start_str = request.args.get('start_date')
end_str = request.args.get('end_date')
classroom_id = request.args.get('classroom_id', type=int)
now = datetime.utcnow()
try:
start_dt = datetime.strptime(start_str, '%Y-%m-%d') if start_str else now.replace(hour=0, minute=0, second=0)
except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de start_date (debe ser YYYY-MM-DD).'}), 400
try:
end_dt = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) if end_str else start_dt + timedelta(days=7)
except ValueError:
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de end_date (debe ser YYYY-MM-DD).'}), 400
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor = request.args.get('floor')
shift = request.args.get('shift')
search = request.args.get('search')
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
result = []
for r in reservations:
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
if hide_virtual and is_virt:
continue
if floor and r.classroom and str(r.classroom.floor).strip().lower() != str(floor).strip().lower():
continue
if shift and r.shift and shift.lower() not in r.shift.lower():
continue
if search:
s_lower = search.lower()
subj_n = (r.subject_name or '').lower()
subj_c = (r.subject_code or '').lower()
c_name = (r.classroom.code if r.classroom else '').lower()
u_name = (r.user.name if r.user else '').lower()
if s_lower not in subj_n and s_lower not in subj_c and s_lower not in c_name and s_lower not in u_name:
continue
result.append({
'id': r.id,
'classroom_id': r.classroom_id,
'classroom_name': r.classroom.code if r.classroom else None,
'classroom': {
'id': r.classroom.id,
'code': r.classroom.code,
'code_display': getattr(r.classroom, 'code_display', r.classroom.code),
'floor': r.classroom.floor,
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
'building': getattr(r.classroom, 'building_name', r.classroom.building),
'is_virtual': getattr(r.classroom, 'is_virtual', False),
'location_display': getattr(r.classroom, 'location_display', r.classroom.code)
} if r.classroom else None,
'commission_id': r.commission_id,
'commission': {
'id': r.commission.id,
'code': r.commission.code,
'virtual_link': r.commission.virtual_link,
'max_students': r.commission.max_students
} if r.commission else None,
'teacher_name': r.user.name if r.user else None,
'user': {
'id': r.user.id,
'name': r.user.name,
'email': r.user.email
} if r.user else None,
'start_time': r.start_time.isoformat(),
'end_time': r.end_time.isoformat(),
'purpose': r.purpose,
'subject_name': r.subject_name,
'subject_code': r.subject_code,
'status': str(r.status.value if hasattr(r.status, 'value') else r.status),
'shift': r.shift or 'Mañana',
'virtual_link': r.virtual_link,
'effective_virtual_link': r.effective_virtual_link,
'is_virtual': is_virt,
'expected_attendees': r.expected_attendees or 30,
'notes': r.notes or ''
})
return jsonify({
'total': len(result),
'start_date': start_dt.strftime('%Y-%m-%d'),
'end_date': end_dt.strftime('%Y-%m-%d'),
'reservations': result
}), 200
@api_reservations_bp.route('/<int:reservation_id>', methods=['GET'])
@jwt_required
def get_reservation(reservation_id: int):
"""Obtiene el detalle de una reserva específica."""
r = reservation_service.get_reservation(reservation_id)
if not r:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
return jsonify({
'id': r.id,
'classroom_id': r.classroom_id,
'classroom_name': r.classroom.code if r.classroom else None,
'location_display': r.classroom.location_display if r.classroom else None,
'commission_id': r.commission_id,
'teacher_id': r.user_id,
'teacher_name': r.user.name if r.user else None,
'start_time': r.start_time.isoformat(),
'end_time': r.end_time.isoformat(),
'purpose': r.purpose,
'status': str(r.status),
'shift': r.shift,
'virtual_link': r.virtual_link,
'expected_attendees': r.expected_attendees,
'notes': r.notes
}), 200
@api_reservations_bp.route('', methods=['POST'])
@jwt_required
def create_reservation():
"""Crea una reserva aplicando validación DTO y detección de colisiones."""
data = request.get_json(silent=True) or {}
# Inyectar usuario autenticado si no se especifica
if 'user_id' not in data:
data['user_id'] = g.jwt_user.id
try:
dto = ReservationCreateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
reservation = reservation_service.create_reservation(dto)
return jsonify({
'message': 'Reserva creada exitosamente.',
'id': reservation.id,
'classroom_id': reservation.classroom_id,
'start_time': reservation.start_time.isoformat(),
'end_time': reservation.end_time.isoformat(),
'status': str(reservation.status)
}), 201
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
@api_reservations_bp.route('/<int:reservation_id>', methods=['PUT'])
@jwt_required
def update_reservation(reservation_id: int):
"""Modifica una reserva existente."""
data = request.get_json(silent=True) or {}
try:
dto = ReservationUpdateDTO(**data)
except ValidationError as e:
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
try:
reservation = reservation_service.update_reservation(reservation_id, dto)
return jsonify({
'message': 'Reserva actualizada exitosamente.',
'id': reservation.id,
'start_time': reservation.start_time.isoformat(),
'end_time': reservation.end_time.isoformat(),
'status': str(reservation.status)
}), 200
except ValueError as e:
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
@api_reservations_bp.route('/<int:reservation_id>', methods=['DELETE'])
@jwt_required
def cancel_reservation(reservation_id: int):
"""Cancela una reserva."""
cancelled = reservation_service.cancel_reservation(reservation_id)
if not cancelled:
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
return jsonify({'message': f'Reserva {reservation_id} cancelada correctamente.'}), 200
+149
View File
@@ -0,0 +1,149 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
from flask_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models.user import User
from app.forms.auth import LoginForm
from datetime import datetime
auth_bp = Blueprint('auth', __name__)
from sqlalchemy import func
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Handle user login with case-insensitive email lookup and whitespace trimming"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm()
if form.validate_on_submit():
clean_email = (form.email.data or '').strip().lower()
user = User.query.filter(func.lower(User.email) == clean_email).first()
if user and user.check_password(form.password.data):
if not user.is_active:
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
return render_template('auth/login.html', form=form)
login_user(user, remember=form.remember_me.data)
user.update_last_login()
# Registrar en bitácora de auditoría
try:
from app.services.audit_service import AuditService
AuditService.log(
action='LOGIN',
module='auth',
user_id=user.id,
user_email=user.email,
details=f"Inicio de sesión exitoso ({user.role})"
)
except Exception:
pass
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
if user.preferred_language and user.preferred_language in ['es', 'en']:
session['language'] = user.preferred_language
else:
session.pop('language', None)
session.pop('login_lang_explicit', None)
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('main.dashboard')
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
return redirect(next_page)
else:
try:
from app.services.audit_service import AuditService
AuditService.log(
action='LOGIN_FAILED',
module='auth',
details=f"Intento fallido de login con email: {clean_email}"
)
except Exception:
pass
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
return render_template('auth/login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
"""Handle user logout"""
logout_user()
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
session.pop('language', None)
session.pop('login_lang_explicit', None)
flash(_('Has cerrado sesión correctamente.'), 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
"""User profile page with language and theme settings"""
if request.method == 'POST':
lang = request.form.get('preferred_language')
if lang in ['es', 'en']:
current_user.preferred_language = lang
session['language'] = lang
else:
# Cadena vacía o None restablece la detección automática del navegador
current_user.preferred_language = None
session.pop('language', None)
theme = request.form.get('theme_preference')
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/profile.html')
@auth_bp.route('/api/user/theme', methods=['POST'])
@login_required
def update_theme_api():
"""Update user theme preference via fetch/AJAX from navbar toggle"""
data = request.get_json(silent=True) or request.form
theme = data.get('theme') if data else None
if theme in ['auto', 'light', 'dark']:
current_user.theme_preference = theme
db.session.commit()
return jsonify({'status': 'success', 'theme': theme})
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
@auth_bp.route('/change_password', methods=['GET', 'POST'])
@login_required
def change_password():
"""Change user password"""
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('auth.change_password'))
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('auth.change_password'))
if len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'error')
return redirect(url_for('auth.change_password'))
current_user.set_password(new_password)
db.session.commit()
flash('Password changed successfully!', 'success')
return redirect(url_for('auth.profile'))
return render_template('auth/change_password.html')
+193
View File
@@ -0,0 +1,193 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models.building import Building
from app.models.classroom import Classroom
from app.forms.building import BuildingForm
from app.utils.decorators import permission_required
from datetime import datetime
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
@buildings_bp.route('/')
@login_required
def list_buildings():
"""List all buildings with statistics"""
search = request.args.get('search', '').strip()
status_filter = request.args.get('status', '')
query = Building.query
if search:
query = query.filter(
db.or_(
Building.name.ilike(f'%{search}%'),
Building.code.ilike(f'%{search}%'),
Building.address.ilike(f'%{search}%')
)
)
if status_filter == 'active':
query = query.filter(Building.is_active == True)
elif status_filter == 'inactive':
query = query.filter(Building.is_active == False)
buildings = query.order_by(Building.name.asc()).all()
# Calculate global building stats
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
return render_template(
'buildings/list.html',
buildings=buildings,
total_classrooms=total_classrooms,
total_capacity=total_capacity,
search=search,
status_filter=status_filter,
title='Building Management'
)
@buildings_bp.route('/add', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def add_building():
"""Add new building"""
form = BuildingForm()
if form.validate_on_submit():
try:
# Check unique name
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower()
).first()
if existing:
flash('Ya existe un edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
building = Building(
name=form.name.data.strip(),
code=form.code.data.strip().upper() if form.code.data else None,
address=form.address.data.strip() if form.address.data else None,
floors=form.floors.data,
description=form.description.data.strip() if form.description.data else None,
is_active=form.is_active.data
)
db.session.add(building)
db.session.commit()
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al crear el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, title='Add Building')
@buildings_bp.route('/<int:id>')
@login_required
def view_building(id):
"""View building details and its classrooms by floor"""
building = Building.query.get_or_404(id)
# Group active classrooms by floor
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
# Fallback to name matching if building_id was not set
if not classrooms:
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
classrooms_by_floor = {}
for c in classrooms:
floor_key = c.floor
if floor_key not in classrooms_by_floor:
classrooms_by_floor[floor_key] = []
classrooms_by_floor[floor_key].append(c)
return render_template(
'buildings/view.html',
building=building,
classrooms=classrooms,
classrooms_by_floor=classrooms_by_floor,
title=f'Building - {building.name}'
)
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
@permission_required('classrooms', 'read_write')
def edit_building(id):
"""Edit building"""
building = Building.query.get_or_404(id)
form = BuildingForm(obj=building)
if form.validate_on_submit():
try:
# Check unique name excluding current
existing = Building.query.filter(
db.func.lower(Building.name) == form.name.data.lower(),
Building.id != id
).first()
if existing:
flash('Ya existe otro edificio con este nombre.', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
old_name = building.name
new_name = form.name.data.strip()
building.name = new_name
building.code = form.code.data.strip().upper() if form.code.data else None
building.address = form.address.data.strip() if form.address.data else None
building.floors = form.floors.data
building.description = form.description.data.strip() if form.description.data else None
building.is_active = form.is_active.data
building.updated_at = datetime.utcnow()
# If name changed, update string building on classrooms
if old_name != new_name:
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
db.session.commit()
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
return redirect(url_for('buildings.view_building', id=building.id))
except Exception as e:
db.session.rollback()
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def toggle_building(id):
"""Toggle building active status"""
building = Building.query.get_or_404(id)
try:
building.is_active = not building.is_active
building.updated_at = datetime.utcnow()
db.session.commit()
status_text = 'activado' if building.is_active else 'desactivado'
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
@permission_required('classrooms', 'read_write')
def delete_building(id):
"""Delete building if it has no classrooms assigned"""
building = Building.query.get_or_404(id)
has_classrooms = Classroom.query.filter(
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
).count()
if has_classrooms > 0:
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
return redirect(url_for('buildings.view_building', id=building.id))
try:
db.session.delete(building)
db.session.commit()
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
return redirect(url_for('buildings.list_buildings'))
+513
View File
@@ -0,0 +1,513 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from datetime import datetime, date
from app.models.classroom import Classroom
from app.models.user import User
from app.forms.classroom import ClassroomForm
from app import db
from sqlalchemy import text
classrooms_bp = Blueprint('classrooms', __name__)
@classrooms_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_classroom():
"""Add a new classroom with dynamic building creation and floor suggestion"""
from app.models.building import Building
import re
from sqlalchemy import func
form = ClassroomForm()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
# Map of existing floors per building for datalist
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Prevent duplicate room in same building & floor
existing_classroom = Classroom.query.filter_by(
building_id=building.id,
code=room_code,
floor=floor_val
).first()
if existing_classroom:
flash(f'Ya existe un aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 9999
else:
cap_val = int(cap_raw)
from app.services.classroom_service import ClassroomService
from app.schemas.classroom_dto import ClassroomCreateDTO
from app.services.audit_service import AuditService
dto = ClassroomCreateDTO(
building_id=building.id,
code=room_code,
floor=floor_val,
capacity=cap_val,
description=form.description.data,
is_active=form.is_active.data
)
classroom_service = ClassroomService()
classroom = classroom_service.create_classroom(dto)
# Registrar en bitácora inmutable de auditoría
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
return redirect(url_for('classrooms.list_classrooms'))
except ValueError as ve:
flash(str(ve), 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
except Exception as e:
db.session.rollback()
flash(f'Error al crear el aula: {str(e)}', 'error')
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
@classrooms_bp.route('/', endpoint='index')
@classrooms_bp.route('/list')
@login_required
def list_classrooms():
"""List all classrooms with separation between Physical spaces and Virtual on-demand rooms"""
try:
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
from app.models.subject import Commission, Subject
import calendar
from datetime import date, timedelta
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active_only', 'true').lower() == 'true'
search = request.args.get('search', '', type=str).strip()
building_filter = request.args.get('building_id', '', type=str).strip()
floor_filter = request.args.get('floor', '', type=str).strip()
view_mode = request.args.get('view_mode', '').strip().lower()
# Identificar edificio virtual y aula virtual base
virtual_building = Building.query.filter(
db.or_(Building.code == 'VIRTUAL', Building.name.ilike('%Virtual%'))
).first()
virtual_bldg_id = virtual_building.id if virtual_building else None
# Si el usuario seleccionó "Campus Virtual" en el filtro de edificios, conmutar a pestaña virtual
if building_filter and str(building_filter) == str(virtual_bldg_id):
view_mode = 'virtual'
elif not view_mode:
view_mode = 'physical'
# Fechas operativas (Día, Semana, Mes)
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
# --- AULAS FÍSICAS (Presenciales) ---
physical_query = Classroom.query
if virtual_bldg_id:
physical_query = physical_query.filter(Classroom.building_id != virtual_bldg_id)
else:
physical_query = physical_query.filter(~Classroom.code.ilike('%VIRTUAL%'))
if active_only:
physical_query = physical_query.filter_by(is_active=True)
physical_count = physical_query.count()
if building_filter and building_filter.isdigit() and str(building_filter) != str(virtual_bldg_id):
physical_query = physical_query.filter(Classroom.building_id == int(building_filter))
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
physical_query = physical_query.filter(db.or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
physical_query = physical_query.filter(Classroom.floor == flr)
if search and view_mode == 'physical':
physical_query = physical_query.filter(
db.or_(
Classroom.building.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Classroom.description.ilike(f'%{search}%')
)
)
classrooms = physical_query.order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).paginate(
page=page, per_page=20, error_out=False
)
# Edificios y Pisos estrictamente físicos para el filtro
buildings = Building.query.filter_by(is_active=True)
if virtual_bldg_id:
buildings = buildings.filter(Building.id != virtual_bldg_id)
buildings = buildings.order_by(Building.name.asc()).all()
floors = [f[0] for f in db.session.query(Classroom.floor).filter(
Classroom.is_active == True,
Classroom.building_id != virtual_bldg_id if virtual_bldg_id else True,
Classroom.floor.isnot(None)
).distinct().order_by(Classroom.floor.asc()).all()]
# Métricas de ocupación física
visible_ids = [c.id for c in classrooms.items]
month_reservations = Reservation.query.filter(
Reservation.classroom_id.in_(visible_ids),
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all() if visible_ids else []
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
occupancy_metrics = {}
for c in classrooms.items:
c_res = [r for r in month_reservations if r.classroom_id == c.id]
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
occupancy_metrics[c.id] = {
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
}
# --- AULAS VIRTUALES (Campus Virtual a Demanda - Infinitas) ---
vc = Classroom.query.filter(
Classroom.building_id == virtual_bldg_id if virtual_bldg_id else Classroom.code.ilike('%VIRTUAL%')
).first()
vc_id = vc.id if vc else 1
all_vres = Reservation.query.filter(Reservation.classroom_id == vc_id).all() if vc_id else []
# Agrupar las salas virtuales por comisión
comm_dict = {}
for r in all_vres:
c = r.commission
cid = c.id if c else 0
if cid not in comm_dict:
vlink = (c.virtual_link if c and c.virtual_link else None) or r.effective_virtual_link
comm_dict[cid] = {
'commission_id': cid,
'commission_code': c.code if c else 'General',
'subject_name': c.subject.name if (c and c.subject) else (r.purpose or 'Clase Virtual'),
'subject_code': c.subject.code if (c and c.subject) else '',
'career_name': c.subject.career.name if (c and c.subject and hasattr(c.subject, 'career') and c.subject.career) else '',
'max_students': c.max_students if c else (r.expected_attendees or 0),
'virtual_link': vlink,
'reservations': []
}
comm_dict[cid]['reservations'].append(r)
if not comm_dict[cid]['virtual_link'] and r.effective_virtual_link:
comm_dict[cid]['virtual_link'] = r.effective_virtual_link
virtual_rooms = []
for cid, data in comm_dict.items():
res_list = data['reservations']
day_cnt = sum(1 for r in res_list if today_start <= r.start_time <= today_end)
week_cnt = sum(1 for r in res_list if week_start <= r.start_time <= week_end)
month_cnt = sum(1 for r in res_list if month_start <= r.start_time <= month_end)
shift = res_list[0].shift if res_list else 'A Demanda'
if search and view_mode == 'virtual':
s_low = search.lower()
matches = (
s_low in data['subject_name'].lower() or
s_low in data['commission_code'].lower() or
s_low in data['subject_code'].lower() or
s_low in data['career_name'].lower()
)
if not matches:
continue
virtual_rooms.append({
'commission_id': data['commission_id'],
'commission_code': data['commission_code'],
'subject_name': data['subject_name'],
'subject_code': data['subject_code'],
'career_name': data['career_name'],
'max_students': data['max_students'],
'virtual_link': data['virtual_link'],
'shift': shift,
'day_count': day_cnt,
'week_count': week_cnt,
'month_count': month_cnt,
'total_count': len(res_list),
'reservations': sorted(res_list, key=lambda x: x.start_time)
})
virtual_rooms.sort(key=lambda x: (x['subject_name'], x['commission_code']))
global_virtual_metrics = {
'today_count': sum(1 for r in all_vres if today_start <= r.start_time <= today_end),
'week_count': sum(1 for r in all_vres if week_start <= r.start_time <= week_end),
'month_count': sum(1 for r in all_vres if month_start <= r.start_time <= month_end),
'total_count': len(all_vres),
'commissions_count': len(comm_dict)
}
return render_template('classrooms/list.html',
classrooms=classrooms,
buildings=buildings,
floors=floors,
building_filter=building_filter,
floor_filter=floor_filter,
active_only=active_only,
search=search,
view_mode=view_mode,
physical_count=physical_count,
virtual_count=len(comm_dict),
virtual_rooms=virtual_rooms,
virtual_classroom_id=vc_id,
global_virtual_metrics=global_virtual_metrics,
occupancy_metrics=occupancy_metrics,
date_filters=date_filters,
title='Classrooms')
except Exception as e:
flash(f'Error loading classrooms: {str(e)}', 'error')
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
@classrooms_bp.route('/virtual-room/<int:commission_id>/update-link', methods=['POST'])
@login_required
def update_virtual_link(commission_id):
"""Actualizar enlace de videollamada para una comisión virtual"""
from app.models.subject import Commission
from flask_babel import gettext as _
comm = Commission.query.get_or_404(commission_id)
new_link = request.form.get('virtual_link', '').strip() or None
comm.virtual_link = new_link
db.session.commit()
flash(_('Enlace de videollamada para %(code)s actualizado correctamente.', code=comm.get_full_code()), 'success')
return redirect(request.referrer or url_for('classrooms.list_classrooms', view_mode='virtual'))
@classrooms_bp.route('/<int:id>')
@login_required
def view_classroom(id):
"""View classroom details"""
try:
classroom = Classroom.query.get_or_404(id)
from app.models.reservation import Reservation, ReservationStatus
upcoming_reservations = Reservation.query.filter(
Reservation.classroom_id == id,
Reservation.start_time > datetime.utcnow(),
Reservation.status == ReservationStatus.CONFIRMED.value
).order_by(Reservation.start_time).limit(10).all()
return render_template('classrooms/view.html',
classroom=classroom,
upcoming_reservations=upcoming_reservations,
title=f'Classroom {classroom.code_display}')
except Exception as e:
flash(f'Error loading classroom: {str(e)}', 'error')
return redirect(url_for('classrooms.list_classrooms'))
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_classroom(id):
"""Edit classroom with decoupled building and floor"""
from app.models.building import Building
import re
from sqlalchemy import func
classroom = Classroom.query.get_or_404(id)
form = ClassroomForm(obj=classroom)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
form.building_id.choices = [(str(b.id), b.name) for b in buildings] + [('__new__', '+ Crear nuevo edificio')]
building_floors = {str(b.id): list(dict.fromkeys(c.floor for c in b.classrooms if c.floor)) for b in buildings}
if request.method == 'GET':
form.building_id.data = str(classroom.building_id or '')
form.room_number.data = classroom.code
form.floor.data = classroom.floor
if classroom.capacity == 0 or classroom.is_virtual:
form.capacity.data = '∞'
else:
form.capacity.data = str(classroom.capacity)
if form.validate_on_submit():
try:
building = None
if form.building_id.data == '__new__':
new_building_name = form.new_building_name.data.strip() if form.new_building_name.data else ''
if not new_building_name:
flash('Debe ingresar el nombre del nuevo edificio.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
building = Building.query.filter(func.lower(Building.name) == func.lower(new_building_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_building_name.upper()).strip('_')
building = Building(name=new_building_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.commit()
else:
building = Building.query.get_or_404(int(form.building_id.data))
room_code = form.room_number.data.strip()
floor_val = str(form.floor.data).strip()
# Check duplicate excluding current
existing_classroom = Classroom.query.filter(
Classroom.building_id == building.id,
Classroom.code == room_code,
Classroom.floor == floor_val,
Classroom.id != id
).first()
if existing_classroom:
flash(f'Ya existe otra aula con el nombre "{room_code}" en el piso {floor_val} de {building.name}.', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
cap_raw = str(form.capacity.data or '').strip()
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
cap_val = 0
else:
cap_val = int(cap_raw)
classroom.building_id = building.id
classroom.building = building.name
classroom.code = room_code
classroom.floor = floor_val
classroom.capacity = cap_val
classroom.description = form.description.data
classroom.is_active = form.is_active.data
classroom.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Aula {classroom.location_display} actualizada correctamente.', 'success')
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom: {str(e)}', 'error')
return render_template('classrooms/edit.html', form=form, classroom=classroom, building_floors=building_floors, title='Edit Classroom')
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
@login_required
def toggle_classroom(id):
"""Toggle classroom active status"""
classroom = Classroom.query.get_or_404(id)
try:
classroom.is_active = not classroom.is_active
classroom.updated_at = datetime.utcnow()
db.session.commit()
status = 'activated' if classroom.is_active else 'deactivated'
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error updating classroom status: {str(e)}', 'error')
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
# API endpoints for AJAX requests
@classrooms_bp.route('/api/available')
@login_required
def get_available_classrooms():
"""Get available classrooms for a specific time slot"""
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
if not start_time or not end_time:
return jsonify({'error': 'Start time and end time are required'}), 400
try:
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
start_dt = datetime.fromisoformat(start_time)
end_dt = datetime.fromisoformat(end_time)
# Find all classrooms
all_classrooms = Classroom.query.filter_by(is_active=True).all()
available_classrooms = []
for classroom in all_classrooms:
# Check if classroom has conflicting reservations
conflicts = Reservation.query.filter(
Reservation.classroom_id == classroom.id,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < end_dt,
Reservation.end_time > start_dt
).all()
if not conflicts:
available_classrooms.append({
'id': classroom.id,
'code': classroom.code,
'building': classroom.building,
'room_number': classroom.code,
'floor': classroom.floor,
'capacity': classroom.capacity,
'code_display': classroom.code_display
})
return jsonify({'classrooms': available_classrooms})
except Exception as e:
return jsonify({'error': str(e)}), 500
+255
View File
@@ -0,0 +1,255 @@
from flask import Blueprint, request, jsonify, current_app, render_template
from datetime import datetime, timedelta
from app.models.genetic_algorithm import ReservationOptimizer
from app.models.reservation import Reservation, ReservationStatus
from app import db
from flask_login import login_required, current_user
genetic_bp = Blueprint('genetic_algorithm', __name__, url_prefix='/api/genetic')
@genetic_bp.route('/optimize', methods=['POST'])
@login_required
def optimize_reservations():
"""Optimize reservations using genetic algorithm"""
try:
data = request.get_json()
if not data or 'commission_ids' not in data:
return jsonify({'error': 'Missing commission_ids in request'}), 400
commission_ids = data['commission_ids']
if not isinstance(commission_ids, list) or not commission_ids:
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Optional parameters
start_date = None
end_date = None
if 'start_date' in data:
try:
start_date = datetime.fromisoformat(data['start_date'])
except ValueError:
return jsonify({'error': 'Invalid start_date format. Use ISO format.'}), 400
if 'end_date' in data:
try:
end_date = datetime.fromisoformat(data['end_date'])
except ValueError:
return jsonify({'error': 'Invalid end_date format. Use ISO format.'}), 400
# Check if user has permission (admin, teacher or RBAC optimizer write)
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
return jsonify({'error': 'Insufficient permissions to optimize reservations'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=current_user.id,
start_date=start_date,
end_date=end_date
)
return jsonify({
'success': True,
'data': {
'fitness_score': result['fitness_score'],
'total_requests': result['total_requests'],
'assigned_reservations': result['assigned_reservations'],
'reservations': result['reservations'],
'conflicts': result['conflicts']
}
}), 200
except Exception as e:
current_app.logger.error(f"Error in genetic algorithm optimization: {str(e)}")
return jsonify({'error': f'Optimization failed: {str(e)}'}), 500
@genetic_bp.route('/apply-optimization', methods=['POST'])
@login_required
def apply_optimization():
"""Apply optimized reservations to the database"""
try:
data = request.get_json()
if not data or 'reservations' not in data:
return jsonify({'error': 'Missing reservations data'}), 400
reservations_data = data['reservations']
if not isinstance(reservations_data, list):
return jsonify({'error': 'reservations must be a list'}), 400
# Check if user has permission
if not (current_user.is_admin() or current_user.has_permission('optimizer', 'read_write') or current_user.role in ['ADMIN', 'teacher']):
return jsonify({'error': 'Insufficient permissions to apply reservations'}), 403
# Initialize optimizer and apply reservations
optimizer = ReservationOptimizer()
applied_reservations = optimizer.apply_optimized_reservations(reservations_data)
return jsonify({
'success': True,
'data': {
'applied_count': len(applied_reservations),
'reservations': [res.to_dict() for res in applied_reservations]
}
}), 200
except Exception as e:
current_app.logger.error(f"Error applying optimized reservations: {str(e)}")
db.session.rollback()
return jsonify({'error': f'Failed to apply reservations: {str(e)}'}), 500
@genetic_bp.route('/preview-optimization', methods=['POST'])
@login_required
def preview_optimization():
"""Preview optimization without applying to database"""
try:
data = request.get_json()
if not data or 'commission_ids' not in data:
return jsonify({'error': 'Missing commission_ids in request'}), 400
commission_ids = data['commission_ids']
# Optional parameters
dry_run = data.get('dry_run', True) # Default to dry run
if not isinstance(commission_ids, list) or not commission_ids:
return jsonify({'error': 'commission_ids must be a non-empty list'}), 400
# Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions to preview optimization'}), 403
# Initialize optimizer and run optimization
optimizer = ReservationOptimizer()
result = optimizer.optimize_schedule(
commission_ids=commission_ids,
admin_user_id=current_user.id
)
# Add additional preview information
preview_data = {
'optimization_result': result,
'commission_details': [],
'classroom_utilization': {},
'time_distribution': {}
}
# Get commission details
from app.models.subject import Commission, Subject
commissions = Commission.query.filter(Commission.id.in_(commission_ids)).all()
for commission in commissions:
preview_data['commission_details'].append({
'id': commission.id,
'code': commission.get_full_code(),
'name': commission.subject.name if commission.subject else 'Unknown',
'max_students': commission.max_students,
'current_students': commission.current_students
})
return jsonify({
'success': True,
'data': preview_data
}), 200
except Exception as e:
current_app.logger.error(f"Error in optimization preview: {str(e)}")
return jsonify({'error': f'Preview failed: {str(e)}'}), 500
@genetic_bp.route('/commissions', methods=['GET'])
@login_required
def get_commissions():
"""Get available commissions for optimization"""
try:
# Check if user has permission
if current_user.role not in ['ADMIN', 'teacher']:
return jsonify({'error': 'Insufficient permissions'}), 403
from app.models.subject import Commission, Subject
# Get active commissions
commissions = Commission.query.filter_by(active=True).all()
commissions_data = []
for commission in commissions:
commission_dict = commission.to_dict()
commission_dict['get_full_code'] = commission.get_full_code()
commissions_data.append(commission_dict)
return jsonify({
'success': True,
'commissions': commissions_data
}), 200
except Exception as e:
current_app.logger.error(f"Error getting commissions: {str(e)}")
return jsonify({'error': f'Failed to load commissions: {str(e)}'}), 500
@genetic_bp.route('/algorithm-status', methods=['GET'])
@login_required
def get_algorithm_status():
"""Get genetic algorithm configuration status"""
try:
# Check if user has permission
if current_user.role not in ['ADMIN']:
return jsonify({'error': 'Insufficient permissions'}), 403
# Get system status
from app.models.classroom import Classroom
from app.models.subject import Commission
total_classrooms = Classroom.query.filter_by(is_active=True).count()
total_commissions = Commission.query.filter_by(active=True).count()
total_reservations = Reservation.query.filter_by(status=ReservationStatus.CONFIRMED.value).count()
return jsonify({
'success': True,
'data': {
'algorithm_config': {
'population_size': 50,
'generations': 100,
'mutation_rate': 0.1,
'crossover_rate': 0.8
},
'system_status': {
'available_classrooms': total_classrooms,
'active_commissions': total_commissions,
'confirmed_reservations': total_reservations
},
'capabilities': [
'Automatic classroom assignment',
'Capacity optimization',
'Time conflict resolution',
'Resource matching',
'Batch reservation processing'
]
}
}), 200
except Exception as e:
current_app.logger.error(f"Error getting algorithm status: {str(e)}")
return jsonify({'error': f'Status check failed: {str(e)}'}), 500
# Web interface route (without API prefix)
genetic_web_bp = Blueprint('genetic_algorithm_web', __name__)
@genetic_web_bp.route('/genetic-optimizer')
@login_required
def genetic_optimizer():
"""Render the genetic algorithm optimizer interface"""
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
return render_template('auth/403.html'), 403
return render_template('genetic_optimizer.html')
+333
View File
@@ -0,0 +1,333 @@
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation
from app.models.classroom import Classroom
from app.models.subject import Subject
from app.models.subject import Commission
from datetime import datetime, timedelta
from sqlalchemy import func
from flask_babel import gettext as _
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
@login_required
def dashboard():
"""Role-personalized institutional dashboard page"""
from app.models.user import User
from app.models.milestone import AcademicMilestone
from app.models.audit_log import AuditLog
actual_role = current_user.get_institutional_role()
view_as = request.args.get('view_as', '').lower().strip()
# Allow admins and management roles to preview dashboard perspectives
if view_as in ['admin', 'bedelia', 'docente', 'alumno'] and (current_user.is_admin() or current_user.can_manage()):
role = view_as
else:
role = actual_role
today = datetime.utcnow().date()
# Common core statistics
try:
total_classrooms = Classroom.query.filter_by(is_active=True).count()
except Exception:
total_classrooms = 0
try:
total_subjects = Subject.query.filter_by(is_active=True).count()
except Exception:
total_subjects = 0
try:
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
except Exception:
today_reservations = 0
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
try:
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
except Exception:
week_reservations = 0
stats = {
'total_classrooms': total_classrooms,
'total_subjects': total_subjects,
'today_reservations': today_reservations,
'week_reservations': week_reservations
}
role_data = {}
if role == 'admin':
# 1. Admin Institutional Cockpit
try:
total_users = User.query.filter_by(is_active=True).count()
except Exception:
total_users = 0
try:
pending_count = Reservation.query.filter(
Reservation.status == 'PENDING'
).count()
except Exception:
pending_count = 0
try:
recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all()
except Exception:
recent_audits = []
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).order_by(Reservation.start_time.asc()).limit(8).all()
except Exception:
upcoming_reservations = []
stats.update({
'total_users': total_users,
'pending_reservations': pending_count
})
role_data = {
'recent_audits': recent_audits,
'upcoming_reservations': upcoming_reservations
}
elif role == 'bedelia':
# 2. Bedelía Operations & Facilities Control
try:
physical_classrooms = Classroom.query.filter_by(is_active=True).filter(
~Classroom.room_number.ilike('%VIRTUAL%') & ~Classroom.building.ilike('%VIRTUAL%')
).count()
virtual_classrooms = total_classrooms - physical_classrooms
if virtual_classrooms < 0:
virtual_classrooms = 0
except Exception:
physical_classrooms = total_classrooms
virtual_classrooms = 0
try:
pending_reservations = Reservation.query.filter(
Reservation.status == 'PENDING'
).order_by(Reservation.start_time.asc()).limit(10).all()
pending_count = len(pending_reservations)
except Exception:
pending_reservations = []
pending_count = 0
try:
today_schedule_list = Reservation.query.filter(
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).limit(12).all()
except Exception:
today_schedule_list = []
occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0
stats.update({
'physical_classrooms': physical_classrooms,
'virtual_classrooms': virtual_classrooms,
'pending_count': pending_count,
'occupancy_rate': occupancy_rate
})
role_data = {
'pending_reservations': pending_reservations,
'today_schedule_list': today_schedule_list
}
elif role == 'docente':
# 3. Docente (Profesor) Academic & Classroom Workflow
try:
my_today_classes = Reservation.query.filter(
Reservation.user_id == current_user.id,
func.date(Reservation.start_time) == today
).order_by(Reservation.start_time.asc()).all()
except Exception:
my_today_classes = []
try:
my_upcoming_reservations = Reservation.query.filter(
Reservation.user_id == current_user.id,
Reservation.start_time >= datetime.utcnow()
).order_by(Reservation.start_time.asc()).limit(8).all()
except Exception:
my_upcoming_reservations = []
try:
my_total_reservations = Reservation.query.filter(
Reservation.user_id == current_user.id
).count()
except Exception:
my_total_reservations = 0
try:
virtual_classrooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).limit(4).all()
except Exception:
virtual_classrooms = []
stats.update({
'my_today_count': len(my_today_classes),
'my_total_reservations': my_total_reservations,
'virtual_rooms_count': len(virtual_classrooms)
})
role_data = {
'my_today_classes': my_today_classes,
'my_upcoming_reservations': my_upcoming_reservations,
'virtual_classrooms': virtual_classrooms
}
elif role == 'alumno':
# 4. Alumno (Estudiante) Daily Guide & Timetable
try:
today_classes = Reservation.query.filter(
func.date(Reservation.start_time) == today,
Reservation.status == 'CONFIRMED'
).order_by(Reservation.start_time.asc()).limit(12).all()
except Exception:
today_classes = []
try:
upcoming_milestones = AcademicMilestone.query.filter(
AcademicMilestone.date >= datetime.utcnow(),
AcademicMilestone.is_active == True
).order_by(AcademicMilestone.date.asc()).limit(6).all()
except Exception:
upcoming_milestones = []
try:
virtual_rooms = Classroom.query.filter_by(is_active=True).filter(
(Classroom.room_number.ilike('%VIRTUAL%')) | (Classroom.building.ilike('%VIRTUAL%'))
).limit(4).all()
except Exception:
virtual_rooms = []
stats.update({
'today_classes_count': len(today_classes),
'milestones_count': len(upcoming_milestones)
})
role_data = {
'today_classes': today_classes,
'upcoming_milestones': upcoming_milestones,
'virtual_rooms': virtual_rooms
}
# Backward compatibility for upcoming_reservations
upcoming_reservations = role_data.get('upcoming_reservations', [])
if not upcoming_reservations:
try:
upcoming_reservations = Reservation.query.filter(
Reservation.start_time >= datetime.utcnow(),
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
).order_by(Reservation.start_time.asc()).limit(6).all()
except Exception:
upcoming_reservations = []
return render_template('dashboard.html',
role=role,
actual_role=actual_role,
stats=stats,
role_data=role_data,
upcoming_reservations=upcoming_reservations)
@main_bp.route('/api/dashboard-stats')
@login_required
def dashboard_stats_api():
"""API endpoint for dashboard statistics"""
# Get today's data
today = datetime.utcnow().date()
today_reservations = Reservation.query.filter(
func.date(Reservation.start_time) == today
).count()
# Get this week's data
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
week_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= week_start,
func.date(Reservation.start_time) <= week_end
).count()
# Get monthly data for chart
current_date = datetime.utcnow()
monthly_data = []
for i in range(12):
month_date = current_date - timedelta(days=30 * i)
month_start = month_date.replace(day=1)
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
monthly_reservations = Reservation.query.filter(
func.date(Reservation.start_time) >= month_start.date(),
func.date(Reservation.start_time) <= month_end.date()
).count()
monthly_data.append({
'month': month_start.strftime('%Y-%m'),
'reservations': monthly_reservations
})
return jsonify({
'today_reservations': today_reservations,
'week_reservations': week_reservations,
'monthly_data': list(reversed(monthly_data))
})
@main_bp.route('/genetic-optimizer')
@login_required
def genetic_optimizer():
"""Serve the genetic algorithm optimization interface"""
if not (current_user.has_permission('optimizer', 'read') or (current_user.role and current_user.role.upper() == 'ADMIN')):
flash(_('No tienes permisos para acceder a la herramienta de optimización.'), 'danger')
return redirect(url_for('main.dashboard'))
return render_template('genetic_optimizer.html')
@main_bp.route('/set_language/<language>')
def set_language(language=None):
if language not in ['en', 'es']:
language = 'es'
session['language'] = language
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
if request.referrer and 'login' in request.referrer:
session['login_lang_explicit'] = language
if current_user.is_authenticated:
current_user.preferred_language = language
db.session.commit()
return redirect(request.referrer or url_for('main.dashboard'))
@main_bp.route('/api/milestone-types')
def api_milestone_types():
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])␍
+126
View File
@@ -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
)
+975
View File
@@ -0,0 +1,975 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app import db
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.models.subject import Subject, Commission
from app.forms.reservation import ReservationForm
from datetime import datetime, timedelta, date
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
@schedule_bp.route('/today')
@login_required
def today_schedule():
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
# Base query for today's reservations
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user)
).join(Reservation.classroom).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
)
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
reservations = query.order_by(Reservation.start_time).all()
# Create time blocks for timeline view
time_blocks = {}
for reservation in reservations:
start_hour = reservation.start_time.hour
end_hour = reservation.end_time.hour
for hour in range(max(7, start_hour), min(22, end_hour + 1)):
if hour not in time_blocks:
time_blocks[hour] = []
if reservation not in time_blocks[hour]:
time_blocks[hour].append({
'reservation': reservation,
'classroom': reservation.classroom,
'commission': reservation.commission,
'user': reservation.user
})
# Calculate stats
total_reservations = len(reservations)
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
total_classrooms = Classroom.query.count()
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
stats = {
'total_reservations': total_reservations,
'confirmed': confirmed,
'pending': pending,
'occupied_classrooms': occupied_classrooms,
'total_classrooms': total_classrooms
}
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template('schedule/today.html',
reservations=reservations,
time_blocks=time_blocks,
stats=stats,
today=today,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
available_floors=available_floors)
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
@login_required
def cancel_reservation(id):
"""Cancel a reservation"""
try:
reservation = Reservation.query.get_or_404(id)
if not reservation.can_be_cancelled():
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
return redirect(request.referrer)
reservation.status = ReservationStatus.CANCELLED
db.session.commit()
flash('Reservation cancelled successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error cancelling reservation: {str(e)}', 'error')
return redirect(request.referrer)
@schedule_bp.route('/reservation/<int:id>/view')
@login_required
def view_reservation(id):
"""View reservation details"""
try:
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
return render_template('schedule/view.html',
reservation=reservation,
title='Reservation Details')
except Exception as e:
flash(f'Error loading reservation details: {str(e)}', 'error')
return redirect(url_for('schedule.today_schedule'))
@schedule_bp.route('/reservation/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_reservation(id):
"""Edit existing reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
from flask_babel import gettext as _
reservation = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission),
joinedload(Reservation.user)
).get_or_404(id)
if not current_user.can_edit_reservation(reservation):
flash(_('No tienes los permisos necesarios para modificar esta reserva.'), 'error')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
form = ReservationForm(is_edit=True)
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'GET':
form.classroom_id.data = reservation.classroom_id
form.commission_id.data = reservation.commission_id
form.start_time.data = reservation.start_time
form.end_time.data = reservation.end_time
form.purpose.data = reservation.purpose
form.expected_attendees.data = reservation.expected_attendees
form.notes.data = reservation.notes or ''
form.virtual_link.data = reservation.virtual_link or ''
elif request.method == 'POST':
if form.validate_on_submit():
try:
# Verificar colisión de horario si no es virtual
target_room = Classroom.query.get(form.classroom_id.data)
if target_room and not target_room.is_virtual:
conflict = Reservation.query.filter(
Reservation.id != reservation.id,
Reservation.classroom_id == form.classroom_id.data,
Reservation.status == ReservationStatus.CONFIRMED.value,
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if conflict:
flash(_('El aula seleccionada ya cuenta con una reserva confirmada en esa franja horaria.'), 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Asignar cambios
reservation.classroom_id = form.classroom_id.data
reservation.commission_id = form.commission_id.data
reservation.start_time = form.start_time.data
reservation.end_time = form.end_time.data
reservation.purpose = form.purpose.data
reservation.expected_attendees = form.expected_attendees.data
reservation.notes = form.notes.data
reservation.virtual_link = form.virtual_link.data.strip() if form.virtual_link.data else None
# Turno
h = reservation.start_time.hour
if h < 13:
reservation.shift = 'Mañana'
elif h < 18:
reservation.shift = 'Tarde'
elif h < 21:
reservation.shift = 'Vespertino'
else:
reservation.shift = 'Noche'
db.session.commit()
flash(_('Reserva #%(id)d modificada exitosamente.', id=reservation.id), 'success')
return redirect(url_for('schedule.view_reservation', id=reservation.id))
except Exception as e:
db.session.rollback()
flash(f'Error al modificar reserva: {str(e)}', 'danger')
return render_template('schedule/edit.html', form=form, reservation=reservation, classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/calendar')
@login_required
def calendar_view():
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
from app.models.career import Career
from app.models.building import Building
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
return render_template(
'schedule/calendar.html',
title='Calendar View',
today_date=date.today().isoformat(),
classrooms=classrooms,
subjects=subjects,
careers=careers,
buildings=buildings,
available_floors=available_floors
)
def user_can_manage_commission_milestones(user, commission=None):
"""Verifica si el usuario (Docente, Admin, Bedelía) puede gestionar hitos y links virtuales"""
if user.is_admin():
return True
role_name = (user.role_obj.name if user.role_obj else user.role or '').lower()
if role_name in ['admin', 'bedelia', 'bedelía', 'operador']:
return True
if role_name in ['docente', 'profesor']:
return True
return user.has_permission('schedule', 'read_write')
@schedule_bp.route('/commissions')
@login_required
def commissions_view():
"""Vista y ABM de Comisiones con soporte de enlace virtual e hitos académicos"""
from app.models.subject import Commission, Subject
from app.models.classroom import Classroom
from app.models.user import User
from app.models.milestone import MilestoneType
subjects = Subject.query.order_by(Subject.code.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.code.asc()).all()
teachers = User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
milestone_types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc()).all()
# Totales para indicadores superiores
total_capacity = sum(c.max_students for s in subjects for c in s.commissions)
total_commissions = sum(len(s.commissions) for s in subjects)
can_manage_milestones = user_can_manage_commission_milestones(current_user)
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
can_edit_commissions = current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')
return render_template('schedule/commissions.html',
subjects=subjects,
classrooms=classrooms,
teachers=teachers,
milestone_types=milestone_types,
total_capacity=total_capacity,
total_commissions=total_commissions,
can_manage_milestones=can_manage_milestones,
can_edit_commissions=can_edit_commissions)
@schedule_bp.route('/commissions/add', methods=['POST'])
@login_required
def commission_add():
"""Crear una nueva comisión de cursada"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para crear comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
subject_id = request.form.get('subject_id', type=int)
code = request.form.get('code', '').strip()
semester = request.form.get('semester', '1C').strip()
year = request.form.get('year', datetime.now().year, type=int)
teacher_id = request.form.get('teacher_id', type=int) or None
max_students = request.form.get('max_students', 40, type=int)
schedule = request.form.get('schedule', '').strip()
shift = request.form.get('shift', 'Tarde').strip()
virtual_link = request.form.get('virtual_link', '').strip()
if not subject_id or not code:
flash(_('La asignatura y el código de comisión son obligatorios.'), 'error')
return redirect(url_for('schedule.commissions_view'))
# Verificar existencia duplicada
existing = Commission.query.filter_by(subject_id=subject_id, code=code, semester=semester, year=year).first()
if existing:
flash(_('Ya existe una comisión %(code)s para el ciclo %(sem)s %(yr)d.', code=code, sem=semester, yr=year), 'error')
return redirect(url_for('schedule.commissions_view'))
new_comm = Commission(
subject_id=subject_id,
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link or None,
active=True
)
db.session.add(new_comm)
db.session.commit()
flash(_('Comisión %(code)s creada exitosamente.', code=new_comm.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/edit', methods=['POST'])
@login_required
def commission_edit(id):
"""Editar datos de una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para modificar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
commission.code = request.form.get('code', commission.code).strip()
commission.semester = request.form.get('semester', commission.semester).strip()
commission.year = request.form.get('year', commission.year, type=int)
commission.teacher_id = request.form.get('teacher_id', type=int) or None
commission.max_students = request.form.get('max_students', commission.max_students, type=int)
commission.schedule = request.form.get('schedule', commission.schedule).strip()
commission.shift = request.form.get('shift', commission.shift).strip()
commission.virtual_link = request.form.get('virtual_link', '').strip() or None
commission.active = True if request.form.get('active') in ['1', 'true', 'on'] else False
db.session.commit()
flash(_('Comisión %(code)s actualizada con éxito.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/delete', methods=['POST'])
@login_required
def commission_delete(id):
"""Eliminar una comisión"""
role_name = (current_user.role_obj.name if current_user.role_obj else current_user.role or '').lower()
if not (current_user.is_admin() or role_name in ['admin', 'bedelia', 'bedelía', 'operador'] or current_user.has_permission('schedule', 'read_write')):
flash(_('No tienes permisos para eliminar comisiones.'), 'error')
return redirect(url_for('schedule.commissions_view'))
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
full_code = commission.get_full_code()
db.session.delete(commission)
db.session.commit()
flash(_('Comisión %(code)s eliminada permanentemente.', code=full_code), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/virtual-link', methods=['POST'])
@login_required
def commission_virtual_link(id):
"""Actualizar enlace del aula virtual (Zoom, Meet, Teams, Moodle) - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para actualizar el aula virtual de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
link = request.form.get('virtual_link', '').strip()
commission.virtual_link = link or None
db.session.commit()
flash(_('Enlace del aula virtual actualizado para la comisión %(code)s.', code=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:id>/milestones/add', methods=['POST'])
@login_required
def commission_milestone_add(id):
"""Agregar un hito académico (Parcial, TP, Recuperatorio) a una comisión - Profesores, Admin y Bedelía"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone, MilestoneType
commission = Commission.query.get_or_404(id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para agregar hitos a esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone_type_id = request.form.get('milestone_type_id', type=int)
title = request.form.get('title', '').strip()
date_str = request.form.get('date', '').strip()
time_str = request.form.get('time', '18:00').strip()
classroom = request.form.get('classroom', '').strip()
link = request.form.get('link', '').strip() or commission.virtual_link or ''
notes = request.form.get('notes', '').strip()
if not milestone_type_id or not date_str:
flash(_('Debes especificar el tipo de hito y la fecha.'), 'error')
return redirect(url_for('schedule.commissions_view'))
m_type = MilestoneType.query.get(milestone_type_id)
if not m_type:
flash(_('Tipo de hito inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
except ValueError:
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
flash(_('Formato de fecha inválido.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone(
title=title or m_type.name,
materia_codigo=commission.subject.code if commission.subject else 'ASIG',
subject_id=commission.subject_id,
commission_id=commission.id,
milestone_type_id=m_type.id,
date=dt,
classroom=classroom or 'Campus Virtual',
link=link or None,
notes=notes or None,
is_active=True
)
db.session.add(milestone)
db.session.commit()
flash(_('Hito "%(title)s" registrado correctamente en %(comm)s.', title=milestone.title, comm=commission.get_full_code()), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/commissions/<int:commission_id>/milestones/<int:milestone_id>/delete', methods=['POST'])
@login_required
def commission_milestone_delete(commission_id, milestone_id):
"""Eliminar un hito académico de una comisión"""
from app.models.subject import Commission
from app.models.milestone import AcademicMilestone
commission = Commission.query.get_or_404(commission_id)
if not user_can_manage_commission_milestones(current_user, commission):
flash(_('No tienes permisos para eliminar hitos de esta comisión.'), 'error')
return redirect(url_for('schedule.commissions_view'))
milestone = AcademicMilestone.query.filter_by(id=milestone_id, commission_id=commission_id).first_or_404()
title = milestone.title
db.session.delete(milestone)
db.session.commit()
flash(_('Hito "%(title)s" eliminado de la comisión.', title=title), 'success')
return redirect(url_for('schedule.commissions_view'))
@schedule_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_reservation():
"""Add new reservation"""
from app.models.classroom import Classroom
from app.models.subject import Commission
from app.models.building import Building
form = ReservationForm()
# Get active buildings and classrooms
buildings = Building.query.filter_by(is_active=True).order_by(Building.name.asc()).all()
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
commissions = Commission.query.filter_by(active=True).all()
# Populate form choices
form.classroom_id.choices = [(c.id, f'{c.building_name} - {c.code} (Cap: {c.capacity_display})') for c in classrooms]
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.subject.name if c.subject else "No Subject"} (Students: {c.max_students})') for c in commissions]
if request.method == 'POST':
if form.validate_on_submit():
try:
# Check for existing reservations at the same time
from app.models.reservation import ReservationStatus
existing_reservation = Reservation.query.filter_by(
classroom_id=form.classroom_id.data,
status=ReservationStatus.CONFIRMED.value
).filter(
Reservation.start_time < form.end_time.data,
Reservation.end_time > form.start_time.data
).first()
if existing_reservation:
flash('This classroom is already booked for the selected time slot.', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
# Create new reservation
reservation = Reservation(
classroom_id=form.classroom_id.data,
commission_id=form.commission_id.data,
user_id=current_user.id,
start_time=form.start_time.data,
end_time=form.end_time.data,
purpose=form.purpose.data,
notes=form.notes.data,
virtual_link=form.virtual_link.data.strip() if form.virtual_link.data else None,
status=ReservationStatus.PENDING.value,
expected_attendees=form.expected_attendees.data
)
db.session.add(reservation)
db.session.commit()
flash('Reservation request submitted successfully! It will be reviewed by an administrator.', 'success')
return redirect(url_for('schedule.list_reservations'))
except Exception as e:
db.session.rollback()
flash(f'Error creating reservation: {str(e)}', 'danger')
return render_template('schedule/add.html', form=form, title='New Reservation', classroom_list=classrooms, commission_list=commissions, buildings=buildings)
@schedule_bp.route('/reservation/add', methods=['POST'])
@login_required
def quick_reservation_add():
"""Endpoint AJAX para crear reserva rápida desde el modal de comisiones"""
try:
commission_id = request.form.get('commission_id', type=int)
classroom_id = request.form.get('classroom_id', type=int)
purpose = request.form.get('purpose', '').strip() or 'Clase de cursada'
start_str = request.form.get('start_time', '').strip()
end_str = request.form.get('end_time', '').strip()
virtual_link = request.form.get('virtual_link', '').strip() or None
if not commission_id or not classroom_id or not start_str or not end_str:
return jsonify({'error': 'Faltan campos obligatorios'}), 400
start_time = datetime.strptime(start_str, '%Y-%m-%d %H:%M')
end_time = datetime.strptime(end_str, '%Y-%m-%d %H:%M')
from app.models.subject import Commission
from app.models.classroom import Classroom
comm = Commission.query.get_or_404(commission_id)
if not virtual_link and comm.virtual_link:
virtual_link = comm.virtual_link
new_res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=comm.teacher_id or current_user.id,
start_time=start_time,
end_time=end_time,
purpose=purpose,
expected_attendees=comm.max_students or 30,
shift=comm.shift or 'Tarde',
virtual_link=virtual_link,
status=ReservationStatus.CONFIRMED.value if current_user.is_admin() else ReservationStatus.PENDING.value
)
db.session.add(new_res)
db.session.commit()
return jsonify({'success': True, 'id': new_res.id})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/list')
@login_required
def list_reservations():
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
page = request.args.get('page', 1, type=int)
status_filter = request.args.get('status', 'all').strip()
date_from = request.args.get('date_from', '').strip()
date_to = request.args.get('date_to', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
floor_filter = request.args.get('floor', '').strip()
shift_filter = request.args.get('shift', '').strip()
classroom_id = request.args.get('classroom_id', type=int)
search = request.args.get('search', '').strip()
query = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject),
joinedload(Reservation.user)
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
if classroom_id:
query = query.filter(Reservation.classroom_id == classroom_id)
if status_filter and status_filter != 'all':
query = query.filter(Reservation.status == status_filter.upper())
if date_from:
try:
d_from = datetime.strptime(date_from, '%Y-%m-%d')
query = query.filter(Reservation.start_time >= d_from)
except ValueError:
pass
if date_to:
try:
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
query = query.filter(Reservation.start_time < d_to)
except ValueError:
pass
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor_filter:
flr = str(floor_filter).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if shift_filter:
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Commission.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%'),
Reservation.shift.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time.desc()).paginate(
page=page, per_page=20, error_out=False
)
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
available_floors = [f[0] for f in floors]
all_classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building_id.asc(), Classroom.floor.asc(), Classroom.code.asc()).all()
return render_template(
'schedule/list.html',
reservations=reservations,
status_filter=status_filter,
date_from=date_from,
date_to=date_to,
hide_virtual=hide_virtual,
floor_filter=floor_filter,
shift_filter=shift_filter,
classroom_id=classroom_id,
all_classrooms=all_classrooms,
search=search,
available_floors=available_floors
)
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
@login_required
def confirm_reservation(id):
"""Confirm a reservation (Handles both GET from link and POST from form)"""
try:
reservation = Reservation.query.get_or_404(id)
# Validación de seguridad: Solo confirmar si está pendiente
if reservation.status != ReservationStatus.PENDING.value:
flash('Only pending reservations can be confirmed.', 'warning')
return redirect(url_for('schedule.today_schedule'))
reservation.status = ReservationStatus.CONFIRMED.value
db.session.commit()
flash(
f'Reservation for {reservation.commission.code if reservation.commission else "Class"} confirmed successfully!',
'success')
except Exception as e:
db.session.rollback()
flash(f'Error confirming reservation: {str(e)}', 'error')
# Redirigir a la página desde la que venía el usuario o al inicio de hoy
return redirect(request.referrer or url_for('schedule.today_schedule'))
# API endpoints
@schedule_bp.route('/api/classroom-availability')
@login_required
def classroom_availability_api():
"""Get classroom availability for a specific date/time"""
try:
date_str = request.args.get('date')
start_time_str = request.args.get('start_time')
end_time_str = request.args.get('end_time')
max_students = request.args.get('max_students', type=int)
if not all([date_str, start_time_str, end_time_str]):
return jsonify({'error': 'Missing required parameters: date, start_time, end_time'}), 400
# Parse datetime strings
from datetime import datetime
start_time = datetime.strptime(f"{date_str} {start_time_str}", "%Y-%m-%d %H:%M")
end_time = datetime.strptime(f"{date_str} {end_time_str}", "%Y-%m-%d %H:%M")
# Get availability status
from app.models.classroom import Classroom
availability = Classroom.get_availability_status(start_time, end_time)
# Get best classroom recommendation if max_students provided
best_recommendation = None
if max_students:
best_classrooms = Classroom.find_best_classroom(max_students, start_time, end_time)
if best_classrooms:
best_recommendation = best_classrooms[0] # Most efficient one
# Format response
response = {
'availability': {
'available': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location()
}
for item in availability['available']
],
'occupied': [
{
'id': item['classroom'].id,
'code': item['classroom'].code_display,
'name': item['classroom'].name,
'capacity': item['classroom'].capacity,
'location': item['classroom'].get_full_location(),
'conflicts': [
{
'start_time': conflict.start_time.strftime('%H:%M'),
'end_time': conflict.end_time.strftime('%H:%M'),
'commission': conflict.commission.get_full_code() if conflict.commission else 'Unknown'
}
for conflict in item['conflicts']
]
}
for item in availability['occupied']
],
'stats': {
'total_available': availability['total_available'],
'total_occupied': availability['total_occupied']
}
}
}
if best_recommendation:
response['recommendation'] = {
'classroom': {
'id': best_recommendation['classroom'].id,
'code': best_recommendation['classroom'].code_display,
'name': best_recommendation['classroom'].name,
'capacity': best_recommendation['classroom'].capacity,
'location': best_recommendation['classroom'].get_full_location()
},
'efficiency': best_recommendation['efficiency'],
'wasted_capacity': best_recommendation['wasted_capacity'],
'reason': 'Best match based on capacity and availability'
}
return jsonify(response)
except ValueError as e:
return jsonify({'error': f'Invalid date/time format: {str(e)}'}), 400
except Exception as e:
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/calendar-data')
@login_required
def calendar_data():
"""Get calendar events data for FullCalendar"""
try:
start = request.args.get('start')
end = request.args.get('end')
classroom_id = request.args.get('classroom_id')
subject_id = request.args.get('subject_id')
career_id = request.args.get('career_id')
shift = request.args.get('shift', '').strip()
floor = request.args.get('floor', '').strip()
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
search = request.args.get('search', '').strip()
if start and end:
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
start_clean = start.replace('Z', '+00:00')
end_clean = end.replace('Z', '+00:00')
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
else:
today = date.today()
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
next_month = today.replace(day=28) + timedelta(days=4)
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter(
Reservation.start_time >= start_date,
Reservation.start_time <= end_date
)
if classroom_id and classroom_id.isdigit():
query = query.filter(Reservation.classroom_id == int(classroom_id))
if subject_id and subject_id.isdigit():
query = query.filter(Commission.subject_id == int(subject_id))
if career_id and career_id.isdigit():
query = query.filter(Subject.career_id == int(career_id))
building = request.args.get('building', '').strip()
if building:
from app.models.building import Building
query = query.filter(
or_(
Classroom.building.ilike(f'%{building}%'),
Classroom.building_entity.has(Building.name.ilike(f'%{building}%'))
)
)
if shift:
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
if hide_virtual:
query = query.filter(
~Classroom.code.ilike('%VIRTUAL%'),
~Classroom.building.ilike('%Virtual%')
)
if floor:
flr = str(floor).strip()
if flr.upper() in ['0', 'PB', 'PLANTA BAJA']:
query = query.filter(or_(Classroom.floor == '0', Classroom.floor.ilike('%PB%'), Classroom.floor.ilike('%Planta Baja%')))
else:
query = query.filter(Classroom.floor == flr)
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Classroom.code.ilike(f'%{search}%'),
Reservation.purpose.ilike(f'%{search}%')
)
)
reservations = query.order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
classroom_floor = reservation.classroom.floor_display if reservation.classroom else None
classroom_building = reservation.classroom.building_name if reservation.classroom else ''
is_virtual = bool(reservation.classroom and reservation.classroom.is_virtual)
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({
'id': reservation.id,
'title': subject_name,
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'className': f'fc-event-{status_val.lower()}',
'extendedProps': {
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'building': classroom_building,
'floor': classroom_floor,
'is_virtual': is_virtual,
'location_display': reservation.classroom.location_display if reservation.classroom else '',
'career': career_name,
'commission': reservation.commission.code if reservation.commission else '',
'shift': reservation.shift or '',
'status': status_val,
'purpose': reservation.purpose or '',
'attendees': reservation.expected_attendees or 0,
'notes': reservation.notes or '',
'teacher': reservation.user.name if reservation.user else '',
'virtual_link': reservation.effective_virtual_link,
'cancel_url': url_for('schedule.cancel_reservation', id=reservation.id)
}
})
return jsonify(events)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
@schedule_bp.route('/api/today-events')
@login_required
def api_today_events():
"""Get today's events as JSON"""
try:
today_start = datetime.combine(date.today(), datetime.min.time())
today_end = datetime.combine(date.today(), datetime.max.time())
reservations = Reservation.query.options(
joinedload(Reservation.classroom),
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
).filter(
Reservation.start_time >= today_start,
Reservation.start_time <= today_end
).order_by(Reservation.start_time).all()
events = []
for reservation in reservations:
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
events.append({
'id': reservation.id,
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
'start': reservation.start_time.isoformat(),
'end': reservation.end_time.isoformat(),
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
'extendedProps': {
'subject_code': subject_code,
'subject_name': subject_name,
'classroom': classroom_name,
'purpose': reservation.purpose or 'No purpose',
'attendees': reservation.expected_attendees,
'status': status_val
}
})
return jsonify({'events': events})
except Exception as e:
return jsonify({'error': str(e)}), 500