diff --git a/app/models/__init__.py b/app/models/__init__.py index ea165db..cafb22f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,6 +2,7 @@ from .user import User from .building import Building from .classroom import Classroom, ClassroomResource from .career import Career +from .academic_term import AcademicTerm from .subject import Subject, Commission from .reservation import Reservation, ReservationStatus from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer @@ -14,6 +15,7 @@ __all__ = [ 'Classroom', 'ClassroomResource', 'Career', + 'AcademicTerm', 'Subject', 'Commission', 'Reservation', diff --git a/app/models/academic_term.py b/app/models/academic_term.py new file mode 100644 index 0000000..22aa0bf --- /dev/null +++ b/app/models/academic_term.py @@ -0,0 +1,34 @@ +from app import db +from datetime import datetime + +class AcademicTerm(db.Model): + """Modelo para Ciclos Lectivos y Períodos Académicos""" + __tablename__ = 'academic_terms' + + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2 + name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre + year = db.Column(db.Integer, nullable=False, default=2026, index=True) + period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano + start_date = db.Column(db.Date, nullable=True) + end_date = db.Column(db.Date, nullable=True) + is_current = db.Column(db.Boolean, default=False, nullable=False, index=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'code': self.code, + 'name': self.name, + 'year': self.year, + 'period_type': self.period_type, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'is_current': self.is_current, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None + } + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index 27816a3..6b6d555 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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//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//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//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//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//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//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 + ) + + + diff --git a/app/templates/admin/academic_terms/list.html b/app/templates/admin/academic_terms/list.html new file mode 100644 index 0000000..696948d --- /dev/null +++ b/app/templates/admin/academic_terms/list.html @@ -0,0 +1,252 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Gestión de Ciclo Lectivo{% endtrans %} - Edu-Space Admin{% endblock %} + +{% block content %} +
+ +
+
+

+ {% trans %}Gestión de Ciclo Lectivo y Períodos Académicos{% endtrans %} +

+

+ {% trans %}Administración de cuatrimestres, años académicos, fechas de inicio/fin y selección del ciclo lectivo activo.{% endtrans %} +

+
+
+ {% if can_edit %} + + {% endif %} + + {% trans %}Ver Cursadas{% endtrans %} + +
+
+ + +
+
+ + + + + + + + + + + {% if can_edit %} + + {% endif %} + + + + {% for term in terms %} + {% set t_stat = stats.get(term.id, {'commissions_count': 0}) %} + + + + + + + + + {% if can_edit %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
{% trans %}Código{% endtrans %}{% trans %}Nombre del Ciclo Lectivo{% endtrans %}{% trans %}Año{% endtrans %}{% trans %}Período / Modalidad{% endtrans %}{% trans %}Fechas del Período{% endtrans %}{% trans %}Comisiones{% endtrans %}{% trans %}Estado Institucional{% endtrans %}{% trans %}Acciones{% endtrans %}
+ {{ term.code }} + +
{{ term.name }}
+ ID: #{{ term.id }} +
+ {{ term.year }} + + + {{ term.period_type }} + + + {% if term.start_date and term.end_date %} + + {{ term.start_date.strftime('%d/%m/%Y') }} - {{ term.end_date.strftime('%d/%m/%Y') }} + + {% else %} + {% trans %}Sin fechas fijadas{% endtrans %} + {% endif %} + + + {{ t_stat.commissions_count }} {% trans %}comisiones{% endtrans %} + + + {% if term.is_current %} + + {% trans %}Ciclo Activo Actual{% endtrans %} + + {% else %} + + {% if term.is_active %}{% trans %}Habilitado{% endtrans %}{% else %}{% trans %}Archivado{% endtrans %}{% endif %} + + {% endif %} + +
+ {% if not term.is_current %} +
+ +
+ {% endif %} + +
+ + + +
+ + {% trans %}No se encontraron ciclos lectivos registrados.{% endtrans %} +
+
+
+
+ + +{% if can_edit %} + +{% endif %} +{% endblock %} diff --git a/app/templates/admin/careers/list.html b/app/templates/admin/careers/list.html new file mode 100644 index 0000000..96addd0 --- /dev/null +++ b/app/templates/admin/careers/list.html @@ -0,0 +1,208 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Gestión de Carreras{% endtrans %} - Edu-Space Admin{% endblock %} + +{% block content %} +
+ +
+
+

+ {% trans %}Gestión de Carreras y Titulaciones{% endtrans %} +

+

+ {% trans %}Administración de programas académicos, tecnicaturas, licenciaturas y planes de estudio.{% endtrans %} +

+
+
+ {% if can_edit %} + + {% endif %} + + {% trans %}Ver Asignaturas{% endtrans %} + +
+
+ + +
+
+
+
+
+ + +
+
+
+ + + {% trans %}Limpiar{% endtrans %} + +
+
+
+
+ + +
+
+ + + + + + + + + {% if can_edit %} + + {% endif %} + + + + {% for career in careers %} + {% set c_stat = stats.get(career.id, {'subjects_count': 0, 'commissions_count': 0}) %} + + + + + + + {% if can_edit %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
{% trans %}Carrera / Titulación{% endtrans %}{% trans %}Código{% endtrans %}{% trans %}Asignaturas{% endtrans %}{% trans %}Comisiones Activas{% endtrans %}{% trans %}Descripción{% endtrans %}{% trans %}Acciones{% endtrans %}
+
+
+ +
+
+ {{ career.name }} +
+
+
+ {% if career.code %} + {{ career.code }} + {% else %} + - + {% endif %} + + + {{ c_stat.subjects_count }} {% trans %}asignaturas{% endtrans %} + + + + {{ c_stat.commissions_count }} {% trans %}comisiones{% endtrans %} + + + + {{ career.description or '-' }} + + +
+ + {% if c_stat.subjects_count == 0 %} +
+ +
+ {% endif %} +
+ + + +
+ + {% trans %}No se encontraron carreras registradas.{% endtrans %} +
+
+
+
+ + +{% if can_edit %} + +{% endif %} +{% endblock %} diff --git a/app/templates/admin/metrics/occupancy.html b/app/templates/admin/metrics/occupancy.html new file mode 100644 index 0000000..dc89309 --- /dev/null +++ b/app/templates/admin/metrics/occupancy.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Gestión de Ocupación y Métricas{% endtrans %} - Edu-Space Admin{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ {% trans %}Gestión de Ocupación y Métricas de Espacios{% endtrans %} +

+

+ {% trans %}Analítica institucional de aprovechamiento de aulas físicas, demanda en Campus Virtual y distribución horaria.{% endtrans %} +

+
+ +
+ + +
+ +
+
+
+
+ {% trans %}Tasa de Ocupación Hoy{% endtrans %} +

{{ avg_day_pct }}%

+
+
+ +
+
+
+
+
+ + {% trans %}Promedio sobre 15 hs operativas ({{ physical_rooms_count }} aulas presenciales){% endtrans %} + +
+
+ + +
+
+
+
+ {% trans %}Ocupación Semanal{% endtrans %} +

{{ avg_week_pct }}%

+
+
+ +
+
+
+
+
+ + {% trans %}Semana en curso (Lunes a Sábado){% endtrans %} + +
+
+ + +
+
+
+
+ {% trans %}Ocupación del Mes{% endtrans %} +

{{ avg_month_pct }}%

+
+
+ +
+
+
+
+
+ + {% trans %}Total mes calendario activo{% endtrans %} + +
+
+ + +
+
+
+
+ {% trans %}Total Clases en el Mes{% endtrans %} +

{{ total_month_classes }}

+
+
+ +
+
+
+ Físicas: {{ total_month_classes - v_month }} + Virtuales: {{ v_month }} +
+ + {% trans %}Reservas y sesiones registradas en el período{% endtrans %} + +
+
+
+ + +
+ +
+
+
+
+ {% trans %}Tasa de Ocupación por Piso / Nivel{% endtrans %} +
+ Presencial +
+
+
+ {% for fs in floor_summary %} +
+
+ + {{ fs.floor }} ({{ fs.count }} {% trans %}aulas{% endtrans %}) + + + {{ fs.avg_pct }}% + +
+
+
+
+
+ {% endfor %} +
+
+
+
+ + +
+
+
+
+ {% trans %}Distribución de Clases por Turno{% endtrans %} +
+ Mes Completo +
+
+
+
+
+
{% trans %}Mañana{% endtrans %}
+
{{ shift_counts.Mañana }}
+
+
+
+
+
{% trans %}Tarde{% endtrans %}
+
{{ shift_counts.Tarde }}
+
+
+
+
+
{% trans %}Vespertino{% endtrans %}
+
{{ shift_counts.Vespertino }}
+
+
+
+
+
{% trans %}Noche{% endtrans %}
+
{{ shift_counts.Noche }}
+
+
+
+ + +
+
+
+
{% trans %}Campus Virtual (A Demanda · ∞){% endtrans %}
+

{% trans %}Salas simultáneas sin límite físico de cupo ni colisión.{% endtrans %}

+
+
+
{{ v_month }} clases mes
+ {{ v_today }} hoy · {{ v_week }} semana +
+
+
+
+
+
+
+ + +
+ +
+
+
+
+ {% trans %}Top Aulas con Mayor Demanda y Ocupación{% endtrans %} +
+
+
+ + + + + + + + + + + + {% for rm in top_rooms %} + + + + + + + + {% endfor %} + +
{% trans %}Aula{% endtrans %}{% trans %}Piso{% endtrans %}{% trans %}Capacidad{% endtrans %}{% trans %}Ocup. Mes{% endtrans %}{% trans %}Acción{% endtrans %}
{{ rm.classroom.code }}{{ rm.classroom.floor_display }}{{ rm.classroom.capacity }} asientos + + {{ rm.month_pct }}% ({{ rm.month_count }} cl.) + + + + {% trans %}Ver Reservas{% endtrans %} + +
+
+
+
+ + +
+
+
+
+ {% trans %}Aulas con Mayor Disponibilidad (Menor Uso){% endtrans %} +
+
+
+ + + + + + + + + + + + {% for rm in low_rooms %} + + + + + + + + {% endfor %} + +
{% trans %}Aula{% endtrans %}{% trans %}Piso{% endtrans %}{% trans %}Capacidad{% endtrans %}{% trans %}Ocup. Mes{% endtrans %}{% trans %}Acción{% endtrans %}
{{ rm.classroom.code }}{{ rm.classroom.floor_display }}{{ rm.classroom.capacity }} asientos + + {{ rm.month_pct }}% ({{ rm.month_count }} cl.) + + + + {% trans %}Reservar{% endtrans %} + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/subjects/list.html b/app/templates/admin/subjects/list.html new file mode 100644 index 0000000..ba38dcd --- /dev/null +++ b/app/templates/admin/subjects/list.html @@ -0,0 +1,306 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Gestión de Asignaturas{% endtrans %} - Edu-Space Admin{% endblock %} + +{% block content %} +
+ +
+
+

+ {% trans %}Gestión de Asignaturas y Materias{% endtrans %} +

+

+ {% trans %}Catálogo institucional de asignaturas, códigos, créditos académicos y carreras vinculadas.{% endtrans %} +

+
+
+ {% if can_edit %} + + {% endif %} + + {% trans %}Ver Comisiones{% endtrans %} + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + + {% trans %}Limpiar{% endtrans %} + +
+
+
+
+ + +
+
+ + + + + + + + + + + {% if can_edit %} + + {% endif %} + + + + {% for s in subjects %} + + + + + + + + + {% if can_edit %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
{% trans %}Código{% endtrans %}{% trans %}Nombre de la Asignatura{% endtrans %}{% trans %}Carrera{% endtrans %}{% trans %}Departamento{% endtrans %}{% trans %}Créditos{% endtrans %}{% trans %}Comisiones{% endtrans %}{% trans %}Estado{% endtrans %}{% trans %}Acciones{% endtrans %}
+ {{ s.code }} + + {{ s.name }} + {% if s.description %} + {{ s.description }} + {% endif %} + + {% if s.career_obj %} + + {{ s.career_obj.name }} + + {% else %} + General + {% endif %} + + {{ s.department or '-' }} + + {{ s.credits }} cr. + + + {{ s.commissions|length if s.commissions else 0 }} + + + + {% if s.is_active %}{% trans %}Activa{% endtrans %}{% else %}{% trans %}Inactiva{% endtrans %}{% endif %} + + +
+ +
+ +
+
+ + + +
+ + {% trans %}No se encontraron asignaturas con los criterios indicados.{% endtrans %} +
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} +
+
+ + +{% if can_edit %} + +{% endif %} +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 1752891..e0c079d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -104,19 +104,32 @@ {% if current_user.is_authenticated and current_user.can_manage() %}