feat: implementar ABM completo de comisiones, carreras y asignaturas con proteccion de integridad y corregir edificios y campus virtual
This commit is contained in:
@@ -17,10 +17,40 @@ class Building(db.Model):
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Identifica si la sede corresponde al Campus Virtual"""
|
||||
c = (self.code or '').upper()
|
||||
n = (self.name or '').upper()
|
||||
return 'VIRTUAL' in c or 'VIRTUAL' in n
|
||||
|
||||
@property
|
||||
def physical_capacity(self):
|
||||
"""Capacidad física instalada real (excluye aulas virtuales con capacidad 9999 o infinitas)"""
|
||||
if self.is_virtual:
|
||||
return 0
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active and (c.capacity or 0) < 999 and not getattr(c, 'is_virtual', False))
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
"""Capacidad para compatibilidad histórica"""
|
||||
if self.is_virtual:
|
||||
return 0
|
||||
return self.physical_capacity
|
||||
|
||||
@property
|
||||
def capacity_display(self):
|
||||
"""Representación textual clara para UI/UX"""
|
||||
if self.is_virtual:
|
||||
return "Ilimitada (Virtual)"
|
||||
return str(self.physical_capacity)
|
||||
|
||||
@property
|
||||
def floors_display(self):
|
||||
"""Representación de pisos"""
|
||||
if self.is_virtual:
|
||||
return "Digital"
|
||||
return str(self.floors)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
@@ -34,10 +64,14 @@ class Building(db.Model):
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'floors_display': self.floors_display,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'is_virtual': self.is_virtual,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'physical_capacity': self.physical_capacity,
|
||||
'total_capacity': self.physical_capacity if not self.is_virtual else 0,
|
||||
'capacity_display': self.capacity_display,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,9 @@ def get_subjects():
|
||||
'name': s.name,
|
||||
'career_id': s.career_id,
|
||||
'career_name': s.career_obj.name if s.career_obj else (s.department or 'General'),
|
||||
'career_obj': {'id': s.career_obj.id, 'name': s.career_obj.name} if s.career_obj else None,
|
||||
'department': s.department or 'General',
|
||||
'description': getattr(s, 'description', '') or '',
|
||||
'credits': getattr(s, 'credits', 4),
|
||||
'is_active': s.is_active,
|
||||
'commissions_count': len(s.commissions),
|
||||
@@ -162,6 +164,7 @@ def get_careers():
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'code': getattr(c, 'code', f'CAR-{c.id}'),
|
||||
'description': getattr(c, 'description', '') or '',
|
||||
'degree_level': getattr(c, 'degree_level', 'Grado / Tecnicatura'),
|
||||
'is_active': getattr(c, 'is_active', True),
|
||||
'subjects_count': len(c.subjects),
|
||||
@@ -217,6 +220,7 @@ def get_commissions():
|
||||
'career_name': career_name,
|
||||
'semester': getattr(c, 'semester', '1C') or '1C',
|
||||
'year': getattr(c, 'year', 2026) or 2026,
|
||||
'academic_term_id': getattr(c, 'academic_term_id', None),
|
||||
'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 [],
|
||||
@@ -926,6 +930,11 @@ def update_commission(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
|
||||
if 'subject_id' in data and data['subject_id']:
|
||||
comm.subject_id = int(data['subject_id'])
|
||||
if 'academic_term_id' in data:
|
||||
t_id = data['academic_term_id']
|
||||
comm.academic_term_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||
if 'code' in data and data['code']:
|
||||
comm.code = data['code'].strip()
|
||||
if 'semester' in data and data['semester']:
|
||||
@@ -934,7 +943,7 @@ def update_commission(id):
|
||||
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
|
||||
comm.teacher_id = int(t_id) if (t_id and str(t_id).strip()) else None
|
||||
if comm.teacher_id:
|
||||
ct = CommissionTeacher.query.filter_by(commission_id=comm.id, user_id=comm.teacher_id).first()
|
||||
if not ct:
|
||||
@@ -971,6 +980,33 @@ def toggle_commission(id):
|
||||
'message': f'Comisión {"activada" if comm.active else "desactivada"} correctamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_commission(id):
|
||||
comm = Commission.query.get_or_404(id)
|
||||
reservations_count = len(comm.reservations) if comm.reservations else 0
|
||||
enrollments_count = len(comm.enrollments) if hasattr(comm, 'enrollments') and comm.enrollments else 0
|
||||
|
||||
if reservations_count > 0 or enrollments_count > 0:
|
||||
comm.active = False
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'deactivated',
|
||||
'action': 'deactivated',
|
||||
'message': f'La comisión tiene {reservations_count} reserva(s) y {enrollments_count} alumno(s) inscriptos. Se ha desactivado en su lugar para mantener la consistencia histórica.'
|
||||
}), 200
|
||||
|
||||
from app.models.commission_teacher import CommissionTeacher
|
||||
CommissionTeacher.query.filter_by(commission_id=comm.id).delete()
|
||||
code = comm.code
|
||||
db.session.delete(comm)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'status': 'deleted',
|
||||
'action': 'deleted',
|
||||
'message': f'Comisión {code} eliminada exitosamente.'
|
||||
}), 200
|
||||
|
||||
@api_admin_bp.route('/commissions/<int:id>/assign-teacher', methods=['POST'])
|
||||
@jwt_required
|
||||
def assign_commission_teacher(id):
|
||||
@@ -2237,3 +2273,202 @@ def retry_all_failed_moodle_tasks():
|
||||
'status': 'success',
|
||||
'message': f'Se reiniciaron {count} tareas fallidas a estado PENDIENTE.'
|
||||
}), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# BUILDINGS CRUD
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api_admin_bp.route('/buildings', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_buildings():
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
|
||||
search = request.args.get('search', '').strip().lower()
|
||||
status_filter = request.args.get('status', '').strip().lower()
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
buildings_data = [b.to_dict() for b in buildings]
|
||||
|
||||
# Estadísticas globales: Capacidad física real sin sumar 9999 de aulas virtuales
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
physical_classrooms = [c for c in all_classrooms if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(buildings_data),
|
||||
'buildings': buildings_data,
|
||||
'stats': {
|
||||
'total_buildings': len(buildings),
|
||||
'total_classrooms': len(all_classrooms),
|
||||
'physical_classrooms_count': len(physical_classrooms),
|
||||
'total_physical_capacity': total_physical_capacity,
|
||||
'total_floors': sum(b.floors for b in buildings if not b.is_virtual)
|
||||
}
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/buildings/<int:id>', methods=['GET'])
|
||||
@jwt_required
|
||||
def get_building(id):
|
||||
from app.models.building import Building
|
||||
building = Building.query.get(id)
|
||||
if not building:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'building': building.to_dict(),
|
||||
'classrooms': [c.to_dict() for c in building.classrooms]
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/buildings', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_building():
|
||||
from app.models.building import Building
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
code = (data.get('code') or '').strip().upper() or None
|
||||
address = (data.get('address') or '').strip() or None
|
||||
floors = int(data.get('floors', 1) or 1)
|
||||
description = (data.get('description') or '').strip() or None
|
||||
is_active = data.get('is_active', True)
|
||||
if isinstance(is_active, str):
|
||||
is_active = is_active.lower() in ['true', '1', 'on']
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||
|
||||
existing = Building.query.filter(db.func.lower(Building.name) == name.lower()).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe un edificio con el nombre "{name}".'}), 409
|
||||
|
||||
building = Building(
|
||||
name=name,
|
||||
code=code,
|
||||
address=address,
|
||||
floors=floors,
|
||||
description=description,
|
||||
is_active=bool(is_active)
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Edificio "{name}" creado con éxito.',
|
||||
'building': building.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@api_admin_bp.route('/buildings/<int:id>', methods=['PUT', 'POST'])
|
||||
@jwt_required
|
||||
def update_building(id):
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
building = Building.query.get(id)
|
||||
if not building:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||
|
||||
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'ValidationError', 'message': 'El nombre del edificio es obligatorio.'}), 400
|
||||
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == name.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Conflict', 'message': f'Ya existe otro edificio con el nombre "{name}".'}), 409
|
||||
|
||||
old_name = building.name
|
||||
building.name = name
|
||||
if 'code' in data:
|
||||
building.code = (data.get('code') or '').strip().upper() or None
|
||||
if 'address' in data:
|
||||
building.address = (data.get('address') or '').strip() or None
|
||||
if 'floors' in data:
|
||||
building.floors = int(data.get('floors', 0) or 0)
|
||||
if 'description' in data:
|
||||
building.description = (data.get('description') or '').strip() or None
|
||||
if 'is_active' in data:
|
||||
is_active = data.get('is_active')
|
||||
if isinstance(is_active, str):
|
||||
building.is_active = is_active.lower() in ['true', '1', 'on']
|
||||
else:
|
||||
building.is_active = bool(is_active)
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
if old_name != name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': name})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Edificio "{building.name}" actualizado con éxito.',
|
||||
'building': building.to_dict()
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/buildings/<int:id>/toggle', methods=['POST'])
|
||||
@jwt_required
|
||||
def toggle_building(id):
|
||||
from app.models.building import Building
|
||||
building = Building.query.get(id)
|
||||
if not building:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Edificio "{building.name}" {"activado" if building.is_active else "desactivado"} exitosamente.',
|
||||
'building': building.to_dict()
|
||||
}), 200
|
||||
|
||||
|
||||
@api_admin_bp.route('/buildings/<int:id>', methods=['DELETE'])
|
||||
@jwt_required
|
||||
def delete_building(id):
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
building = Building.query.get(id)
|
||||
if not building:
|
||||
return jsonify({'error': 'NotFound', 'message': f'Edificio con ID {id} no encontrado.'}), 404
|
||||
|
||||
count = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
if count > 0:
|
||||
return jsonify({
|
||||
'error': 'Conflict',
|
||||
'message': f'No se puede eliminar el edificio "{building.name}" porque tiene {count} aulas asociadas.'
|
||||
}), 409
|
||||
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': f'Edificio "{building.name}" eliminado correctamente.'
|
||||
}), 200
|
||||
|
||||
@@ -32,15 +32,18 @@ def list_buildings():
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
# Calculate global building stats (excluye aulas virtuales con 9999)
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
physical_classrooms = [c for c in Classroom.query.filter_by(is_active=True).all() if not getattr(c, 'is_virtual', False) and (c.capacity or 0) < 999 and 'VIRTUAL' not in (c.code or '').upper()]
|
||||
total_physical_capacity = sum(c.capacity for c in physical_classrooms)
|
||||
total_capacity = total_physical_capacity
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
total_physical_capacity=total_physical_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
|
||||
Reference in New Issue
Block a user