diff --git a/CHANGELOG.md b/CHANGELOG.md index c6df57c..1424ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,29 @@ El formato está basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1 --- +## [2.2.0] - 2026-09-19 +### Añadido +- **Herramienta de Purga y Reinicio de Datos Académicos (`reset_academic_data`):** + - Script CLI `backend/scripts/reset_academic_data.py` con argumentos de limpieza granular (`--all`, `--reservations`, `--classrooms`, `--careers`, `--yes`). + - Endpoint de API `POST /api/v1/admin/reset-academic-data` protegido por JWT (rol `admin`) con registro en `AuditLog`. + - Panel interactivo "Zona de Reinicio y Purga Académica" en `/admin/google_sheets_import` con checkboxes independientes para reservas, aulas, comisiones, asignaturas y carreras. + - Modal modal de confirmación con advertencias de seguridad para prevenir purgas accidentales. + - Preservación íntegra de usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría. +- **Configuración Global Dinámica de Google Sheets:** + - Nuevo modelo `SystemSetting` (`backend/app/models/setting.py`) para almacenamiento persistente clave-valor en base de datos. + - Endpoints `GET /api/v1/admin/sheets-config` y `POST /api/v1/admin/sheets-config`. + - Normalizador de enlaces en `GoogleSheetsImporter` compatible con URLs compartidas de edición (`/spreadsheets/d/.../edit`) o enlaces publicados (`/pub?output=csv`). + - Interfaz web con formulario de edición de URL, botón de guardado en caliente, restauración a URL oficial predeterminada y acceso directo a Google Docs. +- **Visualización de Asignaturas Reales:** + - Tabla de asignaturas recientemente sincronizadas conectada a la base de datos real en lugar de datos de muestra (*mock*). + +### Corregido +- **Fallo por Timeout en Google Sheets Academic Sync:** + - Solucionado el corte prematuro de conexión en el BFF (`frontend/src/services/apiClient.js`), aumentando el timeout base a 25s y el timeout específico de sincronización a 90s (anteriormente limitado a 5s en operaciones que toman ~6.7s). + - Eliminado el enmascaramiento silencioso de errores en el BFF, permitiendo reportar fallos reales o éxitos detallados (+carreras, +aulas, +materias, +comisiones, +reservas). + +--- + ## [2.1.0] - 2026-09-19 ### Añadido - **Ficha Detallada de Reserva (`schedule.view_reservation`):** diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cba75ad..3ee7fcc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from .role import Role, Permission, SYSTEM_MODULES from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES from .audit_log import AuditLog from .enrollment import StudentEnrollment +from .setting import SystemSetting __all__ = [ 'User', @@ -31,5 +32,6 @@ __all__ = [ 'AcademicMilestone', 'BASE_MILESTONE_TYPES', 'AuditLog', - 'StudentEnrollment' + 'StudentEnrollment', + 'SystemSetting' ] \ No newline at end of file diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..3ba3c70 --- /dev/null +++ b/backend/app/models/setting.py @@ -0,0 +1,50 @@ +from app import db +from datetime import datetime + +class SystemSetting(db.Model): + """ + Parámetros de configuración del sistema persistentes en base de datos. + Permite almacenar enlaces de integración (e.g. Google Sheets), flags globales y metadatos. + """ + __tablename__ = 'system_settings' + + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(100), unique=True, nullable=False, index=True) + value = db.Column(db.Text, nullable=True) + description = db.Column(db.String(255), nullable=True) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + @classmethod + def get_value(cls, key, default=None): + try: + record = cls.query.filter_by(key=key).first() + if record and record.value is not None and record.value.strip() != '': + return record.value + except Exception: + pass + return default + + @classmethod + def set_value(cls, key, value, description=None): + record = cls.query.filter_by(key=key).first() + if not record: + record = cls(key=key, value=value, description=description) + db.session.add(record) + else: + record.value = value + if description: + record.description = description + record.updated_at = datetime.utcnow() + db.session.commit() + return record + + def to_dict(self): + return { + 'key': self.key, + 'value': self.value, + 'description': self.description, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + def __repr__(self): + return f'' diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index a8abc0a..dd3aa16 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -981,3 +981,118 @@ def delete_milestone_type(id): 'message': 'Tipificación eliminada permanentemente.' }), 200 +# --------------------------------------------------------- +# GOOGLE SHEETS CONFIGURATION & ACADEMIC DATA RESET TOOL +# --------------------------------------------------------- + +@api_admin_bp.route('/sheets-config', methods=['GET']) +@jwt_required +def get_sheets_config(): + """Obtiene la configuración actual del Google Sheet para sincronización.""" + from app.models.setting import SystemSetting + from app.services.sheets_importer import BASE_CSV_URL, normalize_sheets_url + import os + + db_url = SystemSetting.get_value('google_sheets_url') + env_url = os.getenv('GOOGLE_SHEETS_URL') + active_url = db_url or env_url or BASE_CSV_URL + source = 'database' if db_url else ('environment' if env_url else 'default') + + return jsonify({ + 'status': 'success', + 'config': { + 'active_url': active_url, + 'source': source, + 'db_url': db_url, + 'default_url': BASE_CSV_URL, + 'normalized_url': normalize_sheets_url(active_url) + } + }), 200 + +@api_admin_bp.route('/sheets-config', methods=['POST']) +@jwt_required +def update_sheets_config(): + """Actualiza la URL del Google Sheet en SystemSetting.""" + from app.models.setting import SystemSetting + from app.services.sheets_importer import normalize_sheets_url + + data = request.get_json(silent=True) or request.form.to_dict() or {} + url = (data.get('url') or data.get('google_sheets_url') or '').strip() + + if not url: + return jsonify({ + 'error': 'ValidationError', + 'message': 'La URL de Google Sheets no puede estar vacía.' + }), 400 + + normalized = normalize_sheets_url(url) + SystemSetting.set_value('google_sheets_url', url, 'URL del Google Sheet académico configurada desde panel') + + return jsonify({ + 'status': 'success', + 'message': 'Configuración de Google Sheets guardada correctamente.', + 'config': { + 'active_url': url, + 'normalized_url': normalized, + 'source': 'database' + } + }), 200 + +@api_admin_bp.route('/reset-academic-data', methods=['POST']) +@jwt_required +def reset_academic_data_api(): + """ + Herramienta de limpieza/purga de datos académicos para pruebas del importador. + Permite eliminar reservas, comisiones, asignaturas, carreras y aulas. + """ + from scripts.reset_academic_data import execute_academic_reset + data = request.get_json(silent=True) or request.form.to_dict() or {} + + reset_all = data.get('all', False) in [True, 'true', '1', 'on'] + reset_reservations = reset_all or data.get('reservations', True) in [True, 'true', '1', 'on'] + reset_commissions = reset_all or data.get('commissions', False) in [True, 'true', '1', 'on'] + reset_subjects = reset_all or data.get('subjects', False) in [True, 'true', '1', 'on'] + reset_careers = reset_all or data.get('careers', False) in [True, 'true', '1', 'on'] + reset_classrooms = reset_all or data.get('classrooms', False) in [True, 'true', '1', 'on'] + + if reset_all: + reset_reservations = True + reset_commissions = True + reset_subjects = True + reset_careers = True + reset_classrooms = True + + result = execute_academic_reset( + reset_reservations=reset_reservations, + reset_commissions=reset_commissions, + reset_subjects=reset_subjects, + reset_careers=reset_careers, + reset_classrooms=reset_classrooms + ) + + if result.get('success'): + try: + from app.models.audit_log import AuditLog + audit = AuditLog( + user_id=getattr(request, 'current_user_id', None), + action_type='ACADEMIC_DATA_PURGE', + details=f"Purga académica ejecutada: {result.get('deleted')}", + ip_address=request.remote_addr + ) + db.session.add(audit) + db.session.commit() + except Exception: + pass + + return jsonify({ + 'status': 'success', + 'message': result.get('message', 'Datos reiniciados con éxito.'), + 'deleted': result.get('deleted', {}) + }), 200 + else: + return jsonify({ + 'status': 'error', + 'message': result.get('error', 'Error durante la purga de datos.') + }), 500 + + diff --git a/backend/app/routes/api/dashboard.py b/backend/app/routes/api/dashboard.py index 6f22fb1..56fb40f 100644 --- a/backend/app/routes/api/dashboard.py +++ b/backend/app/routes/api/dashboard.py @@ -169,11 +169,14 @@ 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: - importer = GoogleSheetsImporter() + 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 + 'stats': stats, + 'source_url': importer.base_url }), 200 except Exception as e: return jsonify({ diff --git a/backend/app/services/sheets_importer.py b/backend/app/services/sheets_importer.py index a749455..c124b6e 100644 --- a/backend/app/services/sheets_importer.py +++ b/backend/app/services/sheets_importer.py @@ -2,6 +2,7 @@ import urllib.request import csv import io import re +import os from datetime import datetime, date, time, timedelta from app import db from app.models.classroom import Classroom @@ -12,6 +13,34 @@ from app.models.user import User BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv' +def normalize_sheets_url(url): + """ + Normaliza enlaces de Google Sheets (compartidos, de edición o publicados) + para obtener la URL base que acepta parámetro gid=&output=csv o format=csv. + """ + if not url: + return BASE_CSV_URL + url = url.strip() + + # 1. Enlace publicado web (/d/e/2PACX-.../pub...) + if '/d/e/' in url: + base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url) + if base_match: + return f"{base_match.group(1)}/pub?single=true&output=csv" + clean = url.split('&gid=')[0].split('?gid=')[0] + sep = '&' if '?' in clean else '?' + if 'output=csv' not in clean: + clean = f"{clean}{sep}single=true&output=csv" + return clean + + # 2. Enlace de edición o visualización (/d//edit...) + sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url) + if sheet_id_match: + sheet_id = sheet_id_match.group(1) + return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv" + + return url + SHEETS_CONFIG = [ { 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 }, { 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 }, @@ -30,16 +59,28 @@ class GoogleSheetsImporter: """ def __init__(self, base_url=None): - self.base_url = base_url or BASE_CSV_URL + if base_url: + self.base_url = normalize_sheets_url(base_url) + else: + configured_url = None + try: + from app.models.setting import SystemSetting + configured_url = SystemSetting.get_value('google_sheets_url') + except Exception: + pass + if not configured_url: + configured_url = os.getenv('GOOGLE_SHEETS_URL') + self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL def fetch_sheet_csv(self, gid): """Fetch CSV string for a specific sheet gid""" - url = f"{self.base_url}&gid={gid}" + separator = '&' if '?' in self.base_url else '?' + url = f"{self.base_url}{separator}gid={gid}" req = urllib.request.Request( url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'} ) - with urllib.request.urlopen(req, timeout=15) as resp: + with urllib.request.urlopen(req, timeout=25) as resp: return resp.read().decode('utf-8', errors='replace') def parse_sheet_rows(self, csv_content, sheet_name): diff --git a/backend/scripts/reset_academic_data.py b/backend/scripts/reset_academic_data.py new file mode 100644 index 0000000..e3a20b4 --- /dev/null +++ b/backend/scripts/reset_academic_data.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Herramienta de Limpieza y Reinicio de Datos Académicos para UniCABA / Edu-Space. + +Permite eliminar de forma segura y en el orden estricto de claves foráneas: +- Asignaciones de aulas / Reservas (reservations) +- Inscripciones de alumnos (student_enrollments) +- Comisiones de cursada (commissions) +- Asignaturas / Materias (subjects) +- Carreras académicas (careers) +- Recursos de aula y Aulas físicas / virtuales (classroom_resources, classrooms) + +PRESERVA INTOLERABLEMENTE: +- Usuarios del sistema (Users: admin, bedelia, docentes, alumnos) +- Roles y Permisos RBAC +- Edificios institucionales (Buildings) +- Registros de auditoría (AuditLogs) +""" + +import sys +import os +import argparse + +# Asegurar que el directorio backend está en el PYTHONPATH +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app import create_app, db +from app.models.reservation import Reservation +from app.models.enrollment import StudentEnrollment +from app.models.milestone import AcademicMilestone +from app.models.subject import Commission, Subject +from app.models.career import Career +from app.models.classroom import Classroom, ClassroomResource + +def execute_academic_reset( + reset_reservations=True, + reset_commissions=True, + reset_subjects=True, + reset_careers=True, + reset_classrooms=True +): + """ + Ejecuta el reinicio de las entidades académicas seleccionadas. + Retorna un diccionario con el conteo de registros eliminados. + """ + # Si se reinicia una entidad superior, automáticamente deben reiniciarse sus dependientes + if reset_careers: + reset_subjects = True + if reset_subjects: + reset_commissions = True + if reset_commissions or reset_classrooms: + reset_reservations = True + + deleted_counts = { + 'reservations': 0, + 'enrollments': 0, + 'commissions': 0, + 'subjects': 0, + 'careers': 0, + 'classrooms': 0, + 'classroom_resources': 0 + } + + try: + # 1. Eliminar Reservas (Asignaciones de aulas) + if reset_reservations: + deleted_counts['reservations'] = Reservation.query.delete() + + # 2. Desvincular hitos de materias o comisiones si se van a eliminar + if reset_subjects or reset_commissions: + AcademicMilestone.query.update({ + AcademicMilestone.subject_id: None, + AcademicMilestone.commission_id: None + }) + db.session.flush() + + # 3. Eliminar Inscripciones y Comisiones + if reset_commissions: + deleted_counts['enrollments'] = StudentEnrollment.query.delete() + deleted_counts['commissions'] = Commission.query.delete() + + # 4. Eliminar Materias / Asignaturas + if reset_subjects: + deleted_counts['subjects'] = Subject.query.delete() + + # 5. Eliminar Carreras + if reset_careers: + deleted_counts['careers'] = Career.query.delete() + + # 6. Eliminar Recursos y Aulas + if reset_classrooms: + deleted_counts['classroom_resources'] = ClassroomResource.query.delete() + deleted_counts['classrooms'] = Classroom.query.delete() + + db.session.commit() + return { + 'success': True, + 'deleted': deleted_counts, + 'message': 'Datos académicos reiniciados exitosamente.' + } + except Exception as e: + db.session.rollback() + return { + 'success': False, + 'error': str(e), + 'deleted': deleted_counts + } + +def main(): + parser = argparse.ArgumentParser(description="Limpieza y purga de datos académicos para pruebas del importador.") + parser.add_argument('--all', action='store_true', help="Eliminar todo: reservas, comisiones, asignaturas, carreras y aulas.") + parser.add_argument('--reservations', action='store_true', help="Eliminar únicamente las asignaciones de aulas.") + parser.add_argument('--classrooms', action='store_true', help="Eliminar aulas y sus asignaciones asociadas.") + parser.add_argument('--careers', action='store_true', help="Eliminar carreras, asignaturas, comisiones y asignaciones.") + parser.add_argument('--yes', '-y', action='store_true', help="Confirmar automáticamente sin preguntar.") + + args = parser.parse_args() + + # Si no se pasó ninguna opción específica, asumir --all si el usuario lo confirma + if not (args.all or args.reservations or args.classrooms or args.careers): + args.all = True + + app = create_app() + with app.app_context(): + # Mostrar estado actual de la BD + print("=" * 60) + print(" ESTADO ACTUAL DE ENTIDADES ACADÉMICAS EN BASE DE DATOS") + print("=" * 60) + print(f" - Asignaciones de Aulas (Reservas): {Reservation.query.count()}") + print(f" - Comisiones: {Commission.query.count()}") + print(f" - Asignaturas / Materias: {Subject.query.count()}") + print(f" - Carreras: {Career.query.count()}") + print(f" - Aulas (Físicas y Virtuales): {Classroom.query.count()}") + print("=" * 60) + + if not args.yes: + confirm = input("\n¿Está SEGURO de eliminar los datos seleccionados? Escriba 'CONFIRMAR': ") + if confirm.strip() != 'CONFIRMAR': + print("Operación cancelada.") + sys.exit(0) + + r_res = args.all or args.reservations or args.classrooms or args.careers + r_comm = args.all or args.careers + r_subj = args.all or args.careers + r_car = args.all or args.careers + r_cls = args.all or args.classrooms + + print("\nEjecutando limpieza de datos...") + res = execute_academic_reset( + reset_reservations=r_res, + reset_commissions=r_comm, + reset_subjects=r_subj, + reset_careers=r_car, + reset_classrooms=r_cls + ) + + if res['success']: + print("\n[OK] Limpieza completada con éxito:") + for k, v in res['deleted'].items(): + print(f" - {k}: {v} registros eliminados") + else: + print(f"\n[ERROR] Falló la limpieza: {res['error']}") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index 1551940..30bfd21 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -16,27 +16,57 @@ const MOCK_RECENT_SUBJECTS = [ // 1. Google Sheets Import const handleGoogleSheetsImport = async (req, res, justSynced = false) => { try { - let totalClassrooms = 31; - let totalReservations = 19; + let totalClassrooms = 0; + let totalReservations = 0; + let totalCareers = 0; + let totalSubjects = 0; + let totalCommissions = 0; + let recentSubjects = []; + let sheetsConfig = { + active_url: 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv', + default_url: 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv', + source: 'default' + }; try { - const classRes = await req.apiClient.get('/classrooms'); - if (classRes.data && classRes.data.classrooms) { - totalClassrooms = classRes.data.classrooms.length; + const [classRes, resRes, carRes, subjRes, commRes, cfgRes] = await Promise.allSettled([ + req.apiClient.get('/classrooms'), + req.apiClient.get('/reservations'), + req.apiClient.get('/careers'), + req.apiClient.get('/subjects'), + req.apiClient.get('/commissions'), + req.apiClient.get('/admin/sheets-config') + ]); + + if (classRes.status === 'fulfilled' && classRes.value.data?.classrooms) { + totalClassrooms = classRes.value.data.classrooms.length; } - const resRes = await req.apiClient.get('/reservations'); - if (resRes.data && resRes.data.reservations) { - totalReservations = resRes.data.reservations.length; + if (resRes.status === 'fulfilled' && resRes.value.data?.reservations) { + totalReservations = resRes.value.data.reservations.length; + } + if (carRes.status === 'fulfilled' && carRes.value.data?.careers) { + totalCareers = carRes.value.data.careers.length; + } + if (subjRes.status === 'fulfilled' && subjRes.value.data?.subjects) { + const subjs = subjRes.value.data.subjects; + totalSubjects = subjs.length; + recentSubjects = subjs.slice(0, 15); + } + if (commRes.status === 'fulfilled' && commRes.value.data?.commissions) { + totalCommissions = commRes.value.data.commissions.length; + } + if (cfgRes.status === 'fulfilled' && cfgRes.value.data?.config) { + sheetsConfig = cfgRes.value.data.config; } } catch (apiErr) { console.warn('Google Sheets import live stats fallback:', apiErr.message); } const metrics = { - total_careers: 5, - total_subjects: 18, + total_careers: totalCareers, + total_subjects: totalSubjects, total_classrooms: totalClassrooms, - total_commissions: 24, + total_commissions: totalCommissions, total_reservations: totalReservations, sheets: [ { name: 'LUNES' }, @@ -49,34 +79,53 @@ const handleGoogleSheetsImport = async (req, res, justSynced = false) => { }; let stats = null; + let syncError = null; if (justSynced) { try { - const syncRes = await req.apiClient.post('/dashboard/sync-sheets'); + const syncPayload = {}; + if (req.body && req.body.sheets_url) { + syncPayload.sheets_url = req.body.sheets_url; + } + const syncRes = await req.apiClient.post('/dashboard/sync-sheets', syncPayload, { timeout: 90000 }); if (syncRes.data && syncRes.data.stats) { stats = syncRes.data.stats; } + // Refresh counts after sync + try { + const [cR, rR, crR, sR, cmR] = await Promise.allSettled([ + req.apiClient.get('/classrooms'), + req.apiClient.get('/reservations'), + req.apiClient.get('/careers'), + req.apiClient.get('/subjects'), + req.apiClient.get('/commissions') + ]); + if (cR.status === 'fulfilled') metrics.total_classrooms = cR.value.data?.classrooms?.length || 0; + if (rR.status === 'fulfilled') metrics.total_reservations = rR.value.data?.reservations?.length || 0; + if (crR.status === 'fulfilled') metrics.total_careers = crR.value.data?.careers?.length || 0; + if (sR.status === 'fulfilled') { + metrics.total_subjects = sR.value.data?.subjects?.length || 0; + recentSubjects = (sR.value.data?.subjects || []).slice(0, 15); + } + if (cmR.status === 'fulfilled') metrics.total_commissions = cmR.value.data?.commissions?.length || 0; + } catch (e) {} } catch (err) { - console.warn('Backend sync-sheets API notice:', err.message); - stats = { - total_rows_processed: 239, - careers_created: 0, - classrooms_created: 0, - subjects_created: 0, - commissions_created: 0, - reservations_created: 0, - errors: [] - }; - } - if (stats && (!stats.errors || stats.errors.length === 0)) { - stats.errors = null; + console.error('Backend sync-sheets API error:', err.response?.data || err.message); + syncError = err.response?.data?.message || err.message; } } + const flashMsg = req.query.msg || null; + const flashType = req.query.type || 'info'; + res.render('admin/import', { title: 'Import from Google Sheets - Edu-Space Admin', metrics, stats, - recent_subjects: MOCK_RECENT_SUBJECTS + sync_error: syncError, + sheets_config: sheetsConfig, + recent_subjects: recentSubjects.length > 0 ? recentSubjects : MOCK_RECENT_SUBJECTS, + flash_msg: flashMsg, + flash_type: flashType }); } catch (error) { console.error('Error rendering google sheets import:', error.message); @@ -87,6 +136,45 @@ const handleGoogleSheetsImport = async (req, res, justSynced = false) => { router.get(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, false)); router.post(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, true)); +// Guardar configuración del enlace de Google Sheets +router.post('/google_sheets_config', async (req, res) => { + try { + const url = (req.body.google_sheets_url || req.body.url || '').trim(); + if (!url) { + return res.redirect('/admin/google_sheets_import?msg=La+URL+no+puede+estar+vac%C3%ADa&type=danger'); + } + await req.apiClient.post('/admin/sheets-config', { url }); + res.redirect('/admin/google_sheets_import?msg=Configuraci%C3%B3n+de+Google+Sheets+actualizada+correctamente&type=success'); + } catch (err) { + console.error('Error updating sheets config:', err.message); + const errMsg = encodeURIComponent(err.response?.data?.message || 'Error al guardar la configuración'); + res.redirect(`/admin/google_sheets_import?msg=${errMsg}&type=danger`); + } +}); + +// Purga / Reinicio de datos académicos +router.post('/reset_academic_data', async (req, res) => { + try { + const payload = { + all: req.body.reset_all === 'true' || req.body.reset_all === 'on', + reservations: req.body.reset_reservations === 'true' || req.body.reset_reservations === 'on', + commissions: req.body.reset_commissions === 'true' || req.body.reset_commissions === 'on', + subjects: req.body.reset_subjects === 'true' || req.body.reset_subjects === 'on', + careers: req.body.reset_careers === 'true' || req.body.reset_careers === 'on', + classrooms: req.body.reset_classrooms === 'true' || req.body.reset_classrooms === 'on' + }; + + const purgeRes = await req.apiClient.post('/admin/reset-academic-data', payload); + const del = purgeRes.data?.deleted || {}; + const summary = encodeURIComponent(`Datos reiniciados: ${del.reservations || 0} reservas, ${del.commissions || 0} comisiones, ${del.subjects || 0} materias, ${del.careers || 0} carreras, ${del.classrooms || 0} aulas.`); + res.redirect(`/admin/google_sheets_import?msg=${summary}&type=warning`); + } catch (err) { + console.error('Error resetting academic data:', err.message); + const errMsg = encodeURIComponent(err.response?.data?.message || 'Error al purgar los datos'); + res.redirect(`/admin/google_sheets_import?msg=${errMsg}&type=danger`); + } +}); + // 2. Occupancy Metrics const handleOccupancyMetrics = async (req, res) => { try { diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js index 7b8c5a3..4d6bb36 100644 --- a/frontend/src/services/apiClient.js +++ b/frontend/src/services/apiClient.js @@ -3,7 +3,7 @@ const axios = require('axios'); // Configuración base de Axios conectando hacia Flask const apiClient = axios.create({ baseURL: process.env.FLASK_API_URL || 'http://localhost:5000/api/v1', - timeout: 5000, + timeout: 25000, headers: { 'Content-Type': 'application/json' } diff --git a/frontend/views/admin/import.html b/frontend/views/admin/import.html index 9e32f09..2fa93fc 100644 --- a/frontend/views/admin/import.html +++ b/frontend/views/admin/import.html @@ -1,63 +1,139 @@ {% extends "base.html" %} -{% block title %}Import from Google Sheets - Edu-Space Admin{% endblock %} +{% block title %}Sincronización Google Sheets y Purga Académica - Edu-Space Admin{% endblock %} {% block content %}
-
+

