feat(roadmap): implementar Sprints 1, 2 y 3 (MVC, Stateless JWT API y Spring Security)
This commit is contained in:
@@ -32,6 +32,8 @@ def create_app(config_class=Config):
|
||||
from app.models import user
|
||||
# Register blueprints
|
||||
from app.routes import auth_bp, classrooms_bp, buildings_bp, main_bp, schedule_bp, genetic_bp, genetic_web_bp, admin_bp
|
||||
from app.routes.api import api_auth_bp, api_classrooms_bp, api_reservations_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/")
|
||||
app.register_blueprint(classrooms_bp, url_prefix="/classrooms")
|
||||
@@ -41,6 +43,14 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(genetic_bp)
|
||||
app.register_blueprint(genetic_web_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Register REST API (v1) Blueprints with Stateless JWT support
|
||||
app.register_blueprint(api_auth_bp)
|
||||
app.register_blueprint(api_classrooms_bp)
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
|
||||
# Initialize Enterprise Security Pipeline (SecurityFilterChain)
|
||||
SecurityFilterChain(app)
|
||||
|
||||
# Language selector function
|
||||
def get_locale():
|
||||
|
||||
@@ -8,6 +8,7 @@ from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -27,5 +28,6 @@ __all__ = [
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES'
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog'
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||
y trazabilidad de operaciones institucionales.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_email = db.Column(db.String(255), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relación con usuario
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Módulo de Repositorios para persistencia y consultas desacopladas."""
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
|
||||
__all__ = [
|
||||
'BaseRepository',
|
||||
'ClassroomRepository',
|
||||
'ReservationRepository',
|
||||
'UserRepository'
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import TypeVar, Generic, Type, Optional, List
|
||||
from app import db
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Repositorio base con operaciones CRUD genéricas y seguras."""
|
||||
|
||||
def __init__(self, model_class: Type[T]):
|
||||
self.model_class = model_class
|
||||
|
||||
def get_by_id(self, entity_id: int) -> Optional[T]:
|
||||
"""Obtiene una entidad por su ID numérico."""
|
||||
if not entity_id:
|
||||
return None
|
||||
return db.session.get(self.model_class, entity_id)
|
||||
|
||||
def get_all(self, order_by=None) -> List[T]:
|
||||
"""Obtiene todas las entidades opcionalmente ordenadas."""
|
||||
query = self.model_class.query
|
||||
if order_by is not None:
|
||||
query = query.order_by(order_by)
|
||||
return query.all()
|
||||
|
||||
def save(self, entity: T, commit: bool = True) -> T:
|
||||
"""Agrega o actualiza una entidad en la sesión."""
|
||||
db.session.add(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return entity
|
||||
|
||||
def delete(self, entity: T, commit: bool = True) -> bool:
|
||||
"""Elimina una entidad de la sesión."""
|
||||
if entity:
|
||||
db.session.delete(entity)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
"""Confirma la transacción actual."""
|
||||
db.session.commit()
|
||||
|
||||
def rollback(self):
|
||||
"""Revierte la transacción actual."""
|
||||
db.session.rollback()
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ClassroomRepository(BaseRepository[Classroom]):
|
||||
"""Repositorio especializado en operaciones de persistencia y filtrado de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Classroom)
|
||||
|
||||
def get_by_code_and_building(self, building_id: int, code: str, floor: str) -> Optional[Classroom]:
|
||||
"""Busca un aula por edificio, código y piso para verificar duplicados."""
|
||||
return Classroom.query.filter_by(
|
||||
building_id=building_id,
|
||||
code=code.strip(),
|
||||
floor=str(floor).strip()
|
||||
).first()
|
||||
|
||||
def get_active_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Retorna aulas activas con filtros opcionales de edificio, piso y modalidad."""
|
||||
query = Classroom.query.filter_by(is_active=True)
|
||||
|
||||
if building_id:
|
||||
query = query.filter_by(building_id=building_id)
|
||||
|
||||
if floor:
|
||||
query = query.filter_by(floor=str(floor).strip())
|
||||
|
||||
classrooms = query.order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
if only_virtual:
|
||||
return [c for c in classrooms if c.is_virtual]
|
||||
elif only_physical:
|
||||
return [c for c in classrooms if not c.is_virtual]
|
||||
|
||||
return classrooms
|
||||
|
||||
def get_virtual_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las salas virtuales activas."""
|
||||
return [c for c in self.get_active_classrooms() if c.is_virtual]
|
||||
|
||||
def get_physical_classrooms(self) -> List[Classroom]:
|
||||
"""Obtiene todas las aulas físicas activas."""
|
||||
return [c for c in self.get_active_classrooms() if not c.is_virtual]
|
||||
|
||||
def search(self, term: str) -> List[Classroom]:
|
||||
"""Búsqueda de aulas por código, edificio o descripción."""
|
||||
if not term:
|
||||
return self.get_all(order_by=Classroom.code.asc())
|
||||
term_clean = f"%{term.strip()}%"
|
||||
return Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
(Classroom.code.ilike(term_clean)) |
|
||||
(Classroom.building.ilike(term_clean)) |
|
||||
(Classroom.description.ilike(term_clean))
|
||||
).order_by(Classroom.code.asc()).all()
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date, time
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import and_, or_
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
from app import db
|
||||
|
||||
class ReservationRepository(BaseRepository[Reservation]):
|
||||
"""Repositorio para la persistencia y consulta de reservas de aulas."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Reservation)
|
||||
|
||||
def find_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas que se solapan en el mismo aula y franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def get_by_date_range(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de una ventana temporal con carga ansiosa de relaciones."""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
Reservation.start_time >= start_dt,
|
||||
Reservation.start_time <= end_dt,
|
||||
Reservation.status != ReservationStatus.CANCELLED.value
|
||||
)
|
||||
|
||||
if classroom_id:
|
||||
query = query.filter(Reservation.classroom_id == classroom_id)
|
||||
|
||||
return query.order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def get_by_teacher(self, teacher_id: int) -> List[Reservation]:
|
||||
"""Obtiene reservas asignadas a un docente."""
|
||||
return Reservation.query.filter_by(
|
||||
user_id=teacher_id
|
||||
).order_by(Reservation.start_time.desc()).all()
|
||||
|
||||
def get_by_commission(self, commission_id: int) -> List[Reservation]:
|
||||
"""Obtiene todas las reservas asociadas a una comisión."""
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import func
|
||||
from app.models.user import User
|
||||
from app.repositories.base_repository import BaseRepository
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""Repositorio para la gestión de usuarios, credenciales y roles."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(User)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por su dirección de email normalizada."""
|
||||
if not email:
|
||||
return None
|
||||
return User.query.filter(func.lower(User.email) == email.strip().lower()).first()
|
||||
|
||||
def get_active_users(self) -> List[User]:
|
||||
"""Obtiene todos los usuarios con cuenta activa."""
|
||||
return User.query.filter_by(is_active=True).order_by(User.name.asc()).all()
|
||||
|
||||
def get_by_role(self, role_name: str) -> List[User]:
|
||||
"""Obtiene usuarios por nombre de rol."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
func.lower(User.role) == role_name.strip().lower()
|
||||
).order_by(User.name.asc()).all()
|
||||
|
||||
def get_teachers(self) -> List[User]:
|
||||
"""Obtiene lista de docentes y profesores activos."""
|
||||
return User.query.filter(
|
||||
User.is_active == True,
|
||||
User.role.in_(['TEACHER', 'DOCENTE', 'PROFESOR', 'teacher', 'docente'])
|
||||
).order_by(User.name.asc()).all()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""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
|
||||
|
||||
__all__ = [
|
||||
'api_auth_bp',
|
||||
'api_classrooms_bp',
|
||||
'api_reservations_bp'
|
||||
]
|
||||
@@ -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,140 @@
|
||||
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
|
||||
|
||||
reservations = reservation_service.list_reservations(start_dt, end_dt, classroom_id=classroom_id)
|
||||
result = []
|
||||
for r in reservations:
|
||||
result.append({
|
||||
'id': r.id,
|
||||
'classroom_id': r.classroom_id,
|
||||
'classroom_name': r.classroom.code if r.classroom else None,
|
||||
'commission_id': r.commission_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
|
||||
})
|
||||
|
||||
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
|
||||
@@ -58,29 +58,37 @@ def add_classroom():
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
cap_val = 9999
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom = Classroom(
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
dto = ClassroomCreateDTO(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=room_code,
|
||||
floor=floor_val,
|
||||
capacity=cap_val,
|
||||
description=form.description.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
classroom_service = ClassroomService()
|
||||
classroom = classroom_service.create_classroom(dto)
|
||||
|
||||
db.session.add(classroom)
|
||||
db.session.commit()
|
||||
# Registrar en bitácora inmutable de auditoría
|
||||
AuditService.log('CREATE', 'classrooms', entity_id=classroom.id, details={'code': classroom.code, 'building': building.name})
|
||||
|
||||
flash(f'Aula {classroom.location_display} creada exitosamente.', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
except ValueError as ve:
|
||||
flash(str(ve), 'error')
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error creating classroom: {str(e)}', 'error')
|
||||
flash(f'Error al crear el aula: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form, building_floors=building_floors, title='Add Classroom')
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Módulo de Data Transfer Objects (DTOs) y esquemas tipados con Pydantic."""
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO, ClassroomResponseDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO, ReservationResponseDTO
|
||||
from app.schemas.auth_dto import LoginDTO, TokenResponseDTO, RefreshTokenDTO
|
||||
|
||||
__all__ = [
|
||||
'ClassroomCreateDTO',
|
||||
'ClassroomUpdateDTO',
|
||||
'ClassroomResponseDTO',
|
||||
'ReservationCreateDTO',
|
||||
'ReservationUpdateDTO',
|
||||
'ReservationResponseDTO',
|
||||
'LoginDTO',
|
||||
'TokenResponseDTO',
|
||||
'RefreshTokenDTO'
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
class LoginDTO(BaseModel):
|
||||
email: str = Field(..., description="Correo electrónico institucional")
|
||||
password: str = Field(..., min_length=1, description="Contraseña de acceso")
|
||||
|
||||
class TokenResponseDTO(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = 900 # 15 minutos en segundos
|
||||
user: Dict[str, Any]
|
||||
|
||||
class RefreshTokenDTO(BaseModel):
|
||||
refresh_token: str = Field(..., description="Token de refresco válido")
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class ClassroomBaseDTO(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=50, description="Código o nombre del aula (ej: Aula 101, VIRTUAL-A)")
|
||||
floor: str = Field(default="PB", max_length=50, description="Piso o nivel (PB, Piso 1, etc.)")
|
||||
capacity: int = Field(default=30, ge=1, le=10000, description="Capacidad máxima de estudiantes")
|
||||
description: Optional[str] = Field(default=None, max_length=500, description="Detalles o equipamiento")
|
||||
is_active: bool = Field(default=True, description="Estado operativo del aula")
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def clean_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("El código del aula no puede estar vacío.")
|
||||
return v
|
||||
|
||||
@field_validator('floor')
|
||||
@classmethod
|
||||
def clean_floor(cls, v: str) -> str:
|
||||
return str(v).strip() or "PB"
|
||||
|
||||
class ClassroomCreateDTO(ClassroomBaseDTO):
|
||||
building_id: Optional[int] = Field(default=None, description="ID del edificio asignado")
|
||||
building_name: Optional[str] = Field(default=None, description="Nombre de edificio (para creación dinámica)")
|
||||
|
||||
class ClassroomUpdateDTO(BaseModel):
|
||||
building_id: Optional[int] = None
|
||||
code: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class ClassroomResponseDTO(ClassroomBaseDTO):
|
||||
id: int
|
||||
building_id: Optional[int] = None
|
||||
building_name: str
|
||||
is_virtual: bool
|
||||
location_display: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
class ReservationBaseDTO(BaseModel):
|
||||
classroom_id: int = Field(..., description="ID del aula asignada")
|
||||
commission_id: int = Field(..., description="ID de la comisión académica")
|
||||
user_id: int = Field(..., description="ID del docente responsable")
|
||||
start_time: datetime = Field(..., description="Fecha y hora de inicio")
|
||||
end_time: datetime = Field(..., description="Fecha y hora de finalización")
|
||||
purpose: str = Field(default="Clase regular", max_length=500, description="Motivo o título de la reserva")
|
||||
expected_attendees: int = Field(default=20, ge=1, description="Cantidad estimada de asistentes")
|
||||
shift: Optional[str] = Field(default=None, max_length=30, description="Turno (Mañana, Tarde, Vespertino, Noche)")
|
||||
virtual_link: Optional[str] = Field(default=None, max_length=500, description="Enlace a sala virtual (Meet/Zoom)")
|
||||
notes: Optional[str] = Field(default=None, description="Observaciones adicionales")
|
||||
status: Optional[str] = Field(default="PENDING", description="Estado de la reserva")
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_time_range(self):
|
||||
if self.end_time <= self.start_time:
|
||||
raise ValueError("La hora de finalización debe ser posterior a la hora de inicio.")
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
purpose: Optional[str] = None
|
||||
expected_attendees: Optional[int] = None
|
||||
shift: Optional[str] = None
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Módulo de Seguridad Empresarial inspirado en Spring Security (SecurityFilterChain, RBAC y Auditoría)."""
|
||||
from app.security.filter_chain import SecurityFilterChain
|
||||
from app.security.decorators import require_permission
|
||||
|
||||
__all__ = [
|
||||
'SecurityFilterChain',
|
||||
'require_permission'
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, redirect, url_for, flash, abort, g
|
||||
from flask_login import current_user
|
||||
|
||||
def require_permission(module: str, min_level: str = 'read'):
|
||||
"""
|
||||
Decorador de seguridad declarativa a nivel de método inspirado en @PreAuthorize de Spring Security.
|
||||
Verifica los privilegios del usuario autenticado (tanto en sesiones web como en API JWT).
|
||||
|
||||
Niveles de permiso estándar:
|
||||
- 'read': Permiso de lectura / consulta
|
||||
- 'read_write': Permiso de modificación y creación
|
||||
- 'admin': Permiso de control total
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = None
|
||||
# Evaluar contexto JWT primero si está presente
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user = g.jwt_user
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user = current_user
|
||||
|
||||
is_api = request.path.startswith('/api/') or request.is_json
|
||||
|
||||
if not user:
|
||||
if is_api:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Autenticación requerida.'}), 401
|
||||
flash('Debe iniciar sesión para acceder a este recurso.', 'warning')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
|
||||
# Comprobar privilegios en la matriz RBAC
|
||||
if not user.has_permission(module, min_level):
|
||||
if is_api:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Permiso insuficiente en módulo "{module}". Nivel requerido: "{min_level}".'
|
||||
}), 403
|
||||
flash(f'No posee permisos suficientes ({min_level}) para gestionar {module}.', 'danger')
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@@ -0,0 +1,83 @@
|
||||
from flask import request, jsonify, g, render_template, Response
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Tuple
|
||||
import time
|
||||
|
||||
class SecurityFilterChain:
|
||||
"""
|
||||
Pipeline centralizado de interceptación de seguridad (SecurityFilterChain)
|
||||
inspirado en el patrón arquitectónico de Spring Security.
|
||||
|
||||
Capas de filtrado activas:
|
||||
1. Security Headers Filter (Blindaje HTTP en todas las respuestas).
|
||||
2. Rate Limiting Filter (Protección contra fuerza bruta y DoS).
|
||||
3. Bearer Token Context Filter (Inyección de identidad contextual).
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
# Registro de timestamps de peticiones por IP: ip -> [timestamps]
|
||||
self._request_history: Dict[str, List[float]] = {}
|
||||
# Límites por endpoint sensible (max_requests, window_seconds)
|
||||
self._rate_limits = {
|
||||
'/login': (15, 60),
|
||||
'/api/v1/auth/login': (15, 60),
|
||||
'/api/v1/auth/refresh': (30, 60)
|
||||
}
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Registra los filtros en el ciclo de vida de la aplicación Flask."""
|
||||
app.before_request(self._execute_pre_filters)
|
||||
app.after_request(self._execute_post_filters)
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Obtiene la IP real del cliente respetando proxies inversos de confianza."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
return request.remote_addr or '127.0.0.1'
|
||||
|
||||
def _execute_pre_filters(self):
|
||||
"""Ejecuta los filtros previos al enrutamiento del controlador."""
|
||||
path = request.path
|
||||
|
||||
# 1. Rate Limiting Filter
|
||||
if path in self._rate_limits:
|
||||
max_req, window = self._rate_limits[path]
|
||||
client_ip = self._get_client_ip()
|
||||
now = time.time()
|
||||
|
||||
# Limpiar historial fuera de la ventana
|
||||
timestamps = self._request_history.get(client_ip, [])
|
||||
timestamps = [t for t in timestamps if now - t < window]
|
||||
|
||||
if len(timestamps) >= max_req:
|
||||
retry_after = int(window - (now - timestamps[0]))
|
||||
if path.startswith('/api/'):
|
||||
return jsonify({
|
||||
'error': 'TooManyRequests',
|
||||
'message': f'Límite de peticiones excedido. Reintente en {retry_after} segundos.'
|
||||
}), 429
|
||||
return Response(
|
||||
f"Demasiados intentos. Por favor espere {retry_after} segundos antes de volver a intentar.",
|
||||
status=429,
|
||||
mimetype='text/plain'
|
||||
)
|
||||
|
||||
timestamps.append(now)
|
||||
self._request_history[client_ip] = timestamps
|
||||
|
||||
def _execute_post_filters(self, response):
|
||||
"""Inyecta cabeceras de blindaje HTTP (OWASP Secure Headers)."""
|
||||
# Evitar ataques de MIME-Sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Mitigación de Clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Mitigación XSS en navegadores legacy
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Política de Referrer segura
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Inyección HSTS (si es HTTPS o entorno de producción)
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
|
||||
return response
|
||||
@@ -1,3 +1,15 @@
|
||||
from .sheets_importer import GoogleSheetsImporter
|
||||
from .classroom_service import ClassroomService
|
||||
from .reservation_service import ReservationService
|
||||
from .user_service import UserService
|
||||
from .jwt_service import JWTService
|
||||
from .audit_service import AuditService
|
||||
|
||||
__all__ = ['GoogleSheetsImporter']
|
||||
__all__ = [
|
||||
'GoogleSheetsImporter',
|
||||
'ClassroomService',
|
||||
'ReservationService',
|
||||
'UserService',
|
||||
'JWTService',
|
||||
'AuditService'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from flask import request, g, has_request_context
|
||||
from flask_login import current_user
|
||||
import json
|
||||
from app import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
|
||||
class AuditService:
|
||||
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
|
||||
|
||||
@staticmethod
|
||||
def log(action: str, module: str, entity_id: Optional[int] = None,
|
||||
details: Optional[Any] = None, user: Optional[User] = None,
|
||||
ip_address: Optional[str] = None) -> AuditLog:
|
||||
"""
|
||||
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
|
||||
desde el contexto actual de la petición si no se especifican.
|
||||
"""
|
||||
user_id = None
|
||||
user_email = None
|
||||
|
||||
# 1. Determinar usuario actuante
|
||||
if user:
|
||||
user_id = user.id
|
||||
user_email = user.email
|
||||
elif has_request_context():
|
||||
if hasattr(g, 'jwt_user') and g.jwt_user:
|
||||
user_id = g.jwt_user.id
|
||||
user_email = g.jwt_user.email
|
||||
elif current_user and current_user.is_authenticated:
|
||||
user_id = current_user.id
|
||||
user_email = current_user.email
|
||||
|
||||
# 2. Determinar IP de origen
|
||||
if not ip_address and has_request_context():
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
else:
|
||||
ip_address = request.remote_addr
|
||||
|
||||
# 3. Serializar detalles si es estructura de datos
|
||||
details_str = None
|
||||
if details is not None:
|
||||
if isinstance(details, (dict, list)):
|
||||
try:
|
||||
details_str = json.dumps(details, ensure_ascii=False)
|
||||
except Exception:
|
||||
details_str = str(details)
|
||||
else:
|
||||
details_str = str(details)
|
||||
|
||||
log_entry = AuditLog(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
action=action.upper(),
|
||||
module=module.lower(),
|
||||
entity_id=entity_id,
|
||||
details=details_str,
|
||||
ip_address=ip_address
|
||||
)
|
||||
try:
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return log_entry
|
||||
|
||||
@staticmethod
|
||||
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
|
||||
"""Obtiene las entradas más recientes de auditoría."""
|
||||
query = AuditLog.query
|
||||
if module:
|
||||
query = query.filter_by(module=module.lower())
|
||||
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
import re
|
||||
from sqlalchemy import func
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ClassroomService:
|
||||
"""Capa de servicios para la lógica de negocio y validación de aulas."""
|
||||
|
||||
def __init__(self, repository: Optional[ClassroomRepository] = None):
|
||||
self.repository = repository or ClassroomRepository()
|
||||
|
||||
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
|
||||
"""Obtiene un aula por su identificador."""
|
||||
return self.repository.get_by_id(classroom_id)
|
||||
|
||||
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
|
||||
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
|
||||
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
|
||||
return self.repository.get_active_classrooms(
|
||||
building_id=building_id,
|
||||
floor=floor,
|
||||
only_virtual=only_virtual,
|
||||
only_physical=only_physical
|
||||
)
|
||||
|
||||
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
|
||||
"""
|
||||
Crea una nueva aula aplicando reglas de negocio:
|
||||
- Resolución o creación dinámica del edificio.
|
||||
- Unicidad de código por piso y edificio.
|
||||
- Capacidad adaptativa para aulas virtuales.
|
||||
"""
|
||||
building = None
|
||||
if dto.building_id:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if not building:
|
||||
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
|
||||
elif dto.building_name:
|
||||
b_name = dto.building_name.strip()
|
||||
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
|
||||
if not building:
|
||||
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
|
||||
building = Building(name=b_name, code=code_slug, is_active=True)
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
else:
|
||||
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
|
||||
|
||||
# Verificar unicidad
|
||||
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
|
||||
if existing:
|
||||
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
|
||||
|
||||
capacity = dto.capacity
|
||||
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
|
||||
if is_virtual and capacity < 999:
|
||||
capacity = 9999
|
||||
|
||||
classroom = Classroom(
|
||||
building_id=building.id,
|
||||
building=building.name,
|
||||
code=dto.code,
|
||||
floor=dto.floor,
|
||||
capacity=capacity,
|
||||
description=dto.description,
|
||||
is_active=dto.is_active
|
||||
)
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
|
||||
"""Actualiza atributos de un aula existente."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
|
||||
|
||||
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
|
||||
code = dto.code.strip() if dto.code is not None else classroom.code
|
||||
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
|
||||
|
||||
# Si cambia código, piso o edificio, validar colisión
|
||||
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
|
||||
existing = self.repository.get_by_code_and_building(building_id, code, floor)
|
||||
if existing and existing.id != classroom.id:
|
||||
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
|
||||
|
||||
if dto.building_id is not None:
|
||||
building = Building.query.get(dto.building_id)
|
||||
if building:
|
||||
classroom.building_id = building.id
|
||||
classroom.building = building.name
|
||||
if dto.code is not None:
|
||||
classroom.code = code
|
||||
if dto.floor is not None:
|
||||
classroom.floor = floor
|
||||
if dto.capacity is not None:
|
||||
classroom.capacity = dto.capacity
|
||||
if dto.description is not None:
|
||||
classroom.description = dto.description
|
||||
if dto.is_active is not None:
|
||||
classroom.is_active = dto.is_active
|
||||
|
||||
return self.repository.save(classroom)
|
||||
|
||||
def delete_classroom(self, classroom_id: int) -> bool:
|
||||
"""Elimina o desactiva un aula verificando dependencias."""
|
||||
classroom = self.repository.get_by_id(classroom_id)
|
||||
if not classroom:
|
||||
return False
|
||||
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
|
||||
if classroom.reservations:
|
||||
classroom.is_active = False
|
||||
self.repository.save(classroom)
|
||||
return True
|
||||
return self.repository.delete(classroom)
|
||||
@@ -0,0 +1,116 @@
|
||||
import jwt
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from flask import current_app
|
||||
from app.models.user import User
|
||||
|
||||
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
|
||||
_token_blacklist: Dict[str, datetime] = {}
|
||||
|
||||
class JWTService:
|
||||
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
|
||||
|
||||
ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
@classmethod
|
||||
def _clean_expired_blacklist(cls):
|
||||
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
|
||||
for jti in expired_jtis:
|
||||
_token_blacklist.pop(jti, None)
|
||||
|
||||
@classmethod
|
||||
def _get_secret(cls) -> str:
|
||||
"""Obtiene la clave secreta desde la configuración de Flask."""
|
||||
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
|
||||
|
||||
@classmethod
|
||||
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
|
||||
# Mapeo de permisos para claims del token
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'email': user.email,
|
||||
'name': user.name,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + delta
|
||||
}
|
||||
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
|
||||
|
||||
@classmethod
|
||||
def generate_tokens(cls, user: User) -> Dict[str, Any]:
|
||||
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
|
||||
access_token = cls.create_access_token(user)
|
||||
refresh_token = cls.create_refresh_token(user)
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
|
||||
"""
|
||||
Decodifica y valida la firma, expiración y tipo del token.
|
||||
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
|
||||
"""
|
||||
cls._clean_expired_blacklist()
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
|
||||
|
||||
jti = payload.get('jti')
|
||||
if jti and jti in _token_blacklist:
|
||||
raise jwt.InvalidTokenError("El token ha sido revocado.")
|
||||
|
||||
token_type = payload.get('type')
|
||||
if token_type != expected_type:
|
||||
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def revoke_token(cls, token: str) -> bool:
|
||||
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
|
||||
try:
|
||||
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
|
||||
jti = payload.get('jti')
|
||||
exp_ts = payload.get('exp')
|
||||
if jti and exp_ts:
|
||||
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
|
||||
_token_blacklist[jti] = exp_dt
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
from app import db
|
||||
|
||||
class ReservationService:
|
||||
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
|
||||
|
||||
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
|
||||
classroom_repo: Optional[ClassroomRepository] = None):
|
||||
self.reservation_repo = reservation_repo or ReservationRepository()
|
||||
self.classroom_repo = classroom_repo or ClassroomRepository()
|
||||
|
||||
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
|
||||
"""Obtiene una reserva por su ID."""
|
||||
return self.reservation_repo.get_by_id(reservation_id)
|
||||
|
||||
def list_reservations(self, start_dt: datetime, end_dt: datetime,
|
||||
classroom_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Obtiene reservas dentro de un rango temporal."""
|
||||
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
|
||||
|
||||
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si existen solapamientos en un aula específica."""
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
|
||||
if classroom and classroom.is_virtual:
|
||||
return []
|
||||
return self.reservation_repo.find_conflicts(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
"""
|
||||
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
# Detección de colisiones para aulas físicas
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
|
||||
|
||||
if dto.expected_attendees > classroom.capacity:
|
||||
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
commission_id=dto.commission_id,
|
||||
user_id=dto.user_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
purpose=dto.purpose,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
shift=dto.shift,
|
||||
virtual_link=dto.virtual_link,
|
||||
notes=dto.notes,
|
||||
status=dto.status or 'CONFIRMED'
|
||||
)
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
|
||||
classroom_id = dto.classroom_id or reservation.classroom_id
|
||||
start_time = dto.start_time or reservation.start_time
|
||||
end_time = dto.end_time or reservation.end_time
|
||||
|
||||
classroom = self.classroom_repo.get_by_id(classroom_id)
|
||||
if not classroom or not classroom.is_active:
|
||||
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
|
||||
|
||||
if not classroom.is_virtual:
|
||||
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
|
||||
if conflicts:
|
||||
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
|
||||
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
if dto.start_time is not None:
|
||||
reservation.start_time = dto.start_time
|
||||
if dto.end_time is not None:
|
||||
reservation.end_time = dto.end_time
|
||||
if dto.purpose is not None:
|
||||
reservation.purpose = dto.purpose
|
||||
if dto.expected_attendees is not None:
|
||||
reservation.expected_attendees = dto.expected_attendees
|
||||
if dto.shift is not None:
|
||||
reservation.shift = dto.shift
|
||||
if dto.virtual_link is not None:
|
||||
reservation.virtual_link = dto.virtual_link
|
||||
if dto.notes is not None:
|
||||
reservation.notes = dto.notes
|
||||
if dto.status is not None:
|
||||
reservation.status = dto.status
|
||||
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def cancel_reservation(self, reservation_id: int) -> bool:
|
||||
"""Cancela una reserva estableciendo su estado en CANCELLED."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
return False
|
||||
reservation.status = ReservationStatus.CANCELLED.value
|
||||
self.reservation_repo.save(reservation)
|
||||
return True
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app import db
|
||||
|
||||
class UserService:
|
||||
"""Capa de servicios para la autenticación, roles y perfil de usuarios."""
|
||||
|
||||
def __init__(self, repository: Optional[UserRepository] = None):
|
||||
self.repository = repository or UserRepository()
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Valida credenciales de acceso de un usuario.
|
||||
Retorna la entidad User si es válido y activo; None en caso contrario.
|
||||
"""
|
||||
user = self.repository.get_by_email(email)
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
user.last_login = datetime.utcnow()
|
||||
self.repository.save(user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Obtiene un usuario por ID."""
|
||||
return self.repository.get_by_id(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Obtiene un usuario por email."""
|
||||
return self.repository.get_by_email(email)
|
||||
|
||||
def get_profile_data(self, user: User) -> Dict[str, Any]:
|
||||
"""Genera un diccionario estructurado del perfil y permisos del usuario."""
|
||||
role_name = user.role_obj.name if user.role_obj else user.role
|
||||
permissions = {}
|
||||
if user.role_obj and hasattr(user.role_obj, 'permissions'):
|
||||
for p in user.role_obj.permissions:
|
||||
permissions[p.module] = p.access_level
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'role': role_name,
|
||||
'is_admin': user.is_admin(),
|
||||
'permissions': permissions,
|
||||
'preferred_language': user.preferred_language,
|
||||
'theme_preference': user.theme_preference
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, g
|
||||
import jwt
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.models.user import User
|
||||
|
||||
def jwt_required(f):
|
||||
"""
|
||||
Decorador para proteger endpoints REST mediante Bearer Token JWT.
|
||||
Extrae y valida el token, inyectando el usuario en 'g.jwt_user' y el payload en 'g.jwt_payload'.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
token = None
|
||||
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ', 1)[1].strip()
|
||||
elif 'access_token' in request.cookies:
|
||||
token = request.cookies.get('access_token')
|
||||
|
||||
if not token:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Encabezado Authorization con Bearer token no proporcionado.'
|
||||
}), 401
|
||||
|
||||
try:
|
||||
payload = JWTService.decode_token(token, expected_type='access')
|
||||
user_id = int(payload.get('sub'))
|
||||
from app import db
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return jsonify({
|
||||
'error': 'Unauthorized',
|
||||
'message': 'Usuario no encontrado o cuenta desactivada.'
|
||||
}), 401
|
||||
|
||||
g.jwt_user = user
|
||||
g.jwt_payload = payload
|
||||
g.jwt_token = token
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({
|
||||
'error': 'TokenExpired',
|
||||
'message': 'El token de acceso ha expirado. Por favor renueve su sesión.'
|
||||
}), 401
|
||||
except jwt.InvalidTokenError as e:
|
||||
return jsonify({
|
||||
'error': 'InvalidToken',
|
||||
'message': str(e)
|
||||
}), 401
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': 'AuthenticationError',
|
||||
'message': f'Fallo en la verificación del token: {str(e)}'
|
||||
}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def jwt_role_required(*allowed_roles):
|
||||
"""
|
||||
Decorador que verifica que el usuario autenticado vía JWT posea alguno de los roles permitidos.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@jwt_required
|
||||
def decorated_function(*args, **kwargs):
|
||||
user: User = g.jwt_user
|
||||
if user.is_admin():
|
||||
return f(*args, **kwargs)
|
||||
|
||||
user_role = (user.role_obj.name if user.role_obj else user.role).upper()
|
||||
allowed = [r.upper() for r in allowed_roles]
|
||||
|
||||
if user_role not in allowed:
|
||||
return jsonify({
|
||||
'error': 'Forbidden',
|
||||
'message': f'Acceso denegado. Se requiere uno de los siguientes roles: {", ".join(allowed_roles)}'
|
||||
}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
Reference in New Issue
Block a user