feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad
This commit is contained in:
+307
-26
@@ -4,11 +4,13 @@ import re
|
||||
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.subject import Subject, Commission, CommissionTeacher
|
||||
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.constants.document_types import DOCUMENT_TYPES, validate_document, format_document
|
||||
from app.services.enrollment_service import EnrollmentService
|
||||
from app import db
|
||||
|
||||
api_admin_bp = Blueprint('api_admin', __name__)
|
||||
@@ -22,7 +24,14 @@ def get_users():
|
||||
|
||||
query = User.query
|
||||
if search:
|
||||
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
|
||||
query = query.filter(
|
||||
(User.name.ilike(f'%{search}%')) |
|
||||
(User.email.ilike(f'%{search}%')) |
|
||||
(User.personal_email.ilike(f'%{search}%')) |
|
||||
(User.document_number.ilike(f'%{search}%')) |
|
||||
(User.first_name.ilike(f'%{search}%')) |
|
||||
(User.last_name.ilike(f'%{search}%'))
|
||||
)
|
||||
if role_id:
|
||||
query = query.filter(User.role_id == role_id)
|
||||
if status == 'active':
|
||||
@@ -35,19 +44,12 @@ def get_users():
|
||||
|
||||
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)
|
||||
})
|
||||
d = u.to_dict()
|
||||
d['role_obj'] = {
|
||||
'id': u.role_obj.id,
|
||||
'name': u.role_obj.name
|
||||
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'}
|
||||
users_data.append(d)
|
||||
|
||||
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
|
||||
|
||||
@@ -55,7 +57,8 @@ def get_users():
|
||||
'status': 'success',
|
||||
'total': len(users_data),
|
||||
'users': users_data,
|
||||
'roles': roles_data
|
||||
'roles': roles_data,
|
||||
'document_types': DOCUMENT_TYPES
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/roles', methods=['GET'])
|
||||
@@ -213,6 +216,8 @@ def get_commissions():
|
||||
'year': getattr(c, 'year', 2026) or 2026,
|
||||
'teacher_id': c.teacher_id,
|
||||
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
||||
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
||||
'teacher_names': c.teacher_names if hasattr(c, 'teacher_names') else (c.teacher_name if hasattr(c, 'teacher_name') else 'Sin asignar'),
|
||||
'schedule': c.schedule or 'A coordinar',
|
||||
'shift': c.shift or 'Mañana',
|
||||
'max_students': c.max_students or 35,
|
||||
@@ -248,19 +253,47 @@ def get_commissions():
|
||||
def create_user():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
first_name = data.get('first_name', '').strip()
|
||||
last_name = data.get('last_name', '').strip()
|
||||
name = data.get('name', '').strip()
|
||||
if not name and (first_name or last_name):
|
||||
name = f"{first_name} {last_name}".strip()
|
||||
elif name and not first_name:
|
||||
parts = name.split()
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
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']
|
||||
|
||||
phone = data.get('phone', '').strip()
|
||||
personal_email = data.get('personal_email', '').strip().lower()
|
||||
address = data.get('address', '').strip()
|
||||
document_type = data.get('document_type', 'DNI').strip().upper()
|
||||
document_number = data.get('document_number', '').strip()
|
||||
|
||||
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
|
||||
|
||||
if document_number:
|
||||
is_valid, clean_doc, err_msg = validate_document(document_type, document_number)
|
||||
if not is_valid:
|
||||
return jsonify({'error': 'ValidationError', 'message': err_msg}), 400
|
||||
document_number = clean_doc
|
||||
|
||||
existing_doc = User.query.filter(
|
||||
User.document_type == document_type,
|
||||
User.document_number == document_number
|
||||
).first()
|
||||
if existing_doc:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con {document_type} {document_number}.'}), 409
|
||||
|
||||
role_obj = None
|
||||
if role_id:
|
||||
role_obj = Role.query.get(int(role_id))
|
||||
@@ -268,7 +301,14 @@ def create_user():
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
personal_email=personal_email,
|
||||
name=name,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=phone,
|
||||
address=address,
|
||||
document_type=document_type,
|
||||
document_number=document_number,
|
||||
role=role_name,
|
||||
role_id=int(role_id) if role_id else None,
|
||||
is_active=bool(is_active)
|
||||
@@ -280,7 +320,7 @@ def create_user():
|
||||
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}
|
||||
'user': user.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
|
||||
@@ -289,8 +329,48 @@ 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()
|
||||
first_name = data.get('first_name', user.first_name or '').strip()
|
||||
last_name = data.get('last_name', user.last_name or '').strip()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if 'first_name' in data or 'last_name' in data:
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
user.name = f"{first_name} {last_name}".strip()
|
||||
elif name:
|
||||
user.name = name
|
||||
parts = name.split()
|
||||
user.first_name = parts[0] if parts else ''
|
||||
user.last_name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
if 'phone' in data:
|
||||
user.phone = (data['phone'] or '').strip()
|
||||
if 'personal_email' in data:
|
||||
user.personal_email = (data['personal_email'] or '').strip().lower()
|
||||
if 'address' in data:
|
||||
user.address = (data['address'] or '').strip()
|
||||
|
||||
if 'document_type' in data or 'document_number' in data:
|
||||
doc_type = data.get('document_type', user.document_type or 'DNI').strip().upper()
|
||||
doc_num = data.get('document_number', user.document_number or '').strip()
|
||||
|
||||
if doc_num:
|
||||
is_valid, clean_doc, err_msg = validate_document(doc_type, doc_num)
|
||||
if not is_valid:
|
||||
return jsonify({'error': 'ValidationError', 'message': err_msg}), 400
|
||||
doc_num = clean_doc
|
||||
|
||||
existing_doc = User.query.filter(
|
||||
User.document_type == doc_type,
|
||||
User.document_number == doc_num,
|
||||
User.id != id
|
||||
).first()
|
||||
if existing_doc:
|
||||
return jsonify({'error': 'Conflict', 'message': f'El documento {doc_type} {doc_num} ya está asignado a otro usuario.'}), 409
|
||||
|
||||
user.document_type = doc_type
|
||||
user.document_number = doc_num
|
||||
|
||||
if 'email' in data and data['email']:
|
||||
email = data['email'].strip().lower()
|
||||
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
|
||||
@@ -312,7 +392,15 @@ def update_user(id):
|
||||
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}
|
||||
'user': user.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/document-types', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_document_types():
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'document_types': DOCUMENT_TYPES
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
||||
@@ -598,15 +686,18 @@ def delete_subject(id):
|
||||
subject.is_active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
|
||||
'status': 'deactivated',
|
||||
'action': 'deactivated',
|
||||
'message': f'La asignatura "{subject.name}" tiene {len(subject.commissions)} comisión(es) vinculada(s). Se ha desactivado en su lugar para proteger el historial académico.'
|
||||
}), 200
|
||||
|
||||
name = subject.name
|
||||
db.session.delete(subject)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Asignatura eliminada permanentemente.'
|
||||
'status': 'deleted',
|
||||
'action': 'deleted',
|
||||
'message': f'Asignatura "{name}" eliminada permanentemente del sistema.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
@@ -659,6 +750,10 @@ def create_commission():
|
||||
if virtual_link:
|
||||
existing.virtual_link = virtual_link
|
||||
existing.active = bool(active)
|
||||
if teacher_id:
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=existing.id, user_id=teacher_id).first()
|
||||
if not ct:
|
||||
db.session.add(CommissionTeacher(commission_id=existing.id, user_id=teacher_id, role='Titular', is_primary=True))
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -679,6 +774,19 @@ def create_commission():
|
||||
active=bool(active)
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
|
||||
if teacher_id:
|
||||
db.session.add(CommissionTeacher(commission_id=commission.id, user_id=teacher_id, role='Titular', is_primary=True))
|
||||
|
||||
# Soporte para docentes adicionales al crear comisión
|
||||
extra_teachers = data.get('teacher_ids') or data.get('teachers') or []
|
||||
for et in extra_teachers:
|
||||
u_id = et.get('user_id') if isinstance(et, dict) else et
|
||||
u_role = et.get('role', 'Adjunto') if isinstance(et, dict) else 'Adjunto'
|
||||
if u_id and int(u_id) != teacher_id:
|
||||
db.session.add(CommissionTeacher(commission_id=commission.id, user_id=int(u_id), role=u_role, is_primary=False))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
@@ -688,6 +796,16 @@ def create_commission():
|
||||
}), 201
|
||||
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_commission_detail(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_commission(id):
|
||||
@@ -703,6 +821,10 @@ def update_commission(id):
|
||||
if 'teacher_id' in data:
|
||||
t_id = data['teacher_id']
|
||||
comm.teacher_id = int(t_id) if t_id else None
|
||||
if comm.teacher_id:
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
||||
if not ct:
|
||||
db.session.add(CommissionTeacher(commission_id=comm.id, user_id=comm.teacher_id, role='Titular', is_primary=True))
|
||||
if 'max_students' in data and data['max_students']:
|
||||
comm.max_students = int(data['max_students'])
|
||||
if 'schedule' in data:
|
||||
@@ -740,17 +862,176 @@ def toggle_commission(id):
|
||||
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
|
||||
t_id = data.get('teacher_id') or data.get('user_id')
|
||||
role = (data.get('role') or 'Titular').strip()
|
||||
|
||||
if t_id:
|
||||
t_id = int(t_id)
|
||||
comm.teacher_id = t_id
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=t_id).first()
|
||||
if not ct:
|
||||
ct = CommissionTeacher(commission_id=comm.id, user_id=t_id, role=role, is_primary=True)
|
||||
db.session.add(ct)
|
||||
else:
|
||||
ct.role = role
|
||||
ct.is_primary = True
|
||||
else:
|
||||
comm.teacher_id = None
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': comm.id,
|
||||
'teacher_id': comm.teacher_id,
|
||||
'teacher_name': comm.teacher_name,
|
||||
'teachers': comm.teachers,
|
||||
'message': 'Docente asignado correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/teachers', methods=['POST'])
|
||||
@jwt_required
|
||||
def add_commission_teacher(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
user_id = data.get('user_id') or data.get('teacher_id')
|
||||
if not user_id:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El docente es requerido.'}), 400
|
||||
|
||||
user = User.query.get(int(user_id))
|
||||
if not user:
|
||||
return jsonify({'error': 'NotFound', 'message': f'El usuario docente con ID {user_id} no existe.'}), 404
|
||||
|
||||
role = (data.get('role') or 'Adjunto').strip()
|
||||
is_primary = data.get('is_primary', False)
|
||||
if isinstance(is_primary, str):
|
||||
is_primary = is_primary.lower() in ['true', '1', 'on']
|
||||
|
||||
existing = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user.id).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
existing.is_primary = bool(is_primary)
|
||||
else:
|
||||
if is_primary or comm.teacher_id is None:
|
||||
comm.teacher_id = user.id
|
||||
is_primary = True
|
||||
ct = CommissionTeacher(
|
||||
commission_id=comm.id,
|
||||
user_id=user.id,
|
||||
role=role,
|
||||
is_primary=bool(is_primary)
|
||||
)
|
||||
db.session.add(ct)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'{user.name} asignado/a como {role} en la comisión.',
|
||||
'teachers': comm.teachers,
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/teachers/<int:user_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def remove_commission_teacher(id, user_id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user_id).first()
|
||||
if ct:
|
||||
db.session.delete(ct)
|
||||
|
||||
# Si era el docente titular, reasignar a otro docente disponible o dejar en None
|
||||
if comm.teacher_id == user_id:
|
||||
other = CommissionTeacher.query.filter(CommissionTeacher.commission_id == comm.id, CommissionTeacher.user_id != user_id).first()
|
||||
comm.teacher_id = other.user_id if other else None
|
||||
if other:
|
||||
other.is_primary = True
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Docente desvinculado de la comisión correctamente.',
|
||||
'teachers': comm.teachers,
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# COMMISSION STUDENT ENROLLMENTS (FASE 2 MVP)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_commission_enrollments(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
enrollments = EnrollmentService.list_enrollments(comm.id)
|
||||
return jsonify({
|
||||
'commission_id': comm.id,
|
||||
'commission_code': comm.code,
|
||||
'count': len(enrollments),
|
||||
'max_students': comm.max_students,
|
||||
'current_students': comm.current_students,
|
||||
'enrollments': enrollments
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments', methods=['POST'])
|
||||
@jwt_required
|
||||
def enroll_commission_student(id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
student_id = data.get('student_id')
|
||||
notes = data.get('notes')
|
||||
allow_same_day_exception = bool(data.get('allow_same_day_exception', False))
|
||||
exception_reason = data.get('exception_reason')
|
||||
|
||||
if not student_id:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'student_id es obligatorio.'}), 400
|
||||
|
||||
try:
|
||||
enrollment = EnrollmentService.enroll_student(
|
||||
commission_id=id,
|
||||
student_id=int(student_id),
|
||||
notes=notes,
|
||||
allow_same_day_exception=allow_same_day_exception,
|
||||
exception_reason=exception_reason
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estudiante matriculado exitosamente en la comisión.',
|
||||
'enrollment': enrollment.to_dict()
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments/<int:student_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def unenroll_commission_student(id, student_id):
|
||||
success = EnrollmentService.unenroll_student(commission_id=id, student_id=student_id)
|
||||
if not success:
|
||||
return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada para este estudiante.'}), 404
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estudiante desvinculado de la comisión exitosamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments/<int:student_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_commission_enrollment(id, student_id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
status = data.get('status', 'activo')
|
||||
notes = data.get('notes')
|
||||
|
||||
enrollment = EnrollmentService.update_enrollment_status(
|
||||
commission_id=id,
|
||||
student_id=student_id,
|
||||
status=status,
|
||||
notes=notes
|
||||
)
|
||||
if not enrollment:
|
||||
return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estado de matrícula actualizado.',
|
||||
'enrollment': enrollment.to_dict()
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ACADEMIC TERMS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,9 @@ def login():
|
||||
"""
|
||||
Endpoint de autenticación para obtener par de tokens (Access + Refresh).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
|
||||
try:
|
||||
dto = LoginDTO(**data)
|
||||
except ValidationError as e:
|
||||
@@ -56,7 +58,9 @@ def refresh():
|
||||
"""
|
||||
Renovación silenciosa del Access Token utilizando el Refresh Token.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
api_openapi_bp = Blueprint('api_openapi', __name__, url_prefix='/api/v1')
|
||||
|
||||
OPENAPI_SPEC = {
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Edu-Space REST API",
|
||||
"description": "Enterprise Academic & Physical Space Management REST API with JWT Authentication and Role-Based Access Control.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://127.0.0.1:5000",
|
||||
"description": "Local Flask Backend Server"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"example": "admin@eduspace.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"example": "Admin123!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {"type": "string"},
|
||||
"refresh_token": {"type": "string"},
|
||||
"token_type": {"type": "string", "example": "Bearer"},
|
||||
"expires_in": {"type": "integer"},
|
||||
"user": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {"type": "string"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"email": {"type": "string"},
|
||||
"first_name": {"type": "string"},
|
||||
"last_name": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"is_active": {"type": "boolean"}
|
||||
}
|
||||
},
|
||||
"SubjectResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"career": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"ClassroomResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"name": {"type": "string"},
|
||||
"capacity": {"type": "integer"},
|
||||
"building": {"type": "string"},
|
||||
"floor": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"summary": "Authenticate user and issue JWT tokens",
|
||||
"tags": ["Authentication"],
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Authentication successful",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized / Bad credentials",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"summary": "Get authenticated user profile",
|
||||
"tags": ["Authentication"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current user profile",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/UserResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/classrooms": {
|
||||
"get": {
|
||||
"summary": "List all physical classrooms and facilities",
|
||||
"tags": ["Classrooms"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of classrooms",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ClassroomResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users": {
|
||||
"get": {
|
||||
"summary": "List all users (Admin only)",
|
||||
"tags": ["Admin - Users"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/UserResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - Requires admin role"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/subjects": {
|
||||
"get": {
|
||||
"summary": "List all academic subjects (Admin only)",
|
||||
"tags": ["Admin - Academic"],
|
||||
"security": [{"bearerAuth": []}],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of subjects",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SubjectResponse"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/openapi.json": {
|
||||
"get": {
|
||||
"summary": "Get OpenAPI 3.0.3 specification JSON",
|
||||
"tags": ["Documentation"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI Specification"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@api_openapi_bp.route('/openapi.json', methods=['GET'])
|
||||
def get_openapi_spec():
|
||||
"""Serves the OpenAPI 3.0 specification for Schemathesis / DAST and Swagger UI."""
|
||||
return jsonify(OPENAPI_SPEC)
|
||||
@@ -245,3 +245,48 @@ def confirm_reservation(reservation_id: int):
|
||||
|
||||
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
||||
|
||||
@api_reservations_bp.route('/check-conflicts', methods=['POST'])
|
||||
@jwt_required
|
||||
def check_conflicts_endpoint():
|
||||
"""
|
||||
Evalúa la matriz completa de conflictos (físico, docente y aforo) en tiempo real.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
|
||||
|
||||
classroom_id = data.get('classroom_id')
|
||||
start_str = data.get('start_time')
|
||||
end_str = data.get('end_time')
|
||||
|
||||
if not classroom_id or not start_str or not end_str:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'classroom_id, start_time y end_time son obligatorios.'}), 400
|
||||
|
||||
try:
|
||||
start_time = datetime.fromisoformat(str(start_str).replace('Z', '+00:00'))
|
||||
end_time = datetime.fromisoformat(str(end_str).replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'Formato inválido de fecha/hora (ISO 8601 esperado).'}), 400
|
||||
|
||||
teacher_id = data.get('teacher_id')
|
||||
commission_id = data.get('commission_id')
|
||||
expected_attendees = data.get('expected_attendees')
|
||||
allow_co_teaching = bool(data.get('allow_co_teaching_exception', False))
|
||||
exclude_id = data.get('exclude_id')
|
||||
|
||||
try:
|
||||
matrix = reservation_service.check_full_conflicts_matrix(
|
||||
classroom_id=int(classroom_id),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
teacher_id=int(teacher_id) if teacher_id else None,
|
||||
commission_id=int(commission_id) if commission_id else None,
|
||||
expected_attendees=int(expected_attendees) if expected_attendees is not None else None,
|
||||
allow_co_teaching_exception=allow_co_teaching,
|
||||
exclude_id=int(exclude_id) if exclude_id else None
|
||||
)
|
||||
return jsonify(matrix), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ValidationError', 'message': str(e)}), 400
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user