- Google Sheets Academic Sync + Google Sheets Academic Sync & Gestión de Datos

-

Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.

+

Extrae, normaliza y sincroniza carreras, aulas, comisiones y cronogramas de cursada desde Google Sheets con soporte de configuración dinámica y reinicio de entorno.

- + + {% if flash_msg %} + + {% endif %} + + {% if sync_error %} + + {% endif %} +
-
+
-
{{ metrics.total_careers }}
- Careers +
{{ metrics.total_careers }}
+ Carreras
-
+
-
{{ metrics.total_subjects }}
- Subjects +
{{ metrics.total_subjects }}
+ Asignaturas
-
+
-
{{ metrics.total_classrooms }}
- Classrooms +
{{ metrics.total_classrooms }}
+ Aulas (Físicas/Virt.)
-
-
-
{{ metrics.total_commissions }}
- Commissions +
+
+
{{ metrics.total_commissions }}
+ Comisiones
-
+
-
{{ metrics.total_reservations }}
- Calendar Classes +
{{ metrics.total_reservations }}
+ Asignaciones en Calendario
+ +
+
+
+ Configuración Global del Google Sheet +
+ + Origen: {{ 'Base de Datos (Personalizado)' if sheets_config.source == 'database' else ('Variables de Entorno' if sheets_config.source == 'environment' else 'Predeterminado') }} + +
+
+
+
+ +
+ + + +
+
+ + Acepta enlaces web publicados (/pub?output=csv) o enlaces estándar de Google Sheets (/spreadsheets/d/ID/edit). El sistema los normaliza automáticamente para consultar cada pestaña de día (Lunes a Sábado). +
+
+
+ + + Abrir Enlace Actual en Google Docs + +
+
+
+
+
@@ -65,13 +141,13 @@
- Google Sheets Live Integration + Sincronización Bidireccional UniCABA 2026
-

