feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
from app.utils.jwt_decorators import jwt_required
|
||||
from app.services.gradebook_service import GradebookService
|
||||
|
||||
api_students_bp = Blueprint('api_students', __name__)
|
||||
|
||||
|
||||
@api_students_bp.route('/students/my-grades', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_my_grades():
|
||||
"""Consulta individual de calificaciones y estado académico del alumno logueado."""
|
||||
user = getattr(g, 'jwt_user', None)
|
||||
user_id = user.id if user else getattr(request, 'current_user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
|
||||
|
||||
try:
|
||||
grades = GradebookService.get_student_grades(user_id)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'student_id': user_id,
|
||||
'is_impersonating': getattr(g, 'is_impersonating', False),
|
||||
'data': grades
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@api_students_bp.route('/students/my-upcoming-milestones', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_my_upcoming_milestones():
|
||||
"""Próximas evaluaciones e hitos académicos con cuenta regresiva para el dashboard."""
|
||||
user = getattr(g, 'jwt_user', None)
|
||||
user_id = user.id if user else getattr(request, 'current_user_id', None)
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'message': 'Usuario no autenticado.'}), 401
|
||||
|
||||
limit = request.args.get('limit', default=5, type=int)
|
||||
try:
|
||||
milestones = GradebookService.get_student_upcoming_milestones(user_id, limit=limit)
|
||||
return jsonify({'status': 'success', 'data': milestones}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'ServerError', 'message': str(e)}), 500
|
||||
Reference in New Issue
Block a user