feat(sync): add academic data reset tool, configurable sheets URL and fix sync timeout

This commit is contained in:
2026-09-19 11:13:24 -03:00
parent 42b31ec44e
commit 5087e22988
10 changed files with 835 additions and 101 deletions
+23
View File
@@ -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 ## [2.1.0] - 2026-09-19
### Añadido ### Añadido
- **Ficha Detallada de Reserva (`schedule.view_reservation`):** - **Ficha Detallada de Reserva (`schedule.view_reservation`):**
+3 -1
View File
@@ -10,6 +10,7 @@ from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from .audit_log import AuditLog from .audit_log import AuditLog
from .enrollment import StudentEnrollment from .enrollment import StudentEnrollment
from .setting import SystemSetting
__all__ = [ __all__ = [
'User', 'User',
@@ -31,5 +32,6 @@ __all__ = [
'AcademicMilestone', 'AcademicMilestone',
'BASE_MILESTONE_TYPES', 'BASE_MILESTONE_TYPES',
'AuditLog', 'AuditLog',
'StudentEnrollment' 'StudentEnrollment',
'SystemSetting'
] ]
+50
View File
@@ -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'<SystemSetting {self.key}={self.value}>'
+115
View File
@@ -981,3 +981,118 @@ def delete_milestone_type(id):
'message': 'Tipificación eliminada permanentemente.' 'message': 'Tipificación eliminada permanentemente.'
}), 200 }), 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
+5 -2
View File
@@ -169,11 +169,14 @@ def sync_google_sheets():
"""Ejecuta la sincronización real con las hojas de cálculo de Google Sheets.""" """Ejecuta la sincronización real con las hojas de cálculo de Google Sheets."""
from app.services.sheets_importer import GoogleSheetsImporter from app.services.sheets_importer import GoogleSheetsImporter
try: 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() stats = importer.sync()
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'stats': stats 'stats': stats,
'source_url': importer.base_url
}), 200 }), 200
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
+44 -3
View File
@@ -2,6 +2,7 @@ import urllib.request
import csv import csv
import io import io
import re import re
import os
from datetime import datetime, date, time, timedelta from datetime import datetime, date, time, timedelta
from app import db from app import db
from app.models.classroom import Classroom 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' 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=<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/<SPREADSHEET_ID>/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 = [ SHEETS_CONFIG = [
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 }, { 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 }, { 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
@@ -30,16 +59,28 @@ class GoogleSheetsImporter:
""" """
def __init__(self, base_url=None): 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): def fetch_sheet_csv(self, gid):
"""Fetch CSV string for a specific sheet 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( req = urllib.request.Request(
url, url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'} 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') return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name): def parse_sheet_rows(self, csv_content, sheet_name):
+167
View File
@@ -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()
+114 -26
View File
@@ -16,27 +16,57 @@ const MOCK_RECENT_SUBJECTS = [
// 1. Google Sheets Import // 1. Google Sheets Import
const handleGoogleSheetsImport = async (req, res, justSynced = false) => { const handleGoogleSheetsImport = async (req, res, justSynced = false) => {
try { try {
let totalClassrooms = 31; let totalClassrooms = 0;
let totalReservations = 19; 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 { try {
const classRes = await req.apiClient.get('/classrooms'); const [classRes, resRes, carRes, subjRes, commRes, cfgRes] = await Promise.allSettled([
if (classRes.data && classRes.data.classrooms) { req.apiClient.get('/classrooms'),
totalClassrooms = classRes.data.classrooms.length; 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.status === 'fulfilled' && resRes.value.data?.reservations) {
if (resRes.data && resRes.data.reservations) { totalReservations = resRes.value.data.reservations.length;
totalReservations = resRes.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) { } catch (apiErr) {
console.warn('Google Sheets import live stats fallback:', apiErr.message); console.warn('Google Sheets import live stats fallback:', apiErr.message);
} }
const metrics = { const metrics = {
total_careers: 5, total_careers: totalCareers,
total_subjects: 18, total_subjects: totalSubjects,
total_classrooms: totalClassrooms, total_classrooms: totalClassrooms,
total_commissions: 24, total_commissions: totalCommissions,
total_reservations: totalReservations, total_reservations: totalReservations,
sheets: [ sheets: [
{ name: 'LUNES' }, { name: 'LUNES' },
@@ -49,34 +79,53 @@ const handleGoogleSheetsImport = async (req, res, justSynced = false) => {
}; };
let stats = null; let stats = null;
let syncError = null;
if (justSynced) { if (justSynced) {
try { 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) { if (syncRes.data && syncRes.data.stats) {
stats = 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) { } catch (err) {
console.warn('Backend sync-sheets API notice:', err.message); console.error('Backend sync-sheets API error:', err.response?.data || err.message);
stats = { syncError = err.response?.data?.message || err.message;
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;
} }
} }
const flashMsg = req.query.msg || null;
const flashType = req.query.type || 'info';
res.render('admin/import', { res.render('admin/import', {
title: 'Import from Google Sheets - Edu-Space Admin', title: 'Import from Google Sheets - Edu-Space Admin',
metrics, metrics,
stats, 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) { } catch (error) {
console.error('Error rendering google sheets import:', error.message); 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.get(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, false));
router.post(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, true)); 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 // 2. Occupancy Metrics
const handleOccupancyMetrics = async (req, res) => { const handleOccupancyMetrics = async (req, res) => {
try { try {
+1 -1
View File
@@ -3,7 +3,7 @@ const axios = require('axios');
// Configuración base de Axios conectando hacia Flask // Configuración base de Axios conectando hacia Flask
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: process.env.FLASK_API_URL || 'http://localhost:5000/api/v1', baseURL: process.env.FLASK_API_URL || 'http://localhost:5000/api/v1',
timeout: 5000, timeout: 25000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
+315 -70
View File
@@ -1,63 +1,139 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<div class="container-fluid pt-5 mt-4"> <div class="container-fluid pt-5 mt-4">
<!-- Header --> <!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<div> <div>
<h1 class="h3 mb-1"> <h1 class="h3 mb-1">
<i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>Google Sheets Academic Sync <i class="bi bi-file-earmark-spreadsheet text-success me-2"></i>Google Sheets Academic Sync & Gestión de Datos
</h1> </h1>
<p class="text-muted small mb-0">Extract and synchronize classrooms, careers, subjects, and weekly schedules directly from the official Google Sheet.</p> <p class="text-muted small mb-0">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.</p>
</div> </div>
<div> <div class="d-flex gap-2">
<a href="/main/dashboard" class="btn btn-outline-secondary"> <a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar3 me-1"></i>Ver Calendario
</a>
<a href="/main/dashboard" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Dashboard <i class="bi bi-arrow-left me-1"></i>Dashboard
</a> </a>
</div> </div>
</div> </div>
<!-- Feedback Alerts -->
{% if flash_msg %}
<div class="alert alert-{{ flash_type }} alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
<div class="d-flex align-items-center">
{% if flash_type == 'success' %}
<i class="bi bi-check-circle-fill fs-5 me-2 text-success"></i>
{% elif flash_type == 'danger' %}
<i class="bi bi-exclamation-triangle-fill fs-5 me-2 text-danger"></i>
{% elif flash_type == 'warning' %}
<i class="bi bi-exclamation-circle-fill fs-5 me-2 text-warning"></i>
{% else %}
<i class="bi bi-info-circle-fill fs-5 me-2 text-info"></i>
{% endif %}
<div>{{ flash_msg }}</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if sync_error %}
<div class="alert alert-danger border-0 shadow-sm mb-4" role="alert">
<div class="d-flex align-items-start">
<i class="bi bi-x-octagon-fill fs-4 me-3 text-danger flex-shrink-0"></i>
<div>
<h6 class="alert-heading fw-bold mb-1">Error durante la sincronización:</h6>
<p class="small mb-0">{{ sync_error }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Metrics Cards --> <!-- Metrics Cards -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-sm-6 col-xl-2"> <div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100"> <div class="card border-0 shadow-sm text-center p-3 h-100 bg-body">
<div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div> <div class="text-primary mb-1"><i class="bi bi-mortarboard fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div> <div class="h4 mb-0 fw-bold" id="metric-careers">{{ metrics.total_careers }}</div>
<small class="text-muted">Careers</small> <small class="text-muted">Carreras</small>
</div> </div>
</div> </div>
<div class="col-sm-6 col-xl-2"> <div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100"> <div class="card border-0 shadow-sm text-center p-3 h-100 bg-body">
<div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div> <div class="text-info mb-1"><i class="bi bi-book fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div> <div class="h4 mb-0 fw-bold" id="metric-subjects">{{ metrics.total_subjects }}</div>
<small class="text-muted">Subjects</small> <small class="text-muted">Asignaturas</small>
</div> </div>
</div> </div>
<div class="col-sm-6 col-xl-2"> <div class="col-sm-6 col-xl-2">
<div class="card border-0 shadow-sm text-center p-3 h-100"> <div class="card border-0 shadow-sm text-center p-3 h-100 bg-body">
<div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div> <div class="text-success mb-1"><i class="bi bi-door-open fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div> <div class="h4 mb-0 fw-bold" id="metric-classrooms">{{ metrics.total_classrooms }}</div>
<small class="text-muted">Classrooms</small> <small class="text-muted">Aulas (Físicas/Virt.)</small>
</div> </div>
</div> </div>
<div class="col-sm-6 col-xl-3"> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100"> <div class="card border-0 shadow-sm text-center p-3 h-100 bg-body">
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div> <div class="text-warning mb-1"><i class="bi bi-diagram-3 fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div> <div class="h4 mb-0 fw-bold" id="metric-commissions">{{ metrics.total_commissions }}</div>
<small class="text-muted">Commissions</small> <small class="text-muted">Comisiones</small>
</div> </div>
</div> </div>
<div class="col-sm-6 col-xl-3"> <div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100"> <div class="card border-0 shadow-sm text-center p-3 h-100 bg-body">
<div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div> <div class="text-danger mb-1"><i class="bi bi-calendar-check fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div> <div class="h4 mb-0 fw-bold" id="metric-reservations">{{ metrics.total_reservations }}</div>
<small class="text-muted">Calendar Classes</small> <small class="text-muted">Asignaciones en Calendario</small>
</div> </div>
</div> </div>
</div> </div>
<!-- Google Sheets URL Configuration Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
<h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-gear-wide-connected text-primary me-2"></i>Configuración Global del Google Sheet
</h5>
<span class="badge {% if sheets_config.source == 'database' %}bg-success-subtle text-success border border-success-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %}">
Origen: {{ 'Base de Datos (Personalizado)' if sheets_config.source == 'database' else ('Variables de Entorno' if sheets_config.source == 'environment' else 'Predeterminado') }}
</span>
</div>
<div class="card-body p-4">
<form method="POST" action="/admin/google_sheets_config" id="configForm">
<div class="mb-3">
<label for="google_sheets_url" class="form-label fw-semibold">
URL o Enlace Público de la Hoja de Cálculo (Google Sheets):
</label>
<div class="input-group">
<span class="input-group-text bg-body-secondary"><i class="bi bi-link-45deg"></i></span>
<input type="text" class="form-control font-monospace small" id="google_sheets_url" name="google_sheets_url"
value="{{ sheets_config.active_url }}" required
placeholder="https://docs.google.com/spreadsheets/d/.../pub?output=csv o enlace compartido">
<button type="submit" class="btn btn-primary px-3">
<i class="bi bi-floppy me-1"></i>Guardar Enlace
</button>
</div>
<div class="form-text mt-2">
<i class="bi bi-info-circle me-1"></i>
Acepta enlaces web publicados (<code>/pub?output=csv</code>) o enlaces estándar de Google Sheets (<code>/spreadsheets/d/ID/edit</code>). El sistema los normaliza automáticamente para consultar cada pestaña de día (Lunes a Sábado).
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="restoreDefaultUrl()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar URL Oficial UniCABA
</button>
<a href="{{ sheets_config.active_url }}" target="_blank" rel="noopener noreferrer" class="small text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>Abrir Enlace Actual en Google Docs
</a>
</div>
</form>
</div>
</div>
<!-- Sync Action Banner --> <!-- Sync Action Banner -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
@@ -65,13 +141,13 @@
<div class="col-lg-8 mb-3 mb-lg-0"> <div class="col-lg-8 mb-3 mb-lg-0">
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold"> <span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-1 me-2 fw-semibold">
<i class="bi bi-cloud-check me-1"></i>Google Sheets Live Integration <i class="bi bi-cloud-check me-1"></i>Sincronización Bidireccional
</span> </span>
<span class="badge bg-light text-muted border">UniCABA 2026</span> <span class="badge bg-light text-muted border">UniCABA 2026</span>
</div> </div>
<h4 class="h5 mb-2 fw-bold">Official Schedule Board (Cartelera de Aulas)</h4> <h4 class="h5 mb-2 fw-bold">Cartelera Académica Oficial (Lunes a Sábado)</h4>
<p class="text-muted small mb-2"> <p class="text-muted small mb-2">
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.
</p> </p>
<div class="d-flex flex-wrap gap-1"> <div class="d-flex flex-wrap gap-1">
{% for s in metrics.sheets %} {% for s in metrics.sheets %}
@@ -82,14 +158,15 @@
</div> </div>
</div> </div>
<div class="col-lg-4 text-lg-end"> <div class="col-lg-4 text-lg-end">
<form method="POST" id="syncForm" onsubmit="startSync()"> <form method="POST" id="syncForm" action="/admin/google_sheets_import" onsubmit="startSync()">
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold"> <input type="hidden" name="sheets_url" id="syncSheetsUrl" value="{{ sheets_config.active_url }}">
<i class="bi bi-arrow-repeat me-2"></i>Sync Data Now <button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow-sm fw-semibold">
<i class="bi bi-arrow-repeat me-2"></i>Sincronizar Datos Ahora
</button> </button>
</form> </form>
<div id="syncSpinner" class="d-none mt-2 text-muted small"> <div id="syncSpinner" class="d-none mt-2 text-muted small">
<div class="spinner-border spinner-border-sm text-success me-1" role="status"></div> <div class="spinner-border spinner-border-sm text-success me-1" role="status"></div>
Connecting to Google Sheets and processing records... Conectando a Google Sheets y procesando registros...
</div> </div>
</div> </div>
</div> </div>
@@ -98,55 +175,56 @@
<!-- Sync Results (if just completed) --> <!-- Sync Results (if just completed) -->
{% if stats %} {% if stats %}
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4 border-start border-success border-4">
<div class="card-header bg-success bg-opacity-10 text-success py-3"> <div class="card-header bg-success bg-opacity-10 text-success py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0 fw-bold"> <h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-check-circle-fill me-2"></i>Synchronization Report <i class="bi bi-check-circle-fill me-2"></i>Informe Detallado de Sincronización
</h5> </h5>
<span class="badge bg-success text-white">Completado</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3 text-center mb-3"> <div class="row g-3 text-center mb-3">
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div> <div class="fw-bold fs-4 text-secondary">{{ stats.total_rows_processed }}</div>
<small class="text-muted">Rows Processed</small> <small class="text-muted fw-semibold">Filas Procesadas</small>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div> <div class="fw-bold fs-4 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted">Careers Created</small> <small class="text-muted fw-semibold">Carreras Creadas</small>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div> <div class="fw-bold fs-4 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted">Classrooms Created</small> <small class="text-muted fw-semibold">Aulas Creadas</small>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div> <div class="fw-bold fs-4 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted">Subjects Created</small> <small class="text-muted fw-semibold">Materias Creadas</small>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div> <div class="fw-bold fs-4 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted">Commissions Created</small> <small class="text-muted fw-semibold">Comisiones Creadas</small>
</div> </div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary"> <div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div> <div class="fw-bold fs-4 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted">Reservations Created</small> <small class="text-muted fw-semibold">Clases / Reservas</small>
</div> </div>
</div> </div>
</div> </div>
{% if stats.errors and (stats.errors|length > 0) %} {% if stats.errors and (stats.errors|length > 0) %}
<div class="alert alert-warning mb-0 small"> <div class="alert alert-warning mb-0 small">
<h6 class="alert-heading fw-bold mb-1">Warnings / Errors detected:</h6> <h6 class="alert-heading fw-bold mb-1"><i class="bi bi-exclamation-triangle me-1"></i>Advertencias o incidencias detectadas:</h6>
<ul class="mb-0 ps-3"> <ul class="mb-0 ps-3">
{% for err in stats.errors %} {% for err in stats.errors %}
<li>{{ err }}</li> <li>{{ err }}</li>
@@ -158,25 +236,109 @@
</div> </div>
{% endif %} {% endif %}
<!-- Database Preview Table --> <!-- Academic Data Purge / Reset Tool (Zona de Mantenimiento) -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm mb-4 border-start border-danger border-4">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3"> <div class="card-header bg-danger bg-opacity-10 text-danger py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"> <div>
<i class="bi bi-table text-primary me-2"></i>Recently Synchronized Subjects & Comissions <h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-trash3 text-danger me-2"></i>Herramienta de Reinicio y Purga Académica (Testing & Reset)
</h5> </h5>
<a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm"> <small class="text-muted">Permite eliminar de forma limpia carreras, aulas y asignaciones para realizar pruebas completas del importador desde cero.</small>
<i class="bi bi-calendar3 me-1"></i>View Calendar </div>
</a> <span class="badge bg-danger text-white">Zona de Peligro</span>
</div>
<div class="card-body p-4">
<p class="small text-muted mb-3">
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.
<strong class="text-body">Nota de seguridad:</strong> Los usuarios del sistema, credenciales de acceso, roles RBAC y registros de auditoría <u>NUNCA</u> se eliminan.
</p>
<form id="purgeForm" method="POST" action="/admin/reset_academic_data">
<div class="row g-3 mb-3">
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded bg-body-tertiary">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_reservations" id="chk_reservations" value="true" checked>
<label class="form-check-label fw-semibold" for="chk_reservations">
Asignaciones de Aulas (Reservas)
<small class="d-block text-muted fw-normal">Elimina las clases cargadas en el calendario.</small>
</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded bg-body-tertiary">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_classrooms" id="chk_classrooms" value="true">
<label class="form-check-label fw-semibold" for="chk_classrooms">
Aulas Físicas y Virtuales
<small class="d-block text-muted fw-normal">Elimina aulas y espacios áulicos creados.</small>
</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded bg-body-tertiary">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_commissions" id="chk_commissions" value="true">
<label class="form-check-label fw-semibold" for="chk_commissions">
Comisiones de Cursada
<small class="d-block text-muted fw-normal">Elimina turnos y comisiones de asignaturas.</small>
</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded bg-body-tertiary">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_subjects" id="chk_subjects" value="true">
<label class="form-check-label fw-semibold" for="chk_subjects">
Asignaturas / Materias
<small class="d-block text-muted fw-normal">Elimina el catálogo de materias importadas.</small>
</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded bg-body-tertiary">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_careers" id="chk_careers" value="true">
<label class="form-check-label fw-semibold" for="chk_careers">
Carreras Académicas
<small class="d-block text-muted fw-normal">Elimina las carreras (Tecnicaturas, Licenciaturas, etc).</small>
</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check p-2 border rounded border-danger bg-danger bg-opacity-10">
<input class="form-check-input ms-1 me-2" type="checkbox" name="reset_all" id="chk_all" value="true" onchange="toggleSelectAll(this.checked)">
<label class="form-check-label fw-bold text-danger" for="chk_all">
Reiniciar Todo el Esquema
<small class="d-block text-danger fw-normal">Marca automáticamente todas las entidades.</small>
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-outline-danger px-4" onclick="openPurgeModal()">
<i class="bi bi-exclamation-triangle me-2"></i>Ejecutar Purga de Datos Seleccionados
</button>
</div>
</form>
</div>
</div>
<!-- Database Preview Table -->
<div class="card border-0 shadow-sm mb-5">
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
<h5 class="card-title h6 mb-0 fw-bold">
<i class="bi bi-table text-primary me-2"></i>Muestra de Asignaturas en Base de Datos (Primeras 15)
</h5>
<span class="badge bg-secondary-subtle text-secondary">
Total: {{ metrics.total_subjects }} asignaturas
</span>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary"> <thead class="bg-body-tertiary">
<tr> <tr>
<th class="ps-4">Code</th> <th class="ps-4">Código</th>
<th>Subject Name</th> <th>Nombre de la Asignatura</th>
<th>Career</th> <th>Carrera / Departamento</th>
<th>Commissions</th> <th>Comisiones</th>
<th>Status</th> <th>Estado</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -191,22 +353,22 @@
<div class="fw-semibold">{{ s.name }}</div> <div class="fw-semibold">{{ s.name }}</div>
</td> </td>
<td> <td>
<span class="badge badge-brand small"> <span class="badge bg-primary-subtle text-primary border border-primary-subtle small">
{{ 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') }}
</span> </span>
</td> </td>
<td> <td>
<span class="small text-muted"> <span class="small text-muted">
{{ 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)
</span> </span>
</td> </td>
<td> <td>
{% if s.is_active %} {% if s.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle small"> <span class="badge bg-success-subtle text-success border border-success-subtle small">
<i class="bi bi-check-circle me-1"></i>Active <i class="bi bi-check-circle me-1"></i>Activa
</span> </span>
{% else %} {% else %}
<span class="badge bg-secondary small">Inactive</span> <span class="badge bg-secondary small">Inactiva</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -214,7 +376,7 @@
<tr> <tr>
<td colspan="5" class="text-center py-5 text-muted"> <td colspan="5" class="text-center py-5 text-muted">
<i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i> <i class="bi bi-cloud-arrow-down fs-1 d-block mb-2 text-secondary"></i>
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.
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -223,6 +385,41 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Modal de Confirmación de Purga de Datos -->
<div class="modal fade" id="purgeConfirmModal" tabindex="-1" aria-labelledby="purgeModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title h6 mb-0" id="purgeModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirmar Eliminación de Datos Académicos
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="text-center mb-3">
<i class="bi bi-trash3 text-danger display-4"></i>
</div>
<p class="mb-2 text-center fw-semibold">
¿Está seguro de que desea eliminar los datos académicos seleccionados?
</p>
<p class="small text-muted text-center mb-3">
Esta acción eliminará las asignaciones de aulas, comisiones, asignaturas, carreras o aulas marcadas para permitir probar el importador limpio. Los usuarios del sistema y roles no se eliminarán.
</p>
<div class="alert alert-warning small mb-0">
<i class="bi bi-shield-exclamation me-1"></i>
Para confirmar, haga clic en el botón rojo a continuación.
</div>
</div>
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-danger fw-semibold" onclick="confirmPurge()">
<i class="bi bi-trash3 me-1"></i>Sí, Eliminar Datos
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@@ -230,6 +427,13 @@
function startSync() { function startSync() {
const btn = document.getElementById('syncBtn'); const btn = document.getElementById('syncBtn');
const spinner = document.getElementById('syncSpinner'); 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) { if (btn) {
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
@@ -238,5 +442,46 @@ function startSync() {
spinner.classList.remove('d-none'); 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();
}
}
</script> </script>
{% endblock %} {% endblock %}