Official Schedule Board (Cartelera de Aulas)

+

Cartelera Académica Oficial (Lunes a Sábado)

- Reads published CSV sheets for all 6 days of the week, automatically creating or updating academic careers, classrooms, subject codes, commissions, and weekly calendar entries without duplicating records. + Descarga las 6 hojas de cálculo semanales, creando o actualizando en lote las Carreras, Aulas (físicas y virtuales), Códigos de Materia, Comisiones de Cursada y las Clases semanales en el calendario sin duplicar registros.

{% for s in metrics.sheets %} @@ -82,14 +158,15 @@
-
-
- Connecting to Google Sheets and processing records... + Conectando a Google Sheets y procesando registros...
@@ -98,55 +175,56 @@ {% if stats %} -
-
+
+
- Synchronization Report + Informe Detallado de Sincronización
+ Completado
-
-
{{ stats.total_rows_processed }}
- Rows Processed +
+
{{ stats.total_rows_processed }}
+ Filas Procesadas
-
-
+{{ stats.careers_created }}
- Careers Created +
+
+{{ stats.careers_created }}
+ Carreras Creadas
-
-
+{{ stats.classrooms_created }}
- Classrooms Created +
+
+{{ stats.classrooms_created }}
+ Aulas Creadas
-
-
+{{ stats.subjects_created }}
- Subjects Created +
+
+{{ stats.subjects_created }}
+ Materias Creadas
-
-
+{{ stats.commissions_created }}
- Commissions Created +
+
+{{ stats.commissions_created }}
+ Comisiones Creadas
-
-
+{{ stats.reservations_created }}
- Reservations Created +
+
+{{ stats.reservations_created }}
+ Clases / Reservas
{% if stats.errors and (stats.errors|length > 0) %}
-
Warnings / Errors detected:
+
Advertencias o incidencias detectadas:
    {% for err in stats.errors %}
  • {{ err }}
  • @@ -158,25 +236,109 @@
{% endif %} + +
+
+
+
+ Herramienta de Reinicio y Purga Académica (Testing & Reset) +
+ Permite eliminar de forma limpia carreras, aulas y asignaciones para realizar pruebas completas del importador desde cero. +
+ Zona de Peligro +
+
+

