feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad
This commit is contained in:
+307
-26
@@ -4,11 +4,13 @@ import re
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.subject import Subject, Commission, CommissionTeacher
|
||||
from app.models.career import Career
|
||||
from app.models.academic_term import AcademicTerm
|
||||
from app.models.milestone import MilestoneType, AcademicMilestone
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.constants.document_types import DOCUMENT_TYPES, validate_document, format_document
|
||||
from app.services.enrollment_service import EnrollmentService
|
||||
from app import db
|
||||
|
||||
api_admin_bp = Blueprint('api_admin', __name__)
|
||||
@@ -22,7 +24,14 @@ def get_users():
|
||||
|
||||
query = User.query
|
||||
if search:
|
||||
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
|
||||
query = query.filter(
|
||||
(User.name.ilike(f'%{search}%')) |
|
||||
(User.email.ilike(f'%{search}%')) |
|
||||
(User.personal_email.ilike(f'%{search}%')) |
|
||||
(User.document_number.ilike(f'%{search}%')) |
|
||||
(User.first_name.ilike(f'%{search}%')) |
|
||||
(User.last_name.ilike(f'%{search}%'))
|
||||
)
|
||||
if role_id:
|
||||
query = query.filter(User.role_id == role_id)
|
||||
if status == 'active':
|
||||
@@ -35,19 +44,12 @@ def get_users():
|
||||
|
||||
users_data = []
|
||||
for u in users:
|
||||
users_data.append({
|
||||
'id': u.id,
|
||||
'name': u.name,
|
||||
'first_name': u.name.split(' ')[0] if u.name else 'U',
|
||||
'email': u.email,
|
||||
'is_active': u.is_active,
|
||||
'role': u.role,
|
||||
'role_obj': {
|
||||
'id': u.role_obj.id,
|
||||
'name': u.role_obj.name
|
||||
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'},
|
||||
'last_login': getattr(u, 'last_login', None)
|
||||
})
|
||||
d = u.to_dict()
|
||||
d['role_obj'] = {
|
||||
'id': u.role_obj.id,
|
||||
'name': u.role_obj.name
|
||||
} if u.role_obj else {'id': 1, 'name': u.role or 'Admin'}
|
||||
users_data.append(d)
|
||||
|
||||
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
|
||||
|
||||
@@ -55,7 +57,8 @@ def get_users():
|
||||
'status': 'success',
|
||||
'total': len(users_data),
|
||||
'users': users_data,
|
||||
'roles': roles_data
|
||||
'roles': roles_data,
|
||||
'document_types': DOCUMENT_TYPES
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/roles', methods=['GET'])
|
||||
@@ -213,6 +216,8 @@ def get_commissions():
|
||||
'year': getattr(c, 'year', 2026) or 2026,
|
||||
'teacher_id': c.teacher_id,
|
||||
'teacher_name': c.teacher_name if hasattr(c, 'teacher_name') else (c.teacher.name if c.teacher else None),
|
||||
'teachers': c.teachers if hasattr(c, 'teachers') else [],
|
||||
'teacher_names': c.teacher_names if hasattr(c, 'teacher_names') else (c.teacher_name if hasattr(c, 'teacher_name') else 'Sin asignar'),
|
||||
'schedule': c.schedule or 'A coordinar',
|
||||
'shift': c.shift or 'Mañana',
|
||||
'max_students': c.max_students or 35,
|
||||
@@ -248,19 +253,47 @@ def get_commissions():
|
||||
def create_user():
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
first_name = data.get('first_name', '').strip()
|
||||
last_name = data.get('last_name', '').strip()
|
||||
name = data.get('name', '').strip()
|
||||
if not name and (first_name or last_name):
|
||||
name = f"{first_name} {last_name}".strip()
|
||||
elif name and not first_name:
|
||||
parts = name.split()
|
||||
first_name = parts[0] if parts else ''
|
||||
last_name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
password = data.get('password', '').strip()
|
||||
role_id = data.get('role_id')
|
||||
is_active = data.get('is_active', True)
|
||||
if isinstance(is_active, str):
|
||||
is_active = is_active.lower() in ['true', '1', 'on']
|
||||
|
||||
phone = data.get('phone', '').strip()
|
||||
personal_email = data.get('personal_email', '').strip().lower()
|
||||
address = data.get('address', '').strip()
|
||||
document_type = data.get('document_type', 'DNI').strip().upper()
|
||||
document_number = data.get('document_number', '').strip()
|
||||
|
||||
if not email or not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre y el email son obligatorios.'}), 400
|
||||
|
||||
if User.query.filter(User.email.ilike(email)).first():
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con el email {email}.'}), 409
|
||||
|
||||
if document_number:
|
||||
is_valid, clean_doc, err_msg = validate_document(document_type, document_number)
|
||||
if not is_valid:
|
||||
return jsonify({'error': 'ValidationError', 'message': err_msg}), 400
|
||||
document_number = clean_doc
|
||||
|
||||
existing_doc = User.query.filter(
|
||||
User.document_type == document_type,
|
||||
User.document_number == document_number
|
||||
).first()
|
||||
if existing_doc:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un usuario con {document_type} {document_number}.'}), 409
|
||||
|
||||
role_obj = None
|
||||
if role_id:
|
||||
role_obj = Role.query.get(int(role_id))
|
||||
@@ -268,7 +301,14 @@ def create_user():
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
personal_email=personal_email,
|
||||
name=name,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=phone,
|
||||
address=address,
|
||||
document_type=document_type,
|
||||
document_number=document_number,
|
||||
role=role_name,
|
||||
role_id=int(role_id) if role_id else None,
|
||||
is_active=bool(is_active)
|
||||
@@ -280,7 +320,7 @@ def create_user():
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Usuario creado exitosamente.',
|
||||
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
|
||||
'user': user.to_dict()
|
||||
}), 201
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
|
||||
@@ -289,8 +329,48 @@ def update_user(id):
|
||||
user = User.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'name' in data and data['name']:
|
||||
user.name = data['name'].strip()
|
||||
first_name = data.get('first_name', user.first_name or '').strip()
|
||||
last_name = data.get('last_name', user.last_name or '').strip()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if 'first_name' in data or 'last_name' in data:
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
user.name = f"{first_name} {last_name}".strip()
|
||||
elif name:
|
||||
user.name = name
|
||||
parts = name.split()
|
||||
user.first_name = parts[0] if parts else ''
|
||||
user.last_name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
if 'phone' in data:
|
||||
user.phone = (data['phone'] or '').strip()
|
||||
if 'personal_email' in data:
|
||||
user.personal_email = (data['personal_email'] or '').strip().lower()
|
||||
if 'address' in data:
|
||||
user.address = (data['address'] or '').strip()
|
||||
|
||||
if 'document_type' in data or 'document_number' in data:
|
||||
doc_type = data.get('document_type', user.document_type or 'DNI').strip().upper()
|
||||
doc_num = data.get('document_number', user.document_number or '').strip()
|
||||
|
||||
if doc_num:
|
||||
is_valid, clean_doc, err_msg = validate_document(doc_type, doc_num)
|
||||
if not is_valid:
|
||||
return jsonify({'error': 'ValidationError', 'message': err_msg}), 400
|
||||
doc_num = clean_doc
|
||||
|
||||
existing_doc = User.query.filter(
|
||||
User.document_type == doc_type,
|
||||
User.document_number == doc_num,
|
||||
User.id != id
|
||||
).first()
|
||||
if existing_doc:
|
||||
return jsonify({'error': 'Conflict', 'message': f'El documento {doc_type} {doc_num} ya está asignado a otro usuario.'}), 409
|
||||
|
||||
user.document_type = doc_type
|
||||
user.document_number = doc_num
|
||||
|
||||
if 'email' in data and data['email']:
|
||||
email = data['email'].strip().lower()
|
||||
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
|
||||
@@ -312,7 +392,15 @@ def update_user(id):
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Usuario actualizado correctamente.',
|
||||
'user': {'id': user.id, 'name': user.name, 'email': user.email, 'role': user.role, 'is_active': user.is_active}
|
||||
'user': user.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/document-types', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_document_types():
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'document_types': DOCUMENT_TYPES
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
|
||||
@@ -598,15 +686,18 @@ def delete_subject(id):
|
||||
subject.is_active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
|
||||
'status': 'deactivated',
|
||||
'action': 'deactivated',
|
||||
'message': f'La asignatura "{subject.name}" tiene {len(subject.commissions)} comisión(es) vinculada(s). Se ha desactivado en su lugar para proteger el historial académico.'
|
||||
}), 200
|
||||
|
||||
name = subject.name
|
||||
db.session.delete(subject)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Asignatura eliminada permanentemente.'
|
||||
'status': 'deleted',
|
||||
'action': 'deleted',
|
||||
'message': f'Asignatura "{name}" eliminada permanentemente del sistema.'
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
@@ -659,6 +750,10 @@ def create_commission():
|
||||
if virtual_link:
|
||||
existing.virtual_link = virtual_link
|
||||
existing.active = bool(active)
|
||||
if teacher_id:
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=existing.id, user_id=teacher_id).first()
|
||||
if not ct:
|
||||
db.session.add(CommissionTeacher(commission_id=existing.id, user_id=teacher_id, role='Titular', is_primary=True))
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -679,6 +774,19 @@ def create_commission():
|
||||
active=bool(active)
|
||||
)
|
||||
db.session.add(commission)
|
||||
db.session.flush()
|
||||
|
||||
if teacher_id:
|
||||
db.session.add(CommissionTeacher(commission_id=commission.id, user_id=teacher_id, role='Titular', is_primary=True))
|
||||
|
||||
# Soporte para docentes adicionales al crear comisión
|
||||
extra_teachers = data.get('teacher_ids') or data.get('teachers') or []
|
||||
for et in extra_teachers:
|
||||
u_id = et.get('user_id') if isinstance(et, dict) else et
|
||||
u_role = et.get('role', 'Adjunto') if isinstance(et, dict) else 'Adjunto'
|
||||
if u_id and int(u_id) != teacher_id:
|
||||
db.session.add(CommissionTeacher(commission_id=commission.id, user_id=int(u_id), role=u_role, is_primary=False))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
@@ -688,6 +796,16 @@ def create_commission():
|
||||
}), 201
|
||||
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_commission_detail(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_commission(id):
|
||||
@@ -703,6 +821,10 @@ def update_commission(id):
|
||||
if 'teacher_id' in data:
|
||||
t_id = data['teacher_id']
|
||||
comm.teacher_id = int(t_id) if t_id else None
|
||||
if comm.teacher_id:
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
||||
if not ct:
|
||||
db.session.add(CommissionTeacher(commission_id=comm.id, user_id=comm.teacher_id, role='Titular', is_primary=True))
|
||||
if 'max_students' in data and data['max_students']:
|
||||
comm.max_students = int(data['max_students'])
|
||||
if 'schedule' in data:
|
||||
@@ -740,17 +862,176 @@ def toggle_commission(id):
|
||||
def assign_commission_teacher(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
t_id = data.get('teacher_id')
|
||||
comm.teacher_id = int(t_id) if t_id else None
|
||||
t_id = data.get('teacher_id') or data.get('user_id')
|
||||
role = (data.get('role') or 'Titular').strip()
|
||||
|
||||
if t_id:
|
||||
t_id = int(t_id)
|
||||
comm.teacher_id = t_id
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=t_id).first()
|
||||
if not ct:
|
||||
ct = CommissionTeacher(commission_id=comm.id, user_id=t_id, role=role, is_primary=True)
|
||||
db.session.add(ct)
|
||||
else:
|
||||
ct.role = role
|
||||
ct.is_primary = True
|
||||
else:
|
||||
comm.teacher_id = None
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'id': comm.id,
|
||||
'teacher_id': comm.teacher_id,
|
||||
'teacher_name': comm.teacher_name,
|
||||
'teachers': comm.teachers,
|
||||
'message': 'Docente asignado correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/teachers', methods=['POST'])
|
||||
@jwt_required
|
||||
def add_commission_teacher(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
user_id = data.get('user_id') or data.get('teacher_id')
|
||||
if not user_id:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El docente es requerido.'}), 400
|
||||
|
||||
user = User.query.get(int(user_id))
|
||||
if not user:
|
||||
return jsonify({'error': 'NotFound', 'message': f'El usuario docente con ID {user_id} no existe.'}), 404
|
||||
|
||||
role = (data.get('role') or 'Adjunto').strip()
|
||||
is_primary = data.get('is_primary', False)
|
||||
if isinstance(is_primary, str):
|
||||
is_primary = is_primary.lower() in ['true', '1', 'on']
|
||||
|
||||
existing = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user.id).first()
|
||||
if existing:
|
||||
existing.role = role
|
||||
existing.is_primary = bool(is_primary)
|
||||
else:
|
||||
if is_primary or comm.teacher_id is None:
|
||||
comm.teacher_id = user.id
|
||||
is_primary = True
|
||||
ct = CommissionTeacher(
|
||||
commission_id=comm.id,
|
||||
user_id=user.id,
|
||||
role=role,
|
||||
is_primary=bool(is_primary)
|
||||
)
|
||||
db.session.add(ct)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'{user.name} asignado/a como {role} en la comisión.',
|
||||
'teachers': comm.teachers,
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/teachers/<int:user_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def remove_commission_teacher(id, user_id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=user_id).first()
|
||||
if ct:
|
||||
db.session.delete(ct)
|
||||
|
||||
# Si era el docente titular, reasignar a otro docente disponible o dejar en None
|
||||
if comm.teacher_id == user_id:
|
||||
other = CommissionTeacher.query.filter(CommissionTeacher.commission_id == comm.id, CommissionTeacher.user_id != user_id).first()
|
||||
comm.teacher_id = other.user_id if other else None
|
||||
if other:
|
||||
other.is_primary = True
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Docente desvinculado de la comisión correctamente.',
|
||||
'teachers': comm.teachers,
|
||||
'commission': comm.to_dict()
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# COMMISSION STUDENT ENROLLMENTS (FASE 2 MVP)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_commission_enrollments(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
enrollments = EnrollmentService.list_enrollments(comm.id)
|
||||
return jsonify({
|
||||
'commission_id': comm.id,
|
||||
'commission_code': comm.code,
|
||||
'count': len(enrollments),
|
||||
'max_students': comm.max_students,
|
||||
'current_students': comm.current_students,
|
||||
'enrollments': enrollments
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments', methods=['POST'])
|
||||
@jwt_required
|
||||
def enroll_commission_student(id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
student_id = data.get('student_id')
|
||||
notes = data.get('notes')
|
||||
allow_same_day_exception = bool(data.get('allow_same_day_exception', False))
|
||||
exception_reason = data.get('exception_reason')
|
||||
|
||||
if not student_id:
|
||||
return jsonify({'error': 'BadRequest', 'message': 'student_id es obligatorio.'}), 400
|
||||
|
||||
try:
|
||||
enrollment = EnrollmentService.enroll_student(
|
||||
commission_id=id,
|
||||
student_id=int(student_id),
|
||||
notes=notes,
|
||||
allow_same_day_exception=allow_same_day_exception,
|
||||
exception_reason=exception_reason
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estudiante matriculado exitosamente en la comisión.',
|
||||
'enrollment': enrollment.to_dict()
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': 'ConflictOrValidation', 'message': str(e)}), 409
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments/<int:student_id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def unenroll_commission_student(id, student_id):
|
||||
success = EnrollmentService.unenroll_student(commission_id=id, student_id=student_id)
|
||||
if not success:
|
||||
return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada para este estudiante.'}), 404
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estudiante desvinculado de la comisión exitosamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/enrollments/<int:student_id>', methods=['PUT'])
|
||||
@jwt_required
|
||||
def update_commission_enrollment(id, student_id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
status = data.get('status', 'activo')
|
||||
notes = data.get('notes')
|
||||
|
||||
enrollment = EnrollmentService.update_enrollment_status(
|
||||
commission_id=id,
|
||||
student_id=student_id,
|
||||
status=status,
|
||||
notes=notes
|
||||
)
|
||||
if not enrollment:
|
||||
return jsonify({'error': 'NotFound', 'message': 'Matrícula no encontrada.'}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'Estado de matrícula actualizado.',
|
||||
'enrollment': enrollment.to_dict()
|
||||
}), 200
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ACADEMIC TERMS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user