from flask import Blueprint, jsonify, request from datetime import datetime 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 commissions_data.append({ 'id': c.id, 'code': 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 '-', '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 = [{'id': s.id, 'name': s.name, 'code': s.code} for s in subjects] 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/', 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//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/', 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/', 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/', 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/', 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/', 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/', 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//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/', 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', '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 else None max_students = int(data.get('max_students', 35) or 35) schedule = data.get('schedule', '').strip() shift = data.get('shift', 'Mañana').strip() virtual_link = data.get('virtual_link', '').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 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': 'Comisión creada exitosamente.', 'commission': commission.to_dict() }), 201 @api_admin_bp.route('/commissions/', 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//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//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/', 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//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/', 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//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/', 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