feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad
This commit is contained in:
@@ -29,6 +29,7 @@ def create_app(config_class=Config):
|
||||
from app.routes.api.optimizer import api_optimizer_bp
|
||||
from app.routes.api.dashboard import api_dashboard_bp
|
||||
from app.routes.api.admin import api_admin_bp
|
||||
from app.routes.api.openapi import api_openapi_bp
|
||||
from app.security import SecurityFilterChain
|
||||
|
||||
# Register API v1 blueprints
|
||||
@@ -37,6 +38,7 @@ def create_app(config_class=Config):
|
||||
app.register_blueprint(api_reservations_bp)
|
||||
app.register_blueprint(api_optimizer_bp)
|
||||
app.register_blueprint(api_dashboard_bp)
|
||||
app.register_blueprint(api_openapi_bp)
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1')
|
||||
app.register_blueprint(api_admin_bp, url_prefix='/api/v1/admin', name='api_admin_prefixed')
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .document_types import DOCUMENT_TYPES, DOCUMENT_TYPE_MAP, get_document_type, validate_document, format_document
|
||||
@@ -0,0 +1,160 @@
|
||||
import re
|
||||
|
||||
DOCUMENT_TYPES = [
|
||||
{
|
||||
'code': 'DNI',
|
||||
'name': 'Documento Nacional de Identidad',
|
||||
'short_name': 'DNI',
|
||||
'category': 'Nacional',
|
||||
'description': 'Documento oficial para ciudadanos argentinos nativos, naturalizados y residentes permanentes.',
|
||||
'regex': r'^\d{7,8}$',
|
||||
'placeholder': 'Ej: 38123456 (7 u 8 dígitos sin puntos)',
|
||||
'mask_example': '38.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CUIL',
|
||||
'name': 'Código Único de Identificación Laboral (CUIL/CUIT)',
|
||||
'short_name': 'CUIL',
|
||||
'category': 'Nacional / Laboral',
|
||||
'description': 'Identificador fiscal y laboral en Argentina (11 dígitos).',
|
||||
'regex': r'^\d{11}$|^\d{2}-\d{8}-\d{1}$',
|
||||
'placeholder': 'Ej: 20381234567 o 20-38123456-7',
|
||||
'mask_example': '20-38123456-7'
|
||||
},
|
||||
{
|
||||
'code': 'PAS',
|
||||
'name': 'Pasaporte Argentino',
|
||||
'short_name': 'Pasaporte ARG',
|
||||
'category': 'Internacional',
|
||||
'description': 'Pasaporte emitido por la República Argentina para viajes y acreditación internacional.',
|
||||
'regex': r'^[a-zA-Z0-9]{6,12}$',
|
||||
'placeholder': 'Ej: AAB123456',
|
||||
'mask_example': 'PAS AAB123456'
|
||||
},
|
||||
{
|
||||
'code': 'PASEXT',
|
||||
'name': 'Pasaporte Extranjero',
|
||||
'short_name': 'Pasaporte Extranjero',
|
||||
'category': 'Internacional / Extranjero',
|
||||
'description': 'Pasaporte oficial emitido por país de origen (docentes invitados, alumnos de intercambio).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{4,20}$',
|
||||
'placeholder': 'Ej: US987654321 / B1234567',
|
||||
'mask_example': 'PASEXT US987654321'
|
||||
},
|
||||
{
|
||||
'code': 'DNIEXT',
|
||||
'name': 'DNI para Extranjeros (Residente)',
|
||||
'short_name': 'DNI Extranjero',
|
||||
'category': 'Extranjero / Residente',
|
||||
'description': 'DNI argentino otorgado a ciudadanos extranjeros con radicación o residencia temporaria.',
|
||||
'regex': r'^[a-zA-Z0-9]{7,10}$',
|
||||
'placeholder': 'Ej: 94123456 o E94123456',
|
||||
'mask_example': 'DNI-EXT 94.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CI',
|
||||
'name': 'Cédula de Identidad (PFA / Policía Provincial)',
|
||||
'short_name': 'Cédula Identidad',
|
||||
'category': 'Nacional (Histórico)',
|
||||
'description': 'Cédula de Identidad emitida por Policía Federal Argentina o Policías Provinciales.',
|
||||
'regex': r'^\d{6,9}$',
|
||||
'placeholder': 'Ej: 6123456',
|
||||
'mask_example': 'CI 6.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'CIEXT',
|
||||
'name': 'Cédula de Identidad Extranjera (Mercosur)',
|
||||
'short_name': 'Cédula Mercosur',
|
||||
'category': 'Regional / Mercosur',
|
||||
'description': 'Cédula de Identidad emitida por países miembros o asociados al Mercosur (Brasil, Uruguay, etc.).',
|
||||
'regex': r'^[a-zA-Z0-9\-\.]{5,18}$',
|
||||
'placeholder': 'Ej: RG12345678 (Brasil) o 4.123.456-7 (Uruguay)',
|
||||
'mask_example': 'CI-EXT 12345678'
|
||||
},
|
||||
{
|
||||
'code': 'LC',
|
||||
'name': 'Libreta Cívica',
|
||||
'short_name': 'Libreta Cívica',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanas mujeres argentinas.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 5123456',
|
||||
'mask_example': 'LC 5.123.456'
|
||||
},
|
||||
{
|
||||
'code': 'LE',
|
||||
'name': 'Libreta de Enrolamiento',
|
||||
'short_name': 'Libreta Enrolamiento',
|
||||
'category': 'Histórico Nacional',
|
||||
'description': 'Documento histórico nacional para ciudadanos varones argentinos.',
|
||||
'regex': r'^\d{6,8}$',
|
||||
'placeholder': 'Ej: 4123456',
|
||||
'mask_example': 'LE 4.123.456'
|
||||
}
|
||||
]
|
||||
|
||||
DOCUMENT_TYPE_MAP = {doc['code']: doc for doc in DOCUMENT_TYPES}
|
||||
|
||||
def get_document_type(code):
|
||||
"""Devuelve la especificación de un tipo de documento o None."""
|
||||
if not code:
|
||||
return None
|
||||
return DOCUMENT_TYPE_MAP.get(str(code).strip().upper())
|
||||
|
||||
def validate_document(doc_type, doc_number):
|
||||
"""
|
||||
Valida un número de documento según las reglas de negocio de su tipificación.
|
||||
Retorna (is_valid: bool, cleaned_number: str, error_message: str|None).
|
||||
"""
|
||||
if not doc_type:
|
||||
return False, '', 'Debe especificar el tipo de documento.'
|
||||
|
||||
spec = get_document_type(doc_type)
|
||||
if not spec:
|
||||
return False, '', f"El tipo de documento '{doc_type}' no está registrado en el catálogo oficial."
|
||||
|
||||
if not doc_number:
|
||||
return True, '', None # Document number may be optional if pending registration
|
||||
|
||||
cleaned = str(doc_number).strip().upper()
|
||||
# Si es DNI, LC, LE, CI podemos remover puntos y guiones intermedios para validar y almacenar de forma uniforme
|
||||
if spec['code'] in ['DNI', 'LC', 'LE', 'CI', 'DNIEXT']:
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(spec['regex'], numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']}. Debe contener {spec['placeholder']}."
|
||||
|
||||
elif spec['code'] == 'CUIL':
|
||||
numeric_only = re.sub(r'[\.\-\s]', '', cleaned)
|
||||
if re.match(r'^\d{11}$', numeric_only):
|
||||
return True, numeric_only, None
|
||||
return False, cleaned, "El CUIL debe contener 11 dígitos numéricos (ej. 20-38123456-7)."
|
||||
|
||||
else:
|
||||
# Pasaportes o Cédulas Extranjeras
|
||||
if re.match(spec['regex'], cleaned):
|
||||
return True, cleaned, None
|
||||
return False, cleaned, f"Formato inválido para {spec['name']} ({spec['placeholder']})."
|
||||
|
||||
def format_document(doc_type, doc_number):
|
||||
"""
|
||||
Formatea visualmente un número de documento con puntos/guiones estándar.
|
||||
"""
|
||||
if not doc_number:
|
||||
return ''
|
||||
raw = str(doc_number).strip().upper()
|
||||
dtype = (doc_type or 'DNI').upper()
|
||||
|
||||
if dtype in ['DNI', 'LC', 'LE', 'CI']:
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 8:
|
||||
return f"{clean[:2]}.{clean[2:5]}.{clean[5:]}"
|
||||
elif len(clean) == 7:
|
||||
return f"{clean[:1]}.{clean[1:4]}.{clean[4:]}"
|
||||
return clean
|
||||
elif dtype == 'CUIL':
|
||||
clean = re.sub(r'\D', '', raw)
|
||||
if len(clean) == 11:
|
||||
return f"{clean[:2]}-{clean[2:10]}-{clean[10:]}"
|
||||
return clean
|
||||
return raw
|
||||
@@ -3,7 +3,7 @@ from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
from .subject import Subject, Commission
|
||||
from .subject import Subject, Commission, CommissionTeacher
|
||||
from .reservation import Reservation, ReservationStatus
|
||||
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
|
||||
from .role import Role, Permission, SYSTEM_MODULES
|
||||
@@ -21,6 +21,7 @@ __all__ = [
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'CommissionTeacher',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
|
||||
@@ -23,6 +23,8 @@ class StudentEnrollment(db.Model):
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
allow_same_day_exception = db.Column(db.Boolean, default=False, nullable=True)
|
||||
exception_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
@@ -43,6 +45,8 @@ class StudentEnrollment(db.Model):
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
'allow_same_day_exception': bool(self.allow_same_day_exception),
|
||||
'exception_reason': self.exception_reason
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
+185
-118
@@ -1,118 +1,185 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_name': self.teacher_name,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
is_short_course = db.Column(db.Boolean, default=False, nullable=True) # True para talleres/cursos cortos
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'is_short_course': bool(self.is_short_course),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
@property
|
||||
def teachers(self):
|
||||
"""Retorna la lista de docentes asignados.
|
||||
Si teachers_association tiene registros, los retorna ordenados (Titular primero).
|
||||
Si está vacía pero teacher_id está configurado, devuelve el docente titular.
|
||||
"""
|
||||
if hasattr(self, 'teachers_association') and self.teachers_association and len(self.teachers_association) > 0:
|
||||
res = [ta.to_dict() for ta in self.teachers_association]
|
||||
res.sort(key=lambda x: (not x.get('is_primary', False), x.get('role', '') != 'Titular'))
|
||||
return res
|
||||
if self.teacher:
|
||||
return [{
|
||||
'id': 0,
|
||||
'commission_id': self.id,
|
||||
'user_id': self.teacher.id,
|
||||
'name': self.teacher.name,
|
||||
'email': self.teacher.email,
|
||||
'role': 'Titular',
|
||||
'is_primary': True
|
||||
}]
|
||||
return []
|
||||
|
||||
@property
|
||||
def teacher_names(self):
|
||||
t_list = self.teachers
|
||||
if not t_list:
|
||||
return "Sin asignar"
|
||||
return ", ".join([f"{t['name']} ({t['role']})" for t in t_list])
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_id': self.teacher_id,
|
||||
'teacher_name': self.teacher_name,
|
||||
'teachers': self.teachers,
|
||||
'teacher_names': self.teacher_names,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
|
||||
|
||||
class CommissionTeacher(db.Model):
|
||||
"""Modelo de asignación de múltiples docentes a una comisión (Co-Docencia / Cátedra)."""
|
||||
__tablename__ = 'commission_teachers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
role = db.Column(db.String(50), default='Titular', nullable=False) # Titular, Adjunto, JTP, Ayudante
|
||||
is_primary = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commission = db.relationship('Commission', backref=db.backref('teachers_association', cascade='all, delete-orphan', lazy='joined'))
|
||||
teacher = db.relationship('User', backref=db.backref('commission_assignments', cascade='all, delete-orphan', lazy='joined'))
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('commission_id', 'user_id'),)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'commission_id': self.commission_id,
|
||||
'user_id': self.user_id,
|
||||
'name': self.teacher.name if self.teacher else 'Docente',
|
||||
'email': self.teacher.email if self.teacher else '',
|
||||
'role': self.role or 'Titular',
|
||||
'is_primary': self.is_primary,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CommissionTeacher Commission:{self.commission_id} User:{self.user_id} Role:{self.role}>'␍
|
||||
@@ -17,15 +17,29 @@ class User(db.Model):
|
||||
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Virtual fields for future compatibility
|
||||
@property
|
||||
def first_name(self):
|
||||
# Extended personal, contact and identity document fields
|
||||
first_name = db.Column(db.String(100), nullable=True)
|
||||
last_name = db.Column(db.String(100), nullable=True)
|
||||
phone = db.Column(db.String(50), nullable=True)
|
||||
personal_email = db.Column(db.String(255), nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
document_type = db.Column(db.String(20), default='DNI', nullable=True)
|
||||
document_number = db.Column(db.String(30), nullable=True, index=True)
|
||||
|
||||
def get_first_name(self):
|
||||
if self.first_name:
|
||||
return self.first_name
|
||||
return self.name.split()[0] if self.name else ''
|
||||
|
||||
@property
|
||||
def last_name(self):
|
||||
parts = self.name.split()
|
||||
def get_last_name(self):
|
||||
if self.last_name:
|
||||
return self.last_name
|
||||
parts = self.name.split() if self.name else []
|
||||
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
def get_formatted_document(self):
|
||||
from app.constants.document_types import format_document
|
||||
return format_document(self.document_type, self.document_number)
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
@@ -132,8 +146,18 @@ class User(db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'email': self.email,
|
||||
'institutional_email': self.email,
|
||||
'personal_email': self.personal_email or '',
|
||||
'name': self.name,
|
||||
'first_name': self.get_first_name(),
|
||||
'last_name': self.get_last_name(),
|
||||
'phone': self.phone or '',
|
||||
'address': self.address or '',
|
||||
'document_type': self.document_type or 'DNI',
|
||||
'document_number': self.document_number or '',
|
||||
'document_formatted': self.get_formatted_document(),
|
||||
'role': self.role,
|
||||
'role_id': self.role_id,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
|
||||
@@ -59,3 +59,22 @@ class ReservationRepository(BaseRepository[Reservation]):
|
||||
return Reservation.query.filter_by(
|
||||
commission_id=commission_id
|
||||
).order_by(Reservation.start_time.asc()).all()
|
||||
|
||||
def find_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_reservation_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""
|
||||
Encuentra reservas activas donde el docente ya está asignado en la misma franja horaria.
|
||||
Condición de solapamiento: (start_time < existing.end_time) AND (end_time > existing.start_time).
|
||||
"""
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
).filter(
|
||||
Reservation.user_id == teacher_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()
|
||||
|
||||
+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
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ReservationBaseDTO(BaseModel):
|
||||
return self
|
||||
|
||||
class ReservationCreateDTO(ReservationBaseDTO):
|
||||
pass
|
||||
allow_co_teaching_exception: Optional[bool] = Field(default=False, description="Excepción justificada si hay co-docencia")
|
||||
|
||||
class ReservationUpdateDTO(BaseModel):
|
||||
classroom_id: Optional[int] = None
|
||||
@@ -34,6 +34,7 @@ class ReservationUpdateDTO(BaseModel):
|
||||
virtual_link: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
allow_co_teaching_exception: Optional[bool] = False
|
||||
|
||||
class ReservationResponseDTO(ReservationBaseDTO):
|
||||
id: int
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import re
|
||||
from typing import Optional, List, Set, Dict, Any
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.subject import Commission, Subject
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.models.reservation import Reservation
|
||||
|
||||
WEEKDAYS_MAP = {
|
||||
0: 'lunes',
|
||||
1: 'martes',
|
||||
2: 'miercoles',
|
||||
3: 'jueves',
|
||||
4: 'viernes',
|
||||
5: 'sabado',
|
||||
6: 'domingo'
|
||||
}
|
||||
|
||||
SPANISH_DAYS = ['lunes', 'martes', 'miercoles', 'miércoles', 'jueves', 'viernes', 'sabado', 'sábado', 'domingo']
|
||||
|
||||
class EnrollmentService:
|
||||
"""
|
||||
Servicio de matriculación y aplicación de reglas de negocio académicas (Fase 2 MVP):
|
||||
- Control estricto de aforo y cupo de comisión.
|
||||
- Prevención de doble inscripción.
|
||||
- Regla de Restricción Diaria del Alumno (máximo 1 asignatura regular por día calendario).
|
||||
- Soporte de excepciones automáticas por curso corto y excepciones autorizadas de Bedelía.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def normalize_day(day_str: str) -> str:
|
||||
d = day_str.lower().strip()
|
||||
if 'miér' in d or 'mier' in d:
|
||||
return 'miercoles'
|
||||
if 'sáb' in d or 'sab' in d:
|
||||
return 'sabado'
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def get_commission_days(cls, commission: Commission) -> Set[str]:
|
||||
"""Extrae el conjunto de días de cursada de la comisión desde schedule y reservas activas."""
|
||||
days: Set[str] = set()
|
||||
|
||||
# 1. Analizar texto del campo schedule
|
||||
if commission.schedule:
|
||||
sched_lower = commission.schedule.lower()
|
||||
for d in SPANISH_DAYS:
|
||||
if re.search(r'\b' + re.escape(d) + r'\b', sched_lower):
|
||||
days.add(cls.normalize_day(d))
|
||||
|
||||
# 2. Analizar reservas reales de la comisión
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.commission_id == commission.id,
|
||||
Reservation.status != 'CANCELLED'
|
||||
).all()
|
||||
for r in reservations:
|
||||
if r.start_time:
|
||||
weekday_idx = r.start_time.weekday()
|
||||
if weekday_idx in WEEKDAYS_MAP:
|
||||
days.add(WEEKDAYS_MAP[weekday_idx])
|
||||
|
||||
return days
|
||||
|
||||
@classmethod
|
||||
def list_enrollments(cls, commission_id: int) -> List[Dict[str, Any]]:
|
||||
"""Lista todos los alumnos matriculados en una comisión con información extendida."""
|
||||
enrollments = StudentEnrollment.query.filter_by(commission_id=commission_id).all()
|
||||
result = []
|
||||
for enr in enrollments:
|
||||
data = enr.to_dict()
|
||||
if enr.student:
|
||||
data['student'] = {
|
||||
'id': enr.student.id,
|
||||
'name': enr.student.name,
|
||||
'email': enr.student.email,
|
||||
'personal_email': getattr(enr.student, 'personal_email', None),
|
||||
'phone': getattr(enr.student, 'phone', None),
|
||||
'document_type': getattr(enr.student, 'document_type', None),
|
||||
'document_number': getattr(enr.student, 'document_number', None)
|
||||
}
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def enroll_student(cls, commission_id: int, student_id: int,
|
||||
notes: Optional[str] = None,
|
||||
allow_same_day_exception: bool = False,
|
||||
exception_reason: Optional[str] = None) -> StudentEnrollment:
|
||||
"""
|
||||
Matricula a un estudiante en una comisión aplicando la regla de restricción diaria.
|
||||
"""
|
||||
commission = Commission.query.get(commission_id)
|
||||
if not commission or not commission.active:
|
||||
raise ValueError(f"La comisión #{commission_id} no existe o no se encuentra activa.")
|
||||
|
||||
student = User.query.get(student_id)
|
||||
if not student or not student.is_active:
|
||||
raise ValueError(f"El alumno #{student_id} no existe o su cuenta se encuentra inactiva.")
|
||||
|
||||
# Verificar si ya está matriculado
|
||||
existing = StudentEnrollment.query.filter_by(commission_id=commission_id, student_id=student_id).first()
|
||||
if existing:
|
||||
if existing.status == 'activo':
|
||||
raise ValueError(f"El alumno {student.name} ya está matriculado activamente en esta comisión.")
|
||||
# Si estaba retirado, se reactiva
|
||||
existing.status = 'activo'
|
||||
existing.notes = notes or existing.notes
|
||||
existing.allow_same_day_exception = allow_same_day_exception
|
||||
existing.exception_reason = exception_reason if allow_same_day_exception else None
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
return existing
|
||||
|
||||
# Verificar cupo máximo
|
||||
if commission.max_students and (commission.current_students or 0) >= commission.max_students:
|
||||
raise ValueError(f"La comisión ha alcanzado su cupo máximo de {commission.max_students} estudiantes.")
|
||||
|
||||
# ─── REGLA DE RESTRICCIÓN DIARIA DE CURSADA (ESTADIO 2.3) ───
|
||||
target_subject = commission.subject
|
||||
is_target_short_course = bool(target_subject and target_subject.is_short_course)
|
||||
target_days = cls.get_commission_days(commission)
|
||||
|
||||
# Buscar otras inscripciones activas del estudiante
|
||||
active_enrollments = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id,
|
||||
status='activo'
|
||||
).all()
|
||||
|
||||
for enr in active_enrollments:
|
||||
other_comm = enr.commission
|
||||
if not other_comm or not other_comm.active:
|
||||
continue
|
||||
|
||||
# Verificar si coinciden en el mismo ciclo/semestre/año
|
||||
same_term = (
|
||||
other_comm.year == commission.year and
|
||||
other_comm.semester == commission.semester
|
||||
)
|
||||
if not same_term:
|
||||
continue
|
||||
|
||||
other_subject = other_comm.subject
|
||||
is_other_short_course = bool(other_subject and other_subject.is_short_course)
|
||||
|
||||
# Ambas son materias regulares: no pueden cursarse el mismo día sin excepción
|
||||
if not is_target_short_course and not is_other_short_course:
|
||||
other_days = cls.get_commission_days(other_comm)
|
||||
overlapping_days = target_days.intersection(other_days)
|
||||
|
||||
if overlapping_days:
|
||||
if allow_same_day_exception:
|
||||
# Excepción otorgada expresamente por Bedelía
|
||||
if not exception_reason:
|
||||
exception_reason = "Autorización expresa de Bedelía para cursada simultánea en el mismo día."
|
||||
else:
|
||||
days_display = ", ".join([d.capitalize() for d in overlapping_days])
|
||||
other_name = other_subject.name if other_subject else other_comm.code
|
||||
raise ValueError(
|
||||
f"Restricción de cursada diaria: El alumno ya cursa la materia regular '{other_name}' "
|
||||
f"el día {days_display}. La normativa de UniCABA prohíbe cursar dos materias regulares "
|
||||
f"el mismo día calendario salvo curso corto o autorización explícita de Bedelía."
|
||||
)
|
||||
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
commission_id=commission_id,
|
||||
status='activo',
|
||||
notes=notes,
|
||||
allow_same_day_exception=allow_same_day_exception,
|
||||
exception_reason=exception_reason if allow_same_day_exception else None
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
db.session.commit()
|
||||
return enrollment
|
||||
|
||||
@classmethod
|
||||
def unenroll_student(cls, commission_id: int, student_id: int) -> bool:
|
||||
"""Da de baja o retira a un alumno de una comisión."""
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
commission_id=commission_id,
|
||||
student_id=student_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
return False
|
||||
|
||||
commission = Commission.query.get(commission_id)
|
||||
if commission and (commission.current_students or 0) > 0:
|
||||
commission.current_students -= 1
|
||||
|
||||
db.session.delete(enrollment)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def update_enrollment_status(cls, commission_id: int, student_id: int, status: str,
|
||||
notes: Optional[str] = None) -> Optional[StudentEnrollment]:
|
||||
"""Actualiza el estado de la matrícula (activo, condicional, retirado)."""
|
||||
enrollment = StudentEnrollment.query.filter_by(
|
||||
commission_id=commission_id,
|
||||
student_id=student_id
|
||||
).first()
|
||||
if not enrollment:
|
||||
return None
|
||||
|
||||
old_status = enrollment.status
|
||||
enrollment.status = status
|
||||
if notes is not None:
|
||||
enrollment.notes = notes
|
||||
|
||||
commission = Commission.query.get(commission_id)
|
||||
if commission:
|
||||
if old_status == 'activo' and status in ['retirado']:
|
||||
commission.current_students = max(0, (commission.current_students or 1) - 1)
|
||||
elif old_status in ['retirado'] and status == 'activo':
|
||||
commission.current_students = (commission.current_students or 0) + 1
|
||||
|
||||
db.session.commit()
|
||||
return enrollment
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission, CommissionTeacher
|
||||
from app.repositories.reservation_repository import ReservationRepository
|
||||
from app.repositories.classroom_repository import ClassroomRepository
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
|
||||
@@ -38,23 +39,116 @@ class ReservationService:
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime,
|
||||
exclude_id: Optional[int] = None) -> List[Reservation]:
|
||||
"""Verifica si el docente ya tiene otra reserva en la misma franja horaria."""
|
||||
if not teacher_id:
|
||||
return []
|
||||
return self.reservation_repo.find_teacher_conflicts(
|
||||
teacher_id=teacher_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
exclude_reservation_id=exclude_id
|
||||
)
|
||||
|
||||
def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime,
|
||||
teacher_id: Optional[int] = None,
|
||||
commission_id: Optional[int] = None,
|
||||
expected_attendees: Optional[int] = None,
|
||||
allow_co_teaching_exception: bool = False,
|
||||
exclude_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Matriz completa de validación de conflictos (Fase 2 MVP):
|
||||
1. Conflicto físico de aula (bloqueo estricto para aulas físicas).
|
||||
2. Conflicto docente (detección con soporte de co-docencia).
|
||||
3. Conflicto de aforo (capacidad física).
|
||||
"""
|
||||
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.")
|
||||
|
||||
commission = None
|
||||
if commission_id:
|
||||
commission = Commission.query.get(commission_id)
|
||||
if not teacher_id and commission:
|
||||
teacher_id = commission.teacher_id
|
||||
|
||||
# 1. Conflicto Físico
|
||||
physical_conflicts = []
|
||||
if not classroom.is_virtual:
|
||||
physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id)
|
||||
|
||||
# 2. Conflicto Docente y Co-docencia
|
||||
teacher_conflicts = []
|
||||
has_co_teaching_coverage = False
|
||||
co_teachers_list = []
|
||||
|
||||
if teacher_id:
|
||||
teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id)
|
||||
if teacher_conflicts and commission:
|
||||
# Comprobar si la comisión cuenta con equipo docente de respaldo
|
||||
co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all()
|
||||
other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id]
|
||||
if other_teachers:
|
||||
has_co_teaching_coverage = True
|
||||
co_teachers_list = [
|
||||
{'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role}
|
||||
for ct in other_teachers
|
||||
]
|
||||
|
||||
# 3. Conflicto de Aforo
|
||||
has_capacity_conflict = False
|
||||
capacity_message = None
|
||||
if not classroom.is_virtual and expected_attendees is not None:
|
||||
if expected_attendees > classroom.capacity:
|
||||
has_capacity_conflict = True
|
||||
capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})."
|
||||
|
||||
# Evaluación de Bloqueos
|
||||
block_reasons = []
|
||||
if physical_conflicts:
|
||||
c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts]
|
||||
block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.")
|
||||
|
||||
if has_capacity_conflict:
|
||||
block_reasons.append(f"Conflicto de aforo: {capacity_message}")
|
||||
|
||||
if teacher_conflicts:
|
||||
if has_co_teaching_coverage or allow_co_teaching_exception:
|
||||
# Cobertura justificada por equipo de co-docencia
|
||||
pass
|
||||
else:
|
||||
t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts]
|
||||
block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.")
|
||||
|
||||
is_blocked = len(block_reasons) > 0
|
||||
return {
|
||||
'is_valid': not is_blocked,
|
||||
'is_blocked': is_blocked,
|
||||
'block_reasons': block_reasons,
|
||||
'physical_conflicts': [c.to_dict() for c in physical_conflicts],
|
||||
'teacher_conflicts': [c.to_dict() for c in teacher_conflicts],
|
||||
'has_co_teaching_coverage': has_co_teaching_coverage,
|
||||
'co_teachers': co_teachers_list,
|
||||
'has_capacity_conflict': has_capacity_conflict,
|
||||
'classroom': classroom.to_dict()
|
||||
}
|
||||
|
||||
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
|
||||
"""
|
||||
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
|
||||
Crea una nueva reserva aplicando la matriz integral de conflictos.
|
||||
"""
|
||||
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}).")
|
||||
matrix = self.check_full_conflicts_matrix(
|
||||
classroom_id=dto.classroom_id,
|
||||
start_time=dto.start_time,
|
||||
end_time=dto.end_time,
|
||||
teacher_id=dto.user_id,
|
||||
commission_id=dto.commission_id,
|
||||
expected_attendees=dto.expected_attendees,
|
||||
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False)
|
||||
)
|
||||
if matrix['is_blocked']:
|
||||
raise ValueError(" | ".join(matrix['block_reasons']))
|
||||
|
||||
reservation = Reservation(
|
||||
classroom_id=dto.classroom_id,
|
||||
@@ -72,7 +166,7 @@ class ReservationService:
|
||||
return self.reservation_repo.save(reservation)
|
||||
|
||||
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria."""
|
||||
"""Actualiza una reserva existente verificando disponibilidad horaria y matriz de conflictos."""
|
||||
reservation = self.reservation_repo.get_by_id(reservation_id)
|
||||
if not reservation:
|
||||
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
|
||||
@@ -80,16 +174,20 @@ class ReservationService:
|
||||
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
|
||||
expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees
|
||||
|
||||
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}.")
|
||||
matrix = self.check_full_conflicts_matrix(
|
||||
classroom_id=classroom_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
teacher_id=reservation.user_id,
|
||||
commission_id=reservation.commission_id,
|
||||
expected_attendees=expected_attendees,
|
||||
allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False),
|
||||
exclude_id=reservation.id
|
||||
)
|
||||
if matrix['is_blocked']:
|
||||
raise ValueError(" | ".join(matrix['block_reasons']))
|
||||
|
||||
if dto.classroom_id is not None:
|
||||
reservation.classroom_id = dto.classroom_id
|
||||
|
||||
@@ -76,11 +76,13 @@ class GoogleSheetsImporter:
|
||||
"""Fetch CSV string for a specific sheet gid"""
|
||||
separator = '&' if '?' in self.base_url else '?'
|
||||
url = f"{self.base_url}{separator}gid={gid}"
|
||||
if not (url.startswith('https://') or url.startswith('http://')):
|
||||
raise ValueError(f"URL scheme not permitted: {url}")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
with urllib.request.urlopen(req, timeout=25) as resp: # nosec B310
|
||||
return resp.read().decode('utf-8', errors='replace')
|
||||
|
||||
def parse_sheet_rows(self, csv_content, sheet_name):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Migration script for Phase 2 MVP fields:
|
||||
- subjects.is_short_course (BOOLEAN DEFAULT 0)
|
||||
- student_enrollments.allow_same_day_exception (BOOLEAN DEFAULT 0)
|
||||
- student_enrollments.exception_reason (VARCHAR(255))
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
def migrate():
|
||||
db_paths = [
|
||||
os.path.join(os.path.dirname(__file__), 'instance', 'app.db'),
|
||||
os.path.join(os.path.dirname(__file__), 'app.db')
|
||||
]
|
||||
for db_path in db_paths:
|
||||
if not os.path.exists(db_path):
|
||||
continue
|
||||
print(f"Migrating SQLite DB: {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check subjects.is_short_course
|
||||
cursor.execute("PRAGMA table_info(subjects)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'is_short_course' not in columns:
|
||||
print("Adding column 'is_short_course' to subjects table...")
|
||||
cursor.execute("ALTER TABLE subjects ADD COLUMN is_short_course BOOLEAN DEFAULT 0")
|
||||
|
||||
# Check student_enrollments.allow_same_day_exception
|
||||
cursor.execute("PRAGMA table_info(student_enrollments)")
|
||||
enr_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'allow_same_day_exception' not in enr_columns:
|
||||
print("Adding column 'allow_same_day_exception' to student_enrollments table...")
|
||||
cursor.execute("ALTER TABLE student_enrollments ADD COLUMN allow_same_day_exception BOOLEAN DEFAULT 0")
|
||||
if 'exception_reason' not in enr_columns:
|
||||
print("Adding column 'exception_reason' to student_enrollments table...")
|
||||
cursor.execute("ALTER TABLE student_enrollments ADD COLUMN exception_reason VARCHAR(255)")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Migration completed for {db_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate()
|
||||
@@ -0,0 +1,79 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Locate database
|
||||
possible_paths = [
|
||||
os.path.join(os.path.dirname(__file__), 'instance', 'edu_space.db'),
|
||||
os.path.join(os.path.dirname(__file__), 'instance', 'app.db'),
|
||||
os.path.join(os.path.dirname(__file__), 'edu_space.db'),
|
||||
os.path.join(os.path.dirname(__file__), 'app.db'),
|
||||
]
|
||||
|
||||
db_path = None
|
||||
for p in possible_paths:
|
||||
if os.path.exists(p):
|
||||
db_path = p
|
||||
break
|
||||
|
||||
if not db_path:
|
||||
# Check current directory
|
||||
for root, dirs, files in os.walk(os.path.dirname(__file__)):
|
||||
for f in files:
|
||||
if f.endswith('.db'):
|
||||
db_path = os.path.join(root, f)
|
||||
break
|
||||
if db_path:
|
||||
break
|
||||
|
||||
print(f"Connecting to database: {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check existing columns in users table
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
print(f"Existing columns in 'users': {columns}")
|
||||
|
||||
new_columns = [
|
||||
('first_name', 'VARCHAR(100)'),
|
||||
('last_name', 'VARCHAR(100)'),
|
||||
('phone', 'VARCHAR(50)'),
|
||||
('address', 'VARCHAR(255)'),
|
||||
('document_type', 'VARCHAR(20) DEFAULT "DNI"'),
|
||||
('document_number', 'VARCHAR(30)')
|
||||
]
|
||||
|
||||
for col_name, col_type in new_columns:
|
||||
if col_name not in columns:
|
||||
print(f"Adding column '{col_name}'...")
|
||||
cursor.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
|
||||
else:
|
||||
print(f"Column '{col_name}' already exists.")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Create index on document_number if not exists
|
||||
try:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_document_number ON users(document_number)")
|
||||
conn.commit()
|
||||
print("Index on document_number verified.")
|
||||
except Exception as e:
|
||||
print(f"Notice on index: {e}")
|
||||
|
||||
# Migrate existing users: split name into first_name and last_name if empty
|
||||
cursor.execute("SELECT id, name, first_name, last_name FROM users")
|
||||
users = cursor.fetchall()
|
||||
migrated = 0
|
||||
for u_id, name, fn, ln in users:
|
||||
if not fn and name:
|
||||
parts = name.strip().split()
|
||||
first_n = parts[0] if parts else ''
|
||||
last_n = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
cursor.execute("UPDATE users SET first_name = ?, last_name = ? WHERE id = ?", (first_n, last_n, u_id))
|
||||
migrated += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"Successfully migrated {migrated} users with first_name and last_name from name.")
|
||||
conn.close()
|
||||
print("Migration completed successfully!")
|
||||
@@ -1,4 +1,4 @@
|
||||
Flask==3.0.0
|
||||
Flask>=3.1.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-WTF==1.2.1
|
||||
@@ -6,11 +6,11 @@ Flask-Migrate==4.0.5
|
||||
Flask-CORS==6.0.2
|
||||
Flask-Babel==4.0.0
|
||||
WTForms==3.1.0
|
||||
Werkzeug==3.0.1
|
||||
Werkzeug>=3.1.6
|
||||
SQLAlchemy>=2.0.32
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
bleach==6.1.0
|
||||
python-dotenv>=1.2.2
|
||||
requests>=2.32.4
|
||||
bleach>=6.4.0
|
||||
python-dateutil==2.8.2
|
||||
Pillow>=10.4.0
|
||||
email_validator>=2.0.0
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission, CommissionTeacher
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.enrollment import StudentEnrollment
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.services.enrollment_service import EnrollmentService
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO
|
||||
|
||||
class TestPhase2Rules(unittest.TestCase):
|
||||
"""Pruebas unitarias e integrales para la Fase 2 del Roadmap MVP (UniCABA)."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
self.res_service = ReservationService()
|
||||
|
||||
# Obtener o crear rol alumno y docente
|
||||
self.role_alumno = Role.query.filter_by(name='Alumno').first()
|
||||
if not self.role_alumno:
|
||||
self.role_alumno = Role(name='Alumno', description='Rol Alumno')
|
||||
db.session.add(self.role_alumno)
|
||||
|
||||
self.role_docente = Role.query.filter_by(name='Docente').first()
|
||||
if not self.role_docente:
|
||||
self.role_docente = Role(name='Docente', description='Rol Docente')
|
||||
db.session.add(self.role_docente)
|
||||
db.session.commit()
|
||||
|
||||
# Crear usuarios para tests
|
||||
ts = int(datetime.utcnow().timestamp())
|
||||
self.docente1 = User(
|
||||
name=f'Profesor Principal {ts}',
|
||||
email=f'docente1_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
self.docente1.set_password('Secret123!')
|
||||
|
||||
self.docente2 = User(
|
||||
name=f'Profesor Adjunto {ts}',
|
||||
email=f'docente2_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_docente.id,
|
||||
role='Docente',
|
||||
is_active=True
|
||||
)
|
||||
self.docente2.set_password('Secret123!')
|
||||
|
||||
self.alumno1 = User(
|
||||
name=f'Estudiante Test {ts}',
|
||||
email=f'alumno_{ts}@unicaba.edu.ar',
|
||||
role_id=self.role_alumno.id,
|
||||
role='Alumno',
|
||||
is_active=True
|
||||
)
|
||||
self.alumno1.set_password('Secret123!')
|
||||
|
||||
# Aulas física y virtual
|
||||
self.aula_fisica = Classroom(
|
||||
code=f'A-501-{ts}',
|
||||
capacity=30,
|
||||
building='Sede Central',
|
||||
floor='Piso 5',
|
||||
is_active=True
|
||||
)
|
||||
self.aula_virtual = Classroom(
|
||||
code=f'VIRTUAL-MEET-{ts}',
|
||||
capacity=100,
|
||||
building='Campus Virtual',
|
||||
floor='Plataforma Digital',
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Asignaturas regulares y curso corto
|
||||
self.materia_regular_a = Subject(
|
||||
code=f'REG-A-{ts}',
|
||||
name=f'Algoritmos y Estructuras {ts}',
|
||||
is_short_course=False,
|
||||
is_active=True
|
||||
)
|
||||
self.materia_regular_b = Subject(
|
||||
code=f'REG-B-{ts}',
|
||||
name=f'Sistemas Operativos {ts}',
|
||||
is_short_course=False,
|
||||
is_active=True
|
||||
)
|
||||
self.curso_corto = Subject(
|
||||
code=f'TALLER-{ts}',
|
||||
name=f'Taller de Herramientas Git {ts}',
|
||||
is_short_course=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.session.add_all([
|
||||
self.docente1, self.docente2, self.alumno1,
|
||||
self.aula_fisica, self.aula_virtual,
|
||||
self.materia_regular_a, self.materia_regular_b, self.curso_corto
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
# Comisiones
|
||||
self.comision_a = Commission(
|
||||
subject_id=self.materia_regular_a.id,
|
||||
code=f'COM-A-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente1.id,
|
||||
max_students=40,
|
||||
current_students=0,
|
||||
schedule='Lunes 08:00 - 12:00',
|
||||
active=True
|
||||
)
|
||||
self.comision_b = Commission(
|
||||
subject_id=self.materia_regular_b.id,
|
||||
code=f'COM-B-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente1.id,
|
||||
max_students=40,
|
||||
current_students=0,
|
||||
schedule='Lunes 14:00 - 18:00',
|
||||
active=True
|
||||
)
|
||||
self.comision_c_corto = Commission(
|
||||
subject_id=self.curso_corto.id,
|
||||
code=f'COM-CORTO-{ts}',
|
||||
semester='1C',
|
||||
year=2026,
|
||||
teacher_id=self.docente2.id,
|
||||
max_students=20,
|
||||
current_students=0,
|
||||
schedule='Lunes 18:00 - 20:00',
|
||||
active=True
|
||||
)
|
||||
db.session.add_all([self.comision_a, self.comision_b, self.comision_c_corto])
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_01_physical_conflict_blocks_double_booking(self):
|
||||
"""Estadio 2.2: Debe bloquear doble asignación en misma aula física y horario."""
|
||||
start = datetime(2026, 10, 5, 8, 0)
|
||||
end = datetime(2026, 10, 5, 12, 0)
|
||||
|
||||
# Primera reserva exitosa
|
||||
dto1 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=25
|
||||
)
|
||||
res1 = self.res_service.create_reservation(dto1)
|
||||
self.assertIsNotNone(res1.id)
|
||||
|
||||
# Segunda reserva en la misma aula solapándose (09:00 a 11:00)
|
||||
dto2 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente2.id,
|
||||
start_time=start + timedelta(hours=1),
|
||||
end_time=end - timedelta(hours=1),
|
||||
expected_attendees=20
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto2)
|
||||
self.assertIn("Conflicto de horario", str(ctx.exception))
|
||||
|
||||
def test_02_capacity_aforo_validation(self):
|
||||
"""Estadio 2.2: Debe bloquear reserva si asistentes esperados superan capacidad física."""
|
||||
start = datetime(2026, 10, 6, 8, 0)
|
||||
end = datetime(2026, 10, 6, 12, 0)
|
||||
|
||||
dto = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=self.aula_fisica.capacity + 15 # supera capacidad (30)
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto)
|
||||
self.assertIn("Conflicto de aforo", str(ctx.exception))
|
||||
|
||||
def test_03_teacher_conflict_and_co_teaching_exception(self):
|
||||
"""Estadio 2.2: Detección de conflicto docente y excepción justificada por co-docencia."""
|
||||
start = datetime(2026, 10, 7, 14, 0)
|
||||
end = datetime(2026, 10, 7, 18, 0)
|
||||
|
||||
# Docente 1 dicta clase en aula física
|
||||
dto1 = ReservationCreateDTO(
|
||||
classroom_id=self.aula_fisica.id,
|
||||
commission_id=self.comision_a.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=20
|
||||
)
|
||||
self.res_service.create_reservation(dto1)
|
||||
|
||||
# Mismo docente 1 intenta otra clase en sala virtual al mismo tiempo
|
||||
dto_conflict = ReservationCreateDTO(
|
||||
classroom_id=self.aula_virtual.id,
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente1.id,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
expected_attendees=20
|
||||
)
|
||||
|
||||
# Sin co-docencia debe bloquear
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.res_service.create_reservation(dto_conflict)
|
||||
self.assertIn("Conflicto de horario docente", str(ctx.exception))
|
||||
|
||||
# Ahora incorporamos a Docente 2 como co-docente en comision_b
|
||||
ct = CommissionTeacher(
|
||||
commission_id=self.comision_b.id,
|
||||
user_id=self.docente2.id,
|
||||
role='Adjunto'
|
||||
)
|
||||
db.session.add(ct)
|
||||
db.session.commit()
|
||||
|
||||
# Con co-docencia registrada, la matriz detecta cobertura docente y permite la reserva
|
||||
res_co = self.res_service.create_reservation(dto_conflict)
|
||||
self.assertIsNotNone(res_co.id)
|
||||
|
||||
def test_04_student_same_day_restriction_rule(self):
|
||||
"""Estadio 2.3: Un alumno no puede cursar dos asignaturas regulares el mismo día sin excepción."""
|
||||
# Inscribir al alumno en comision_a (Lunes regular)
|
||||
enr1 = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertEqual(enr1.status, 'activo')
|
||||
|
||||
# Intentar inscribir en comision_b (también Lunes regular): debe BLOQUEAR
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_b.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertIn("Restricción de cursada diaria", str(ctx.exception))
|
||||
self.assertIn("Lunes", str(ctx.exception))
|
||||
|
||||
def test_05_student_short_course_bypass(self):
|
||||
"""Estadio 2.3: Si una de las materias es curso corto, se autoriza automáticamente."""
|
||||
# El alumno cursa comision_a los Lunes
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
|
||||
# Intentar inscribir en curso corto (Lunes taller): debe PERMITIR
|
||||
enr_short = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_c_corto.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
self.assertIsNotNone(enr_short.id)
|
||||
self.assertEqual(enr_short.status, 'activo')
|
||||
|
||||
def test_06_student_bedelia_manual_exception_bypass(self):
|
||||
"""Estadio 2.3: Con autorización expresa de Bedelía (allow_same_day_exception), se permite."""
|
||||
# Alumno cursa comision_a los Lunes
|
||||
EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_a.id,
|
||||
student_id=self.alumno1.id
|
||||
)
|
||||
|
||||
# Inscribir en comision_b con allow_same_day_exception=True
|
||||
enr2 = EnrollmentService.enroll_student(
|
||||
commission_id=self.comision_b.id,
|
||||
student_id=self.alumno1.id,
|
||||
allow_same_day_exception=True,
|
||||
exception_reason="Autorización especial de Bedelía por cambio de plan de estudios"
|
||||
)
|
||||
self.assertIsNotNone(enr2.id)
|
||||
self.assertTrue(enr2.allow_same_day_exception)
|
||||
self.assertIn("cambio de plan", enr2.exception_reason)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user