feat: modulo integral de gestion (8 pilares) y limpieza de breadcrumbs redundantes

This commit is contained in:
Carlos Tello
2026-09-03 11:59:47 -03:00
parent adb6ba729d
commit bbe8e220ae
10 changed files with 1749 additions and 23 deletions
+591
View File
@@ -526,3 +526,594 @@ def api_milestone_types():
return jsonify([t.to_dict() for t in types])
# ---------------------------------------------------------
# 1. GESTIÓN DE CARRERAS (CAREERS)
# ---------------------------------------------------------
@admin_bp.route('/careers')
@login_required
def careers_list():
"""List and manage academic careers"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.career import Career
from app.models.subject import Subject
search = request.args.get('search', '').strip()
query = Career.query
if search:
query = query.filter(
or_(
Career.name.ilike(f'%{search}%'),
Career.code.ilike(f'%{search}%'),
Career.description.ilike(f'%{search}%')
)
)
careers = query.order_by(Career.name.asc()).all()
# Calculate stats per career
stats = {}
for c in careers:
subj_count = len(c.subjects) if c.subjects else 0
comm_count = sum(len(s.commissions) for s in c.subjects) if c.subjects else 0
stats[c.id] = {
'subjects_count': subj_count,
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/careers/list.html',
careers=careers,
stats=stats,
search=search,
can_edit=can_edit
)
@admin_bp.route('/careers/add', methods=['POST'])
@login_required
def career_add():
"""Create a new academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name)).first()
if existing:
flash(_('Ya existe una carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career = Career(name=name, code=code or None, description=description or None)
db.session.add(career)
db.session.commit()
flash(_('Carrera "%(name)s" creada exitosamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/edit', methods=['POST'])
@login_required
def career_edit(id):
"""Edit an academic career"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('El nombre de la carrera es obligatorio.'), 'error')
return redirect(url_for('admin.careers_list'))
existing = Career.query.filter(Career.name.ilike(name), Career.id != id).first()
if existing:
flash(_('Ya existe otra carrera registrada con el nombre "%(name)s".', name=name), 'error')
return redirect(url_for('admin.careers_list'))
career.name = name
career.code = code or None
career.description = description or None
db.session.commit()
flash(_('Carrera "%(name)s" actualizada correctamente.', name=career.name), 'success')
return redirect(url_for('admin.careers_list'))
@admin_bp.route('/careers/<int:id>/delete', methods=['POST'])
@login_required
def career_delete(id):
"""Delete an academic career if it has no associated subjects"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.career import Career
career = Career.query.get_or_404(id)
if career.subjects and len(career.subjects) > 0:
flash(_('No se puede eliminar la carrera "%(name)s" porque tiene %(count)d asignaturas vinculadas.', name=career.name, count=len(career.subjects)), 'error')
return redirect(url_for('admin.careers_list'))
name = career.name
db.session.delete(career)
db.session.commit()
flash(_('Carrera "%(name)s" eliminada permanentemente.', name=name), 'success')
return redirect(url_for('admin.careers_list'))
# ---------------------------------------------------------
# 2. GESTIÓN DE ASIGNATURAS (SUBJECTS)
# ---------------------------------------------------------
@admin_bp.route('/subjects')
@login_required
def subjects_list():
"""List and manage subjects / materias"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
search = request.args.get('search', '').strip()
career_filter = request.args.get('career_id', type=int)
active_filter = request.args.get('active', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20
query = Subject.query
if search:
query = query.filter(
or_(
Subject.name.ilike(f'%{search}%'),
Subject.code.ilike(f'%{search}%'),
Subject.department.ilike(f'%{search}%')
)
)
if career_filter:
query = query.filter(Subject.career_id == career_filter)
if active_filter in ['true', 'false']:
query = query.filter(Subject.is_active == (active_filter == 'true'))
pagination = query.order_by(Subject.code.asc(), Subject.name.asc()).paginate(page=page, per_page=per_page, error_out=False)
careers = Career.query.order_by(Career.name.asc()).all()
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/subjects/list.html',
subjects=pagination.items,
pagination=pagination,
careers=careers,
search=search,
career_filter=career_filter,
active_filter=active_filter,
can_edit=can_edit
)
@admin_bp.route('/subjects/add', methods=['POST'])
@login_required
def subject_add():
"""Create a new subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe una asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject = Subject(
code=code,
name=name,
department=department or career_name or 'General',
career_id=career_id if career_id else None,
credits=credits or 4,
description=description or None,
is_active=True
)
db.session.add(subject)
db.session.commit()
flash(_('Asignatura "%(name)s" (%(code)s) creada exitosamente.', name=subject.name, code=subject.code), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/edit', methods=['POST'])
@login_required
def subject_edit(id):
"""Edit an existing subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
from app.models.career import Career
subject = Subject.query.get_or_404(id)
code = request.form.get('code', '').strip().upper()
name = request.form.get('name', '').strip()
department = request.form.get('department', '').strip()
career_id = request.form.get('career_id', type=int)
credits = request.form.get('credits', 4, type=int)
description = request.form.get('description', '').strip()
is_active = request.form.get('is_active') == 'on'
if not code or not name:
flash(_('El código y el nombre de la asignatura son obligatorios.'), 'error')
return redirect(url_for('admin.subjects_list'))
existing = Subject.query.filter(Subject.code == code, Subject.id != id).first()
if existing:
flash(_('Ya existe otra asignatura con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.subjects_list'))
career_name = None
if career_id:
c = Career.query.get(career_id)
if c:
career_name = c.name
subject.code = code
subject.name = name
subject.department = department or career_name or subject.department
subject.career_id = career_id if career_id else None
subject.credits = credits or 4
subject.description = description or None
subject.is_active = is_active
db.session.commit()
flash(_('Asignatura "%(name)s" actualizada exitosamente.', name=subject.name), 'success')
return redirect(url_for('admin.subjects_list'))
@admin_bp.route('/subjects/<int:id>/toggle', methods=['POST'])
@login_required
def subject_toggle(id):
"""Toggle active status of a subject"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.subject import Subject
subject = Subject.query.get_or_404(id)
subject.is_active = not subject.is_active
db.session.commit()
status_str = _('activada') if subject.is_active else _('desactivada')
flash(_('Asignatura "%(name)s" %(status)s.', name=subject.name, status=status_str), 'success')
return redirect(url_for('admin.subjects_list'))
# ---------------------------------------------------------
# 3. GESTIÓN DE CICLO LECTIVO (ACADEMIC TERMS)
# ---------------------------------------------------------
@admin_bp.route('/academic-terms')
@login_required
def academic_terms_list():
"""List and manage academic cycles / terms"""
if not (current_user.has_permission('academic', 'read') or current_user.can_manage()):
abort(403)
from app.models.academic_term import AcademicTerm
from app.models.subject import Commission
from app.models.reservation import Reservation
terms = AcademicTerm.query.order_by(AcademicTerm.year.desc(), AcademicTerm.code.desc()).all()
# Calculate associated commissions and reservations
stats = {}
for t in terms:
comm_count = Commission.query.filter(
or_(Commission.semester == t.code, db.and_(Commission.year == t.year, Commission.semester.ilike(f'%{t.code}%')))
).count()
stats[t.id] = {
'commissions_count': comm_count
}
can_edit = current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')
return render_template(
'admin/academic_terms/list.html',
terms=terms,
stats=stats,
can_edit=can_edit
)
@admin_bp.route('/academic-terms/add', methods=['POST'])
@login_required
def academic_term_add():
"""Create a new academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe un ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
if is_current:
# Unset previous current terms
AcademicTerm.query.update({'is_current': False})
term = AcademicTerm(
code=code,
name=name,
year=year,
period_type=period_type or 'Segundo Cuatrimestre',
start_date=start_date,
end_date=end_date,
is_current=is_current,
is_active=True
)
db.session.add(term)
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" creado exitosamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/edit', methods=['POST'])
@login_required
def academic_term_edit(id):
"""Edit an academic cycle"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
from datetime import datetime
term = AcademicTerm.query.get_or_404(id)
code = request.form.get('code', '').strip()
name = request.form.get('name', '').strip()
year = request.form.get('year', type=int)
period_type = request.form.get('period_type', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
is_current = request.form.get('is_current') == 'on'
is_active = request.form.get('is_active') == 'on'
if not code or not name or not year:
flash(_('El código, nombre y año del ciclo lectivo son obligatorios.'), 'error')
return redirect(url_for('admin.academic_terms_list'))
existing = AcademicTerm.query.filter(AcademicTerm.code == code, AcademicTerm.id != id).first()
if existing:
flash(_('Ya existe otro ciclo lectivo con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.academic_terms_list'))
if is_current and not term.is_current:
AcademicTerm.query.update({'is_current': False})
term.code = code
term.name = name
term.year = year
term.period_type = period_type or term.period_type
term.start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None
term.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
term.is_current = is_current
term.is_active = is_active
db.session.commit()
flash(_('Ciclo lectivo "%(name)s" actualizado correctamente.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
@admin_bp.route('/academic-terms/<int:id>/set-current', methods=['POST'])
@login_required
def academic_term_set_current(id):
"""Set an academic cycle as the currently active one"""
if not (current_user.has_permission('academic', 'read_write') or (current_user.role and current_user.role.upper() == 'ADMIN')):
abort(403)
from app.models.academic_term import AcademicTerm
term = AcademicTerm.query.get_or_404(id)
AcademicTerm.query.update({'is_current': False})
term.is_current = True
term.is_active = True
db.session.commit()
flash(_('El ciclo lectivo "%(name)s" fue establecido como Ciclo Activo del sistema.', name=term.name), 'success')
return redirect(url_for('admin.academic_terms_list'))
# ---------------------------------------------------------
# 4. GESTIÓN DE OCUPACIÓN Y MÉTRICAS (OCCUPANCY DASHBOARD)
# ---------------------------------------------------------
@admin_bp.route('/occupancy-metrics')
@login_required
def occupancy_metrics():
"""Consolidated institutional dashboard of classroom occupancy, shifts, and space usage"""
if not (current_user.has_permission('metrics', 'read') or current_user.can_manage()):
abort(403)
from app.models.classroom import Classroom
from app.models.building import Building
from app.models.reservation import Reservation, ReservationStatus
import calendar
from datetime import datetime, date, timedelta
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
week_start = datetime.combine(today - timedelta(days=today.weekday()), datetime.min.time())
week_end = datetime.combine(week_start.date() + timedelta(days=6), datetime.max.time())
_, last_day = calendar.monthrange(today.year, today.month)
month_start = datetime(today.year, today.month, 1, 0, 0, 0)
month_end = datetime(today.year, today.month, last_day, 23, 59, 59)
# Buildings & Classrooms
buildings = Building.query.filter_by(is_active=True).all()
vb = next((b for b in buildings if b.code == 'VIRTUAL' or 'virtual' in b.name.lower()), None)
vb_id = vb.id if vb else 0
physical_rooms = Classroom.query.filter(
Classroom.is_active == True,
Classroom.building_id != vb_id if vb_id else True,
~Classroom.code.ilike('%VIRTUAL%')
).all()
vc = Classroom.query.filter(
Classroom.building_id == vb_id if vb_id else Classroom.code.ilike('%VIRTUAL%')
).first()
# Reservations in month
month_res = Reservation.query.filter(
Reservation.start_time >= month_start,
Reservation.start_time <= month_end,
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
).all()
# Calculate physical stats
DAILY_OP_MINS = 15 * 60
WEEKLY_OP_MINS = 6 * 15 * 60
MONTHLY_OP_MINS = 24 * 15 * 60
room_metrics = []
for c in physical_rooms:
c_res = [r for r in month_res if r.classroom_id == c.id]
day_res = [r for r in c_res if today_start <= r.start_time <= today_end]
day_mins = sum((min(r.end_time, today_end) - max(r.start_time, today_start)).total_seconds() / 60 for r in day_res)
day_pct = min(100, int(round((day_mins / DAILY_OP_MINS) * 100)))
week_res = [r for r in c_res if week_start <= r.start_time <= week_end]
week_mins = sum((min(r.end_time, week_end) - max(r.start_time, week_start)).total_seconds() / 60 for r in week_res)
week_pct = min(100, int(round((week_mins / WEEKLY_OP_MINS) * 100)))
month_mins = sum((min(r.end_time, month_end) - max(r.start_time, month_start)).total_seconds() / 60 for r in c_res)
month_pct = min(100, int(round((month_mins / MONTHLY_OP_MINS) * 100)))
room_metrics.append({
'classroom': c,
'day_pct': day_pct,
'day_count': len(day_res),
'week_pct': week_pct,
'week_count': len(week_res),
'month_pct': month_pct,
'month_count': len(c_res)
})
room_metrics.sort(key=lambda x: x['month_pct'], reverse=True)
top_rooms = room_metrics[:5]
low_rooms = list(reversed(room_metrics))[:5]
# Global averages
avg_day_pct = int(round(sum(rm['day_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_week_pct = int(round(sum(rm['week_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
avg_month_pct = int(round(sum(rm['month_pct'] for rm in room_metrics) / len(room_metrics))) if room_metrics else 0
# Virtual classes
v_res = [r for r in month_res if vc and r.classroom_id == vc.id]
v_today = sum(1 for r in v_res if today_start <= r.start_time <= today_end)
v_week = sum(1 for r in v_res if week_start <= r.start_time <= week_end)
v_month = len(v_res)
# Shifts distribution
shift_counts = {'Mañana': 0, 'Tarde': 0, 'Vespertino': 0, 'Noche': 0, 'Otro': 0}
for r in month_res:
s = (r.shift or '').strip().capitalize()
if s in shift_counts:
shift_counts[s] += 1
elif 'Mañana' in s:
shift_counts['Mañana'] += 1
elif 'Tarde' in s:
shift_counts['Tarde'] += 1
elif 'Noche' in s:
shift_counts['Noche'] += 1
else:
shift_counts['Otro'] += 1
# Floors occupancy
floor_stats = {}
for rm in room_metrics:
flr = rm['classroom'].floor_display
if flr not in floor_stats:
floor_stats[flr] = {'rooms': 0, 'month_pct_sum': 0}
floor_stats[flr]['rooms'] += 1
floor_stats[flr]['month_pct_sum'] += rm['month_pct']
floor_summary = [
{'floor': f, 'avg_pct': int(round(data['month_pct_sum'] / data['rooms'])), 'count': data['rooms']}
for f, data in floor_stats.items()
]
floor_summary.sort(key=lambda x: x['avg_pct'], reverse=True)
date_filters = {
'today': today.strftime('%Y-%m-%d'),
'week_start': week_start.strftime('%Y-%m-%d'),
'week_end': week_end.strftime('%Y-%m-%d'),
'month_start': month_start.strftime('%Y-%m-%d'),
'month_end': month_end.strftime('%Y-%m-%d')
}
return render_template(
'admin/metrics/occupancy.html',
physical_rooms_count=len(physical_rooms),
avg_day_pct=avg_day_pct,
avg_week_pct=avg_week_pct,
avg_month_pct=avg_month_pct,
top_rooms=top_rooms,
low_rooms=low_rooms,
v_today=v_today,
v_week=v_week,
v_month=v_month,
shift_counts=shift_counts,
floor_summary=floor_summary,
total_month_classes=len(month_res),
date_filters=date_filters
)