from flask import Blueprint, jsonify, request from app.utils.jwt_decorators import jwt_required from app.models.classroom import Classroom from app.models.subject import Subject from app.models.user import User from app.models.reservation import Reservation from app.models.audit_log import AuditLog from datetime import datetime, timedelta from sqlalchemy import func from app import db from app.models.milestone import AcademicMilestone from app.models.subject import Commission api_dashboard_bp = Blueprint('api_dashboard', __name__, url_prefix='/api/v1/dashboard') def serialize_reservation(r): subject_name = (r.commission.subject.name if (r.commission and r.commission.subject) else r.purpose) or 'Actividad Académica' subject_code = (r.commission.subject.code if (r.commission and r.commission.subject) else '') classroom_code = r.classroom.code if r.classroom else 'Sin Aula' is_virtual = ('virtual' in classroom_code.lower()) or (getattr(r.classroom, 'is_virtual', False) if r.classroom else False) return { 'id': r.id, 'start_time': r.start_time.strftime('%H:%M') if r.start_time else '', 'end_time': r.end_time.strftime('%H:%M') if r.end_time else '', 'date_display': r.start_time.strftime('%d/%m') if r.start_time else '', 'classroom': { 'id': r.classroom_id, 'code': classroom_code, 'is_virtual': is_virtual, 'location_display': getattr(r.classroom, 'location_display', f'Sede Central · {classroom_code}') if r.classroom else 'Sin Aula' }, 'subject_name': subject_name, 'subject_code': subject_code, 'shift': r.shift or 'Mañana', 'commission': { 'id': r.commission_id, 'code': r.commission.code if r.commission else 'COM-1' }, 'status': str(r.status.value if hasattr(r.status, 'value') else r.status), 'user': { 'id': r.user_id, 'name': r.user.name if r.user else 'Docente' }, 'virtual_link': r.virtual_link or ('https://meet.google.com/edu-space-' + classroom_code.lower().replace(' ', '-') if is_virtual else '') } def serialize_milestone(m): return { 'id': m.id, 'title': m.title, 'materia_codigo': m.materia_codigo, 'subject_name': m.subject.name if (hasattr(m, 'subject') and m.subject) else m.materia_codigo, 'date': m.date.strftime('%d/%m/%Y') if m.date else '', 'type_obj': { 'name': m.type_obj.name if m.type_obj else 'Evaluación', 'color': m.type_obj.color if m.type_obj else '#0d6efd' }, 'classroom': m.classroom or 'Sede Central', 'link': m.link or '', 'notes': m.notes or '' } @api_dashboard_bp.route('/stats', methods=['GET']) @jwt_required def get_stats(): """Retorna estadísticas clave y datos operativos para el Dashboard.""" today = datetime.utcnow().date() try: total_classrooms = Classroom.query.filter_by(is_active=True).count() total_subjects = Subject.query.filter_by(is_active=True).count() total_users = User.query.filter_by(is_active=True).count() # Aulas presenciales y virtuales all_active_classrooms = Classroom.query.filter_by(is_active=True).all() virtual_classrooms_list = [ c for c in all_active_classrooms if getattr(c, 'is_virtual', False) or 'virtual' in (c.building or '').lower() or 'virtual' in (c.code or '').lower() ] virtual_classrooms = len(virtual_classrooms_list) physical_classrooms_list = [ c for c in all_active_classrooms if c not in virtual_classrooms_list ] total_physical_count = len(physical_classrooms_list) physical_classrooms = total_physical_count physical_ids = set(c.id for c in physical_classrooms_list) # 1. Asignación General del Cuatrimestre / Cronograma assigned_all_ids = set( r[0] for r in db.session.query(Reservation.classroom_id) .filter(Reservation.classroom_id.isnot(None)).distinct().all() ) assigned_physical_ids = physical_ids.intersection(assigned_all_ids) assigned_classrooms = len(assigned_physical_ids) unassigned_classrooms = max(0, total_physical_count - assigned_classrooms) assigned_pct = round((assigned_classrooms / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0 unassigned_pct = round(100.0 - assigned_pct, 1) if total_physical_count > 0 else 0.0 # 2. Asignación Operativa del Día de Hoy assigned_today_ids = set( r[0] for r in db.session.query(Reservation.classroom_id).filter( Reservation.classroom_id.isnot(None), func.date(Reservation.start_time) == today ).distinct().all() ) assigned_today_physical_ids = physical_ids.intersection(assigned_today_ids) assigned_today_count = len(assigned_today_physical_ids) unassigned_today_count = max(0, total_physical_count - assigned_today_count) assigned_today_pct = round((assigned_today_count / max(1, total_physical_count)) * 100, 1) if total_physical_count > 0 else 0.0 unassigned_today_pct = round(100.0 - assigned_today_pct, 1) if total_physical_count > 0 else 0.0 today_reservations_q = Reservation.query.filter( func.date(Reservation.start_time) == today ).order_by(Reservation.start_time.asc()).all() today_reservations = len(today_reservations_q) pending_reservations_q = Reservation.query.filter( Reservation.status == 'PENDING' ).order_by(Reservation.start_time.asc()).all() pending_count = len(pending_reservations_q) upcoming_reservations_q = Reservation.query.filter( Reservation.start_time >= datetime.utcnow() ).order_by(Reservation.start_time.asc()).limit(10).all() milestones_q = AcademicMilestone.query.filter( AcademicMilestone.is_active == True ).order_by(AcademicMilestone.date.asc()).limit(10).all() recent_audits = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(6).all() audit_data = [{ 'created_at': a.created_at.strftime('%Y-%m-%d %H:%M') if a.created_at else None, 'action_type': a.action_type, 'ip_address': a.ip_address, 'user_email': getattr(a.user, 'email', 'System') if hasattr(a, 'user') else 'System' } for a in recent_audits] occupancy_rate = min(100, int((today_reservations / max(1, physical_classrooms * 3)) * 100)) if physical_classrooms > 0 else 0 pending_reservations = [serialize_reservation(r) for r in pending_reservations_q] today_schedule_list = [serialize_reservation(r) for r in today_reservations_q] upcoming_reservations = [serialize_reservation(r) for r in upcoming_reservations_q] milestones = [serialize_milestone(m) for m in milestones_q] except Exception as e: print(f"Error fetching stats: {e}") total_classrooms = 0 total_subjects = 0 total_users = 0 today_reservations = 0 pending_count = 0 physical_classrooms = 0 virtual_classrooms = 0 occupancy_rate = 0 assigned_classrooms = 0 assigned_pct = 0.0 unassigned_classrooms = 0 unassigned_pct = 0.0 assigned_today_count = 0 assigned_today_pct = 0.0 unassigned_today_count = 0 unassigned_today_pct = 0.0 audit_data = [] pending_reservations = [] today_schedule_list = [] upcoming_reservations = [] milestones = [] # Mocked monthly data for chart since complex SQL group by month might differ based on dialect (SQLite vs Postgres) monthly_data = [ {"month": "Ene", "reservations": 120}, {"month": "Feb", "reservations": 180}, {"month": "Mar", "reservations": 250}, {"month": "Abr", "reservations": 220}, {"month": "May", "reservations": 300}, {"month": "Jun", "reservations": 310}, {"month": "Jul", "reservations": 200}, {"month": "Ago", "reservations": 350}, {"month": "Sep", "reservations": 420}, {"month": "Oct", "reservations": 480}, {"month": "Nov", "reservations": 520}, {"month": "Dic", "reservations": 210} ] return jsonify({ 'status': 'success', 'stats': { 'total_classrooms': total_classrooms, 'active_subjects': total_subjects, 'total_active_users': total_users, 'today_reservations': today_reservations, 'pending_count': pending_count, 'physical_classrooms': physical_classrooms, 'virtual_classrooms': virtual_classrooms, 'occupancy_rate': occupancy_rate, 'assigned_classrooms': assigned_classrooms, 'assigned_classrooms_pct': assigned_pct, 'unassigned_classrooms': unassigned_classrooms, 'unassigned_classrooms_pct': unassigned_pct, 'assigned_today_classrooms': assigned_today_count, 'assigned_today_pct': assigned_today_pct, 'unassigned_today_classrooms': unassigned_today_count, 'unassigned_today_pct': unassigned_today_pct }, 'audit_logs': audit_data, 'monthly_data': monthly_data, 'pending_reservations': pending_reservations, 'today_schedule_list': today_schedule_list, 'upcoming_reservations': upcoming_reservations, 'milestones': milestones }), 200 @api_dashboard_bp.route('/sync-sheets', methods=['POST']) @jwt_required def sync_google_sheets(): """Ejecuta la sincronización real con las hojas de cálculo de Google Sheets.""" from app.services.sheets_importer import GoogleSheetsImporter try: data = request.get_json(silent=True) or {} custom_url = data.get('sheets_url') or data.get('base_url') importer = GoogleSheetsImporter(base_url=custom_url) stats = importer.sync() return jsonify({ 'status': 'success', 'stats': stats, 'source_url': importer.base_url }), 200 except Exception as e: return jsonify({ 'status': 'error', 'message': str(e) }), 500