feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
This commit is contained in:
@@ -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'
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user