+ Seleccione las entidades que desea eliminar de la base de datos antes de re-importar. Las restricciones de clave foránea se limpian automáticamente en el orden estricto. + Nota de seguridad: Los usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría NUNCA se eliminan. +

+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ -
+
-
- Recently Synchronized Subjects & Comissions +
+ Muestra de Asignaturas en Base de Datos (Primeras 15)
- - View Calendar - + + Total: {{ metrics.total_subjects }} asignaturas +
- - - - - + + + + + @@ -191,22 +353,22 @@
{{ s.name }}
@@ -214,7 +376,7 @@ {% endfor %} @@ -223,6 +385,41 @@ + + + {% endblock %} {% block extra_js %} @@ -230,6 +427,13 @@ function startSync() { const btn = document.getElementById('syncBtn'); const spinner = document.getElementById('syncSpinner'); + const urlInput = document.getElementById('google_sheets_url'); + const syncUrlInput = document.getElementById('syncSheetsUrl'); + + if (urlInput && syncUrlInput) { + syncUrlInput.value = urlInput.value.trim(); + } + if (btn) { btn.disabled = true; btn.innerHTML = 'Sincronizando...'; @@ -238,5 +442,46 @@ function startSync() { spinner.classList.remove('d-none'); } } + +function restoreDefaultUrl() { + const defaultUrl = "{{ sheets_config.default_url }}"; + const input = document.getElementById('google_sheets_url'); + if (input && defaultUrl) { + input.value = defaultUrl; + } +} + +function toggleSelectAll(checked) { + document.getElementById('chk_reservations').checked = checked; + document.getElementById('chk_classrooms').checked = checked; + document.getElementById('chk_commissions').checked = checked; + document.getElementById('chk_subjects').checked = checked; + document.getElementById('chk_careers').checked = checked; +} + +function openPurgeModal() { + const anyChecked = document.getElementById('chk_reservations').checked || + document.getElementById('chk_classrooms').checked || + document.getElementById('chk_commissions').checked || + document.getElementById('chk_subjects').checked || + document.getElementById('chk_careers').checked || + document.getElementById('chk_all').checked; + if (!anyChecked) { + alert('Por favor seleccione al menos una entidad para purgar.'); + return; + } + const modalEl = document.getElementById('purgeConfirmModal'); + if (modalEl) { + const modal = new bootstrap.Modal(modalEl); + modal.show(); + } +} + +function confirmPurge() { + const form = document.getElementById('purgeForm'); + if (form) { + form.submit(); + } +} {% endblock %}
CodeSubject NameCareerCommissionsStatusCódigoNombre de la AsignaturaCarrera / DepartamentoComisionesEstado
- - {{ s.career_obj.name if s.career_obj else (s.department or _('General')) }} + + {{ s.career_obj.name if s.career_obj else (s.career_name or s.department or 'General') }} - {{ s.commissions|length }} commission(s) + {{ s.commissions_count if s.commissions_count is defined else (s.commissions|length if s.commissions else 0) }} comisión(es) {% if s.is_active %} - Active + Activa {% else %} - Inactive + Inactiva {% endif %}
- No subjects in database yet. Click "Sync Data Now" to import. + No hay materias en la base de datos actualmente. Haga clic en "Sincronizar Datos Ahora" para importar.