Files
admin-edu-space/backend/app/routes/api/admin.py
T

1099 lines
38 KiB
Python

from flask import Blueprint, jsonify, request
from datetime import datetime
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.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 import db
api_admin_bp = Blueprint('api_admin', __name__)
@api_admin_bp.route('/users', methods=['GET'])
@jwt_required
def get_users():
search = request.args.get('search', '').strip().lower()
role_id = request.args.get('role', type=int)
status = request.args.get('status', '').strip().lower()
query = User.query
if search:
query = query.filter((User.name.ilike(f'%{search}%')) | (User.email.ilike(f'%{search}%')))
if role_id:
query = query.filter(User.role_id == role_id)
if status == 'active':
query = query.filter(User.is_active == True)
elif status == 'inactive':
query = query.filter(User.is_active == False)
users = query.order_by(User.id.asc()).all()
roles = Role.query.order_by(Role.name.asc()).all()
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)
})
roles_data = [{'id': r.id, 'name': r.name} for r in roles]
return jsonify({
'status': 'success',
'total': len(users_data),
'users': users_data,
'roles': roles_data
}), 200
@api_admin_bp.route('/roles', methods=['GET'])
@jwt_required
def get_roles():
roles = Role.query.order_by(Role.id.asc()).all()
modules = [
{'id': 'dashboard', 'name': 'Dashboard', 'icon': 'bi-speedometer2'},
{'id': 'classrooms', 'name': 'Aulas y Espacios', 'icon': 'bi-door-open'},
{'id': 'schedule', 'name': 'Cronograma', 'icon': 'bi-calendar3'},
{'id': 'subjects', 'name': 'Asignaturas', 'icon': 'bi-book'},
{'id': 'careers', 'name': 'Carreras', 'icon': 'bi-mortarboard'},
{'id': 'commissions', 'name': 'Comisiones', 'icon': 'bi-diagram-3'},
{'id': 'users', 'name': 'Usuarios', 'icon': 'bi-people'},
{'id': 'roles', 'name': 'Roles (RBAC)', 'icon': 'bi-shield-check'},
{'id': 'optimizer', 'name': 'Optimizador IA', 'icon': 'bi-cpu'},
{'id': 'audit', 'name': 'Auditoría', 'icon': 'bi-clipboard-data'}
]
roles_data = []
for r in roles:
perms_map = {}
for p in r.permissions:
perms_map[p.module] = p.access_level
roles_data.append({
'id': r.id,
'name': r.name,
'description': r.description or f'Rol institucional de {r.name}',
'is_system': getattr(r, 'is_system', True),
'users_count': len(r.users),
'users': [{'id': u.id, 'name': u.name} for u in r.users],
'permissions': perms_map
})
return jsonify({
'status': 'success',
'roles': roles_data,
'modules': modules
}), 200
@api_admin_bp.route('/subjects', methods=['GET'])
@jwt_required
def get_subjects():
search = request.args.get('search', '').strip().lower()
career_id = request.args.get('career_id', type=int)
active = request.args.get('active', '').strip().lower()
query = Subject.query
if search:
query = query.filter((Subject.name.ilike(f'%{search}%')) | (Subject.code.ilike(f'%{search}%')) | (Subject.department.ilike(f'%{search}%')))
if career_id:
query = query.filter(Subject.career_id == career_id)
if active == 'true':
query = query.filter(Subject.is_active == True)
elif active == 'false':
query = query.filter(Subject.is_active == False)
subjects = query.order_by(Subject.id.asc()).all()
careers = Career.query.order_by(Career.name.asc()).all()
subjects_data = []
for s in subjects:
subjects_data.append({
'id': s.id,
'code': s.code,
'name': s.name,
'career_id': s.career_id,
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
'department': s.department or 'General',
'credits': getattr(s, 'credits', 4),
'is_active': s.is_active,
'commissions_count': len(s.commissions),
'commissions': [{'id': c.id, 'code': c.code, 'schedule': c.schedule} for c in s.commissions]
})
careers_data = [{'id': c.id, 'name': c.name} for c in careers]
return jsonify({
'status': 'success',
'total': len(subjects_data),
'subjects': subjects_data,
'careers': careers_data
}), 200
@api_admin_bp.route('/careers', methods=['GET'])
@jwt_required
def get_careers():
search = request.args.get('search', '').strip().lower()
query = Career.query
if search:
query = query.filter(Career.name.ilike(f'%{search}%'))
careers = query.order_by(Career.name.asc()).all()
careers_data = []
for c in careers:
careers_data.append({
'id': c.id,
'name': c.name,
'code': getattr(c, 'code', f'CAR-{c.id}'),
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
'is_active': getattr(c, 'is_active', True),
'subjects_count': len(c.subjects),
'subjects': [{'id': s.id, 'name': s.name, 'code': s.code} for s in c.subjects[:5]]
})
return jsonify({
'status': 'success',
'total': len(careers_data),
'careers': careers_data
}), 200
@api_admin_bp.route('/commissions', methods=['GET'])
@jwt_required
def get_commissions():
search = request.args.get('search', '').strip().lower()
subject_id = request.args.get('subject_id', type=int)
shift = request.args.get('shift', '').strip()
active = request.args.get('active', '').strip().lower()
query = Commission.query
if subject_id:
query = query.filter(Commission.subject_id == subject_id)
if shift:
query = query.filter(Commission.shift == shift)
if active == 'true':
query = query.filter(Commission.active == True)
elif active == 'false':
query = query.filter(Commission.active == False)
commissions = query.order_by(Commission.id.asc()).all()
subjects = Subject.query.order_by(Subject.name.asc()).all()
commissions_data = []
for c in commissions:
if search:
s_name = c.subject.name.lower() if c.subject else ''
c_code = c.code.lower()
if search not in s_name and search not in c_code:
continue
career_name = 'Carrera General'
if c.subject and hasattr(c.subject, 'career') and c.subject.career:
career_name = c.subject.career.name
commissions_data.append({
'id': c.id,
'code': c.code,
'full_code': c.get_full_code() if hasattr(c, 'get_full_code') else c.code,
'subject_id': c.subject_id,
'subject_name': c.subject.name if c.subject else 'Sin materia',
'subject_code': c.subject.code if c.subject else '-',
'career_name': career_name,
'semester': getattr(c, 'semester', '1C') or '1C',
'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),
'schedule': c.schedule or 'A coordinar',
'shift': c.shift or 'Mañana',
'max_students': c.max_students or 35,
'current_students': getattr(c, 'current_students', 0) or 0,
'active': c.active,
'virtual_link': getattr(c, 'virtual_link', '') or ''
})
subjects_data = []
for s in subjects:
c_name = s.career.name if (hasattr(s, 'career') and s.career) else 'Carrera General'
subjects_data.append({
'id': s.id,
'name': s.name,
'code': s.code,
'career': c_name,
'career_name': c_name
})
return jsonify({
'status': 'success',
'total': len(commissions_data),
'commissions': commissions_data,
'subjects': subjects_data
}), 200
# ---------------------------------------------------------
# USERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/users', methods=['POST'])
@jwt_required
def create_user():
data = request.get_json(silent=True) or request.form.to_dict() or {}
email = data.get('email', '').strip().lower()
name = data.get('name', '').strip()
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']
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
role_obj = None
if role_id:
role_obj = Role.query.get(int(role_id))
role_name = role_obj.name if role_obj else data.get('role', 'Docente')
user = User(
email=email,
name=name,
role=role_name,
role_id=int(role_id) if role_id else None,
is_active=bool(is_active)
)
user.set_password(password or 'edu-space2026')
db.session.add(user)
db.session.commit()
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}
}), 201
@api_admin_bp.route('/users/<int:id>', methods=['PUT'])
@jwt_required
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()
if 'email' in data and data['email']:
email = data['email'].strip().lower()
existing = User.query.filter(User.email.ilike(email), User.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'El email {email} ya está registrado por otro usuario.'}), 409
user.email = email
if 'password' in data and data['password']:
user.set_password(data['password'].strip())
if 'role_id' in data and data['role_id']:
role_obj = Role.query.get(int(data['role_id']))
if role_obj:
user.role_id = role_obj.id
user.role = role_obj.name
if 'is_active' in data:
val = data['is_active']
user.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
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}
}), 200
@api_admin_bp.route('/users/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_user(id):
user = User.query.get_or_404(id)
user.is_active = not user.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': user.id,
'is_active': user.is_active,
'message': f'Usuario {"activado" if user.is_active else "desactivado"} correctamente.'
}), 200
@api_admin_bp.route('/users/<int:id>', methods=['DELETE'])
@jwt_required
def delete_user(id):
user = User.query.get_or_404(id)
has_reservations = len(user.reservations) if user.reservations else 0
if has_reservations > 0:
user.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'El usuario tiene reservas asociadas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(user)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Usuario eliminado permanentemente.'
}), 200
# ---------------------------------------------------------
# ROLES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/roles', methods=['POST'])
@jwt_required
def create_role():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
description = data.get('description', '').strip()
permissions = data.get('permissions', {})
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre del rol es obligatorio.'}), 400
if Role.query.filter(Role.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe un rol llamado {name}.'}), 409
role = Role(name=name, description=description, is_system=False)
db.session.add(role)
db.session.flush()
for mod, level in permissions.items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol creado exitosamente.',
'role': role.to_dict()
}), 201
@api_admin_bp.route('/roles/<int:id>', methods=['PUT'])
@jwt_required
def update_role(id):
role = Role.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Role.query.filter(Role.name.ilike(name), Role.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro rol llamado {name}.'}), 409
role.name = name
if 'description' in data:
role.description = data['description'].strip()
if 'permissions' in data and isinstance(data['permissions'], dict):
for mod, level in data['permissions'].items():
role.set_permission(mod, level)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol actualizado correctamente.',
'role': role.to_dict()
}), 200
@api_admin_bp.route('/roles/<int:id>', methods=['DELETE'])
@jwt_required
def delete_role(id):
role = Role.query.get_or_404(id)
if role.is_system:
return jsonify({'error': 'Forbidden', 'message': 'No se pueden eliminar roles de sistema.'}), 403
if role.users and len(role.users) > 0:
return jsonify({'error': 'Conflict', 'message': f'El rol tiene {len(role.users)} usuarios asignados. Reasígnelos primero.'}), 409
db.session.delete(role)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Rol eliminado exitosamente.'
}), 200
# ---------------------------------------------------------
# CAREERS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/careers', methods=['POST'])
@jwt_required
def create_career():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip()
description = data.get('description', '').strip()
if not name:
return jsonify({'error': 'ValidationError', 'message': 'El nombre de la carrera es obligatorio.'}), 400
if Career.query.filter(Career.name.ilike(name)).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una carrera con el nombre {name}.'}), 409
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera creada exitosamente.',
'career': career.to_dict()
}), 201
@api_admin_bp.route('/careers/<int:id>', methods=['PUT'])
@jwt_required
def update_career(id):
career = Career.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
name = data['name'].strip()
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra carrera con el nombre {name}.'}), 409
career.name = name
if 'code' in data:
career.code = data['code'].strip() or None
if 'description' in data:
career.description = data['description'].strip() or None
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera actualizada correctamente.',
'career': career.to_dict()
}), 200
@api_admin_bp.route('/careers/<int:id>', methods=['DELETE'])
@jwt_required
def delete_career(id):
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
return jsonify({
'error': 'Conflict',
'message': f'No se puede eliminar la carrera porque tiene {len(career.subjects)} asignaturas vinculadas.'
}), 409
db.session.delete(career)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Carrera eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# SUBJECTS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/subjects', methods=['POST'])
@jwt_required
def create_subject():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip().upper()
name = data.get('name', '').strip()
department = data.get('department', '').strip()
credits = int(data.get('credits', 4) or 4)
career_id = data.get('career_id')
career_id = int(career_id) if career_id else None
description = data.get('description', '').strip()
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'El código y nombre de la asignatura son obligatorios.'}), 400
if Subject.query.filter_by(code=code).first():
return jsonify({'error': 'Conflict', 'message': f'Ya existe una asignatura con el código {code}.'}), 409
subject = Subject(
code=code,
name=name,
department=department,
credits=credits,
career_id=career_id,
description=description,
is_active=bool(is_active)
)
db.session.add(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura creada exitosamente.',
'subject': subject.to_dict()
}), 201
@api_admin_bp.route('/subjects/<int:id>', methods=['PUT'])
@jwt_required
def update_subject(id):
subject = Subject.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip().upper()
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra asignatura con el código {code}.'}), 409
subject.code = code
if 'name' in data and data['name']:
subject.name = data['name'].strip()
if 'department' in data:
subject.department = data['department'].strip()
if 'credits' in data and data['credits'] is not None:
subject.credits = int(data['credits'])
if 'career_id' in data:
c_id = data['career_id']
subject.career_id = int(c_id) if c_id else None
if 'description' in data:
subject.description = data['description'].strip()
if 'is_active' in data:
val = data['is_active']
subject.is_active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura actualizada correctamente.',
'subject': subject.to_dict()
}), 200
@api_admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_subject(id):
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': subject.id,
'is_active': subject.is_active,
'message': f'Asignatura {"activada" if subject.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/subjects/<int:id>', methods=['DELETE'])
@jwt_required
def delete_subject(id):
subject = Subject.query.get_or_404(id)
if subject.commissions and len(subject.commissions) > 0:
subject.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': 'La asignatura tiene comisiones activas. Se ha desactivado en su lugar.'
}), 200
db.session.delete(subject)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Asignatura eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# COMMISSIONS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/commissions', methods=['POST'])
@jwt_required
def create_commission():
data = request.get_json(silent=True) or request.form.to_dict() or {}
subject_id = data.get('subject_id')
code = data.get('code', '').strip()
semester = (data.get('semester') or '1C').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
teacher_id = data.get('teacher_id')
teacher_id = int(teacher_id) if (teacher_id and str(teacher_id).strip()) else None
max_students = int(data.get('max_students', 35) or 35)
schedule = (data.get('schedule') or '').strip()
shift = (data.get('shift') or 'Mañana').strip()
virtual_link = (data.get('virtual_link') or '').strip()
active = data.get('active', True)
if isinstance(active, str):
active = active.lower() in ['true', '1', 'on']
if not subject_id or not code:
return jsonify({'error': 'ValidationError', 'message': 'La asignatura y código de comisión son obligatorios.'}), 400
subject = Subject.query.get(int(subject_id))
if not subject:
return jsonify({'error': 'NotFound', 'message': f'La asignatura con ID {subject_id} no existe.'}), 404
# Generar enlace automático si no fue provisto
if not virtual_link:
s_clean = re.sub(r'[^a-zA-Z0-9]', '', subject.code).lower()[:6] or 'subj'
c_clean = re.sub(r'[^a-zA-Z0-9]', '', code).lower()[:4] or 'c1'
virtual_link = f"https://meet.google.com/edu-{s_clean}-{c_clean}"
existing = Commission.query.filter_by(
subject_id=int(subject_id),
code=code,
semester=semester,
year=year
).first()
if existing:
existing.teacher_id = teacher_id
existing.max_students = max_students
existing.schedule = schedule
existing.shift = shift
if virtual_link:
existing.virtual_link = virtual_link
existing.active = bool(active)
db.session.commit()
return jsonify({
'status': 'success',
'message': f'Comisión {code} asociada a {subject.name} actualizada exitosamente.',
'commission': existing.to_dict()
}), 200
commission = Commission(
subject_id=int(subject_id),
code=code,
semester=semester,
year=year,
teacher_id=teacher_id,
max_students=max_students,
schedule=schedule,
shift=shift,
virtual_link=virtual_link,
active=bool(active)
)
db.session.add(commission)
db.session.commit()
return jsonify({
'status': 'success',
'message': f'Comisión {code} asociada exitosamente a {subject.name}.',
'commission': commission.to_dict()
}), 201
@api_admin_bp.route('/commissions/<int:id>', methods=['PUT'])
@jwt_required
def update_commission(id):
comm = Commission.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
comm.code = data['code'].strip()
if 'semester' in data and data['semester']:
comm.semester = data['semester'].strip()
if 'year' in data and data['year']:
comm.year = int(data['year'])
if 'teacher_id' in data:
t_id = data['teacher_id']
comm.teacher_id = int(t_id) if t_id else None
if 'max_students' in data and data['max_students']:
comm.max_students = int(data['max_students'])
if 'schedule' in data:
comm.schedule = data['schedule'].strip()
if 'shift' in data:
comm.shift = data['shift'].strip()
if 'virtual_link' in data:
comm.virtual_link = data['virtual_link'].strip()
if 'active' in data:
val = data['active']
comm.active = val in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Comisión actualizada correctamente.',
'commission': comm.to_dict()
}), 200
@api_admin_bp.route('/commissions/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_commission(id):
comm = Commission.query.get_or_404(id)
comm.active = not comm.active
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'active': comm.active,
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
@jwt_required
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
db.session.commit()
return jsonify({
'status': 'success',
'id': comm.id,
'teacher_id': comm.teacher_id,
'teacher_name': comm.teacher_name,
'message': 'Docente asignado correctamente.'
}), 200
# ---------------------------------------------------------
# ACADEMIC TERMS CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/academic-terms', methods=['GET'])
@jwt_required
def get_academic_terms():
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
terms_data = []
for t in terms:
comm_count = Commission.query.filter_by(year=t.year).count()
terms_data.append({
'id': t.id,
'code': t.code,
'name': t.name,
'year': t.year,
'period_type': t.period_type,
'start_date': t.start_date.isoformat() if t.start_date else None,
'end_date': t.end_date.isoformat() if t.end_date else None,
'is_current': t.is_current,
'is_active': t.is_active,
'commissions_count': comm_count
})
return jsonify({
'status': 'success',
'total': len(terms_data),
'terms': terms_data
}), 200
@api_admin_bp.route('/academic-terms', methods=['POST'])
@jwt_required
def create_academic_term():
data = request.get_json(silent=True) or request.form.to_dict() or {}
code = data.get('code', '').strip()
name = data.get('name', '').strip()
year = int(data.get('year', datetime.now().year) or datetime.now().year)
period_type = data.get('period_type', 'Segundo Cuatrimestre').strip()
is_current = data.get('is_current', False)
if isinstance(is_current, str):
is_current = is_current.lower() in ['true', '1', 'on']
if not code or not name:
return jsonify({'error': 'ValidationError', 'message': 'Código y nombre son requeridos.'}), 400
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe un ciclo con el código {code}.'}), 409
if is_current:
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type,
is_current=bool(is_current),
is_active=True
)
db.session.add(term)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo creado exitosamente.',
'term': term.to_dict()
}), 201
@api_admin_bp.route('/academic-terms/<int:id>', methods=['PUT'])
@jwt_required
def update_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'code' in data and data['code']:
code = data['code'].strip()
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro ciclo con el código {code}.'}), 409
term.code = code
if 'name' in data and data['name']:
term.name = data['name'].strip()
if 'year' in data and data['year']:
term.year = int(data['year'])
if 'period_type' in data:
term.period_type = data['period_type'].strip()
if 'is_current' in data:
is_cur = data['is_current'] in [True, 'true', '1', 'on']
if is_cur:
AcademicTerm.query.update({'is_current': False})
term.is_current = is_cur
if 'is_active' in data:
term.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Ciclo lectivo actualizado correctamente.',
'term': term.to_dict()
}), 200
@api_admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@jwt_required
def set_current_academic_term(id):
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
return jsonify({
'status': 'success',
'id': term.id,
'message': f'"{term.name}" establecido como ciclo activo.'
}), 200
# ---------------------------------------------------------
# MILESTONE TYPES CRUD
# ---------------------------------------------------------
@api_admin_bp.route('/milestone-types', methods=['GET'])
@jwt_required
def get_milestone_types():
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify({
'status': 'success',
'total': len(types),
'milestone_types': [t.to_dict() for t in types]
}), 200
@api_admin_bp.route('/milestone-types', methods=['POST'])
@jwt_required
def create_milestone_type():
data = request.get_json(silent=True) or request.form.to_dict() or {}
name = data.get('name', '').strip()
code = data.get('code', '').strip().lower().replace(' ', '_')
color = data.get('color', '#0d6efd').strip()
description = data.get('description', '').strip()
sort_order = int(data.get('sort_order', 0) or 0)
is_active = data.get('is_active', True)
if isinstance(is_active, str):
is_active = is_active.lower() in ['true', '1', 'on']
if not name or not code:
return jsonify({'error': 'ValidationError', 'message': 'Nombre y código son obligatorios.'}), 400
existing = MilestoneType.query.filter_by(code=code).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe una tipificación con el código {code}.'}), 409
mt = MilestoneType(
name=name,
code=code,
color=color,
description=description,
sort_order=sort_order,
is_active=bool(is_active)
)
db.session.add(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación de hito creada correctamente.',
'milestone_type': mt.to_dict()
}), 201
@api_admin_bp.route('/milestone-types/<int:id>', methods=['PUT'])
@jwt_required
def update_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
data = request.get_json(silent=True) or request.form.to_dict() or {}
if 'name' in data and data['name']:
mt.name = data['name'].strip()
if 'code' in data and data['code']:
code = data['code'].strip().lower().replace(' ', '_')
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
if existing:
return jsonify({'error': 'Conflict', 'message': f'Ya existe otra tipificación con el código {code}.'}), 409
mt.code = code
if 'color' in data:
mt.color = data['color'].strip()
if 'description' in data:
mt.description = data['description'].strip()
if 'sort_order' in data:
mt.sort_order = int(data['sort_order'])
if 'is_active' in data:
mt.is_active = data['is_active'] in [True, 'true', '1', 'on']
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación actualizada correctamente.',
'milestone_type': mt.to_dict()
}), 200
@api_admin_bp.route('/milestone-types/<int:id>/toggle', methods=['POST'])
@jwt_required
def toggle_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
mt.is_active = not mt.is_active
db.session.commit()
return jsonify({
'status': 'success',
'id': mt.id,
'is_active': mt.is_active,
'message': f'Tipificación {"activada" if mt.is_active else "desactivada"} correctamente.'
}), 200
@api_admin_bp.route('/milestone-types/<int:id>', methods=['DELETE'])
@jwt_required
def delete_milestone_type(id):
mt = MilestoneType.query.get_or_404(id)
milestone_count = AcademicMilestone.query.filter_by(milestone_type_id=id).count()
if milestone_count > 0:
mt.is_active = False
db.session.commit()
return jsonify({
'status': 'success',
'message': f'La tipificación tiene {milestone_count} hitos registrados. Se ha desactivado en su lugar.'
}), 200
db.session.delete(mt)
db.session.commit()
return jsonify({
'status': 'success',
'message': 'Tipificación eliminada permanentemente.'
}), 200
# ---------------------------------------------------------
# GOOGLE SHEETS CONFIGURATION & ACADEMIC DATA RESET TOOL
# ---------------------------------------------------------
@api_admin_bp.route('/sheets-config', methods=['GET'])
@jwt_required
def get_sheets_config():
"""Obtiene la configuración actual del Google Sheet para sincronización."""
from app.models.setting import SystemSetting
from app.services.sheets_importer import BASE_CSV_URL, normalize_sheets_url
import os
db_url = SystemSetting.get_value('google_sheets_url')
env_url = os.getenv('GOOGLE_SHEETS_URL')
active_url = db_url or env_url or BASE_CSV_URL
source = 'database' if db_url else ('environment' if env_url else 'default')
return jsonify({
'status': 'success',
'config': {
'active_url': active_url,
'source': source,
'db_url': db_url,
'default_url': BASE_CSV_URL,
'normalized_url': normalize_sheets_url(active_url)
}
}), 200
@api_admin_bp.route('/sheets-config', methods=['POST'])
@jwt_required
def update_sheets_config():
"""Actualiza la URL del Google Sheet en SystemSetting."""
from app.models.setting import SystemSetting
from app.services.sheets_importer import normalize_sheets_url
data = request.get_json(silent=True) or request.form.to_dict() or {}
url = (data.get('url') or data.get('google_sheets_url') or '').strip()
if not url:
return jsonify({
'error': 'ValidationError',
'message': 'La URL de Google Sheets no puede estar vacía.'
}), 400
normalized = normalize_sheets_url(url)
SystemSetting.set_value('google_sheets_url', url, 'URL del Google Sheet académico configurada desde panel')
return jsonify({
'status': 'success',
'message': 'Configuración de Google Sheets guardada correctamente.',
'config': {
'active_url': url,
'normalized_url': normalized,
'source': 'database'
}
}), 200
@api_admin_bp.route('/reset-academic-data', methods=['POST'])
@jwt_required
def reset_academic_data_api():
"""
Herramienta de limpieza/purga de datos académicos para pruebas del importador.
Permite eliminar reservas, comisiones, asignaturas, carreras y aulas.
"""
from scripts.reset_academic_data import execute_academic_reset
data = request.get_json(silent=True) or request.form.to_dict() or {}
reset_all = data.get('all', False) in [True, 'true', '1', 'on']
reset_reservations = reset_all or data.get('reservations', True) in [True, 'true', '1', 'on']
reset_commissions = reset_all or data.get('commissions', False) in [True, 'true', '1', 'on']
reset_subjects = reset_all or data.get('subjects', False) in [True, 'true', '1', 'on']
reset_careers = reset_all or data.get('careers', False) in [True, 'true', '1', 'on']
reset_classrooms = reset_all or data.get('classrooms', False) in [True, 'true', '1', 'on']
if reset_all:
reset_reservations = True
reset_commissions = True
reset_subjects = True
reset_careers = True
reset_classrooms = True
result = execute_academic_reset(
reset_reservations=reset_reservations,
reset_commissions=reset_commissions,
reset_subjects=reset_subjects,
reset_careers=reset_careers,
reset_classrooms=reset_classrooms
)
if result.get('success'):
try:
from app.models.audit_log import AuditLog
audit = AuditLog(
user_id=getattr(request, 'current_user_id', None),
action_type='ACADEMIC_DATA_PURGE',
details=f"Purga académica ejecutada: {result.get('deleted')}",
ip_address=request.remote_addr
)
db.session.add(audit)
db.session.commit()
except Exception:
pass
return jsonify({
'status': 'success',
'message': result.get('message', 'Datos reiniciados con éxito.'),
'deleted': result.get('deleted', {})
}), 200
else:
return jsonify({
'status': 'error',
'message': result.get('error', 'Error durante la purga de datos.')
}), 500