feat: modulo integral de gestion (8 pilares) y limpieza de breadcrumbs redundantes
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user