feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README

This commit is contained in:
2026-09-23 12:10:31 -03:00
parent 4ebc5df4fd
commit 7ad4e3f099
38 changed files with 3804 additions and 390 deletions
+266 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, g
from datetime import datetime
import re
from app.utils.jwt_decorators import jwt_required
@@ -6,11 +6,14 @@ from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES
from app.models.subject import Subject, Commission, CommissionTeacher
from app.models.career import Career
from app.models.classroom import Classroom
from app.models.reservation import Reservation, ReservationStatus
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.services.gradebook_service import GradebookService
from app import db
api_admin_bp = Blueprint('api_admin', __name__)
@@ -1032,6 +1035,95 @@ def update_commission_enrollment(id, student_id):
'enrollment': enrollment.to_dict()
}), 200
# ---------------------------------------------------------
# COMMISSION GRADEBOOK & ACTAS (FASE 3 MVP)
# ---------------------------------------------------------
@api_admin_bp.route('/commissions/<int:id>/gradebook', methods=['GET'])
@jwt_required
def get_commission_gradebook(id):
"""Obtiene la matriz completa de calificaciones de la comisión."""
try:
data = GradebookService.get_commission_gradebook(id)
return jsonify({'status': 'success', 'data': data}), 200
except ValueError as e:
return jsonify({'error': 'NotFound', 'message': str(e)}), 404
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/grades', methods=['POST', 'PUT'])
@jwt_required
def save_commission_grades(id):
"""Carga masiva o individual de notas con autoguardado asíncrono."""
data = request.get_json(silent=True) or {}
grader_id = getattr(request, 'current_user_id', None)
try:
if 'grades' in data and isinstance(data['grades'], list):
res = GradebookService.bulk_save_grades(
commission_id=id,
grades_data=data['grades'],
grader_id=grader_id
)
return jsonify({'status': 'success', 'message': f"{res['saved_count']} calificaciones guardadas.", 'data': res}), 200
else:
milestone_id = data.get('milestone_id')
student_id = data.get('student_id')
score = data.get('score')
is_absent = bool(data.get('is_absent', False))
feedback = data.get('feedback', '')
if not milestone_id or not student_id:
return jsonify({'error': 'BadRequest', 'message': 'milestone_id y student_id son obligatorios.'}), 400
grade = GradebookService.save_single_grade(
commission_id=id,
milestone_id=int(milestone_id),
student_id=int(student_id),
score=float(score) if score is not None and str(score).strip() != '' else None,
is_absent=is_absent,
feedback=feedback,
grader_id=grader_id
)
return jsonify({'status': 'success', 'message': 'Calificación guardada.', 'grade': grade.to_dict()}), 200
except ValueError as e:
return jsonify({'error': 'ValidationError', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/close', methods=['POST'])
@jwt_required
def close_commission_gradebook(id):
"""Cierre formal del acta de regularidad y promoción."""
data = request.get_json(silent=True) or {}
user_id = getattr(request, 'current_user_id', None) or 1
notes = data.get('notes', '')
try:
res = GradebookService.close_gradebook(commission_id=id, user_id=user_id, notes=notes)
return jsonify({'status': 'success', **res}), 200
except ValueError as e:
return jsonify({'error': 'BadRequest', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
@api_admin_bp.route('/commissions/<int:id>/gradebook/reopen', methods=['POST'])
@jwt_required
def reopen_commission_gradebook(id):
"""Reapertura administrativa del acta de calificaciones."""
data = request.get_json(silent=True) or {}
user_id = getattr(request, 'current_user_id', None) or 1
reason = data.get('reason', '')
try:
res = GradebookService.reopen_gradebook(commission_id=id, user_id=user_id, reason=reason)
return jsonify({'status': 'success', **res}), 200
except ValueError as e:
return jsonify({'error': 'BadRequest', 'message': str(e)}), 400
except Exception as e:
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
# ---------------------------------------------------------
# ACADEMIC TERMS CRUD
# ---------------------------------------------------------
@@ -1376,4 +1468,177 @@ def reset_academic_data_api():
'message': result.get('error', 'Error durante la purga de datos.')
}), 500
# ---------------------------------------------------------
# FASE 4: MODO IMPERSONACIÓN & DRAG-AND-DROP (ESTADIO 4.1 & 4.2)
# ---------------------------------------------------------
@api_admin_bp.route('/admin/impersonate', methods=['POST'])
@jwt_required
def impersonate_user():
"""Iniciar sesión bajo la identidad de otro usuario (Superadmin / Bedelía)."""
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
if not admin_user.is_admin():
return jsonify({'error': 'Forbidden', 'message': 'Solo administradores pueden impersonar usuarios.'}), 403
data = request.get_json(silent=True) or {}
target_id = data.get('target_user_id') or data.get('user_id')
target_email = data.get('target_email') or data.get('email')
target = None
if target_id:
target = db.session.get(User, int(target_id))
elif target_email:
target = User.query.filter_by(email=target_email.strip()).first()
if not target or not target.is_active:
return jsonify({'error': 'NotFound', 'message': 'Usuario objetivo no encontrado o inactivo.'}), 404
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action='IMPERSONATE_START',
module='auth',
entity_id=target.id,
details=f"Administrador {admin_user.email} inició sesión temporal como {target.email} ({target.role})"
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': f"Impersonando exitosamente a {target.name or target.email}",
'target_user': target.to_dict(),
'real_admin_id': admin_user.id
}), 200
@api_admin_bp.route('/admin/stop-impersonating', methods=['POST'])
@jwt_required
def stop_impersonating():
"""Finalizar sesión de impersonación y restaurar cuenta de administrador."""
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
current_target = g.jwt_user
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action='IMPERSONATE_END',
module='auth',
entity_id=current_target.id if current_target else None,
details=f"Administrador {admin_user.email} finalizó la sesión de impersonación."
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Sesión de impersonación finalizada.'
}), 200
@api_admin_bp.route('/reservations/drag-update', methods=['POST'])
@jwt_required
def drag_update_reservation():
"""Actualización o creación rápida de reserva mediante arrastre en la grilla semanal."""
data = request.get_json(silent=True) or {}
reservation_id = data.get('reservation_id')
commission_id = data.get('commission_id')
classroom_id = data.get('classroom_id')
start_time_str = data.get('start_time')
end_time_str = data.get('end_time')
if not classroom_id or not start_time_str or not end_time_str:
return jsonify({'error': 'ValidationError', 'message': 'Faltan parámetros obligatorios (aula, inicio, fin).'}), 400
try:
if isinstance(start_time_str, str):
start_dt = datetime.fromisoformat(start_time_str.replace('Z', '+00:00'))
else:
start_dt = start_time_str
if isinstance(end_time_str, str):
end_dt = datetime.fromisoformat(end_time_str.replace('Z', '+00:00'))
else:
end_dt = end_time_str
except Exception as e:
return jsonify({'error': 'ValidationError', 'message': f'Formato de fecha inválido: {str(e)}'}), 400
classroom = db.session.get(Classroom, classroom_id)
if not classroom:
return jsonify({'error': 'NotFound', 'message': 'Aula no encontrada.'}), 404
# Pre-validación de conflictos (físico, capacidad, docente)
from app.services.reservation_service import ReservationService
effective_comm_id = commission_id
if not effective_comm_id and reservation_id:
existing_res = db.session.get(Reservation, reservation_id)
if existing_res:
effective_comm_id = existing_res.commission_id
res_service = ReservationService()
matrix = res_service.check_full_conflicts_matrix(
classroom_id=classroom_id,
start_time=start_dt,
end_time=end_dt,
commission_id=effective_comm_id,
exclude_id=reservation_id
)
if matrix.get('is_blocked'):
return jsonify({
'status': 'conflict',
'error': 'ConflictDetected',
'message': ' '.join(matrix.get('block_reasons', ['Conflicto detectado en la asignación.'])),
'conflicts': matrix.get('block_reasons', [])
}), 409
admin_user = getattr(g, 'real_admin_user', g.jwt_user)
acting_user = g.jwt_user
if reservation_id:
res = db.session.get(Reservation, reservation_id)
if not res:
return jsonify({'error': 'NotFound', 'message': 'Reserva no encontrada.'}), 404
res.classroom_id = classroom_id
res.start_time = start_dt
res.end_time = end_dt
action = 'UPDATE_RESERVATION_DRAG'
msg = f"Reserva #{res.id} reprogramada al aula {classroom.code} ({start_dt.strftime('%H:%M')} a {end_dt.strftime('%H:%M')})"
else:
comm = db.session.get(Commission, commission_id) if commission_id else None
res = Reservation(
classroom_id=classroom_id,
commission_id=commission_id,
user_id=acting_user.id,
start_time=start_dt,
end_time=end_dt,
purpose=f"Cursada regular - {comm.subject.name if comm and comm.subject else 'Comisión'}",
expected_attendees=comm.max_students if comm else 30,
status=ReservationStatus.CONFIRMED.value
)
db.session.add(res)
action = 'CREATE_RESERVATION_DRAG'
msg = f"Comisión {comm.code if comm else ''} asignada al aula {classroom.code}"
details_str = msg
if getattr(g, 'is_impersonating', False):
details_str += f" [Ejecutado bajo impersonación por Admin #{admin_user.id}]"
audit = AuditLog(
user_id=admin_user.id,
user_email=admin_user.email,
action=action,
module='reservations',
entity_id=res.id,
details=details_str
)
db.session.add(audit)
db.session.commit()
return jsonify({
'status': 'success',
'message': msg,
'reservation': res.to_dict()
}), 200