248 lines
11 KiB
Python
248 lines
11 KiB
Python
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 con sus relaciones completas."""
|
|
r = reservation_service.get_reservation(reservation_id)
|
|
if not r:
|
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
|
|
|
duration_min = int((r.end_time - r.start_time).total_seconds() / 60) if (r.start_time and r.end_time) else 120
|
|
is_virt = bool(r.is_virtual or (r.classroom and r.classroom.is_virtual))
|
|
|
|
return jsonify({
|
|
'id': r.id,
|
|
'classroom_id': r.classroom_id,
|
|
'classroom_name': r.classroom.code if r.classroom else None,
|
|
'location_display': getattr(r.classroom, 'location_display', 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),
|
|
'building_name': getattr(r.classroom, 'building_name', getattr(r.classroom, 'building', 'Edificio Central')),
|
|
'floor': r.classroom.floor,
|
|
'floor_display': getattr(r.classroom, 'floor_display', f"Piso {r.classroom.floor}"),
|
|
'capacity': r.classroom.capacity,
|
|
'capacity_display': getattr(r.classroom, 'capacity_display', str(r.classroom.capacity)),
|
|
'description': getattr(r.classroom, 'description', '') or '',
|
|
'is_virtual': getattr(r.classroom, 'is_virtual', False)
|
|
} if r.classroom else None,
|
|
'commission_id': r.commission_id,
|
|
'commission': {
|
|
'id': r.commission.id,
|
|
'code': r.commission.code,
|
|
'full_code': r.commission.get_full_code() if hasattr(r.commission, 'get_full_code') else r.commission.code,
|
|
'virtual_link': r.commission.virtual_link,
|
|
'max_students': r.commission.max_students,
|
|
'current_students': getattr(r.commission, 'current_students', 0),
|
|
'subject': {
|
|
'id': r.commission.subject.id,
|
|
'name': r.commission.subject.name,
|
|
'code': r.commission.subject.code
|
|
} if (r.commission and r.commission.subject) else None
|
|
} if r.commission else None,
|
|
'teacher_id': r.user_id,
|
|
'teacher_name': r.user.name if r.user else None,
|
|
'user': {
|
|
'id': r.user.id,
|
|
'name': r.user.name,
|
|
'first_name': getattr(r.user, 'first_name', r.user.name.split()[0] if r.user.name else 'Usuario'),
|
|
'last_name': getattr(r.user, 'last_name', ' '.join(r.user.name.split()[1:]) if r.user.name and len(r.user.name.split()) > 1 else ''),
|
|
'email': r.user.email
|
|
} if r.user else None,
|
|
'start_time': r.start_time.isoformat(),
|
|
'end_time': r.end_time.isoformat(),
|
|
'duration': duration_min,
|
|
'purpose': r.purpose,
|
|
'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 '',
|
|
'can_be_cancelled': r.can_be_cancelled() if hasattr(r, 'can_be_cancelled') else True
|
|
}), 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
|
|
|
|
@api_reservations_bp.route('/<int:reservation_id>/cancel', methods=['POST'])
|
|
@jwt_required
|
|
def cancel_reservation_post(reservation_id: int):
|
|
"""Cancela una reserva (endpoint POST compatible con formularios)."""
|
|
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
|
|
|
|
@api_reservations_bp.route('/<int:reservation_id>/confirm', methods=['POST'])
|
|
@jwt_required
|
|
def confirm_reservation(reservation_id: int):
|
|
"""Confirma una reserva cambiando su estado a CONFIRMED."""
|
|
confirmed = reservation_service.confirm_reservation(reservation_id)
|
|
if not confirmed:
|
|
return jsonify({'error': 'NotFound', 'message': f'Reserva {reservation_id} no encontrada.'}), 404
|
|
|
|
return jsonify({'message': f'Reserva {reservation_id} confirmada correctamente.'}), 200
|
|
|