feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad

This commit is contained in:
2026-09-19 14:05:16 -03:00
parent 05e197e49f
commit ada39248d7
36 changed files with 3037 additions and 443 deletions
+307 -26
View File
@@ -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
# ---------------------------------------------------------