feat(sync): add academic data reset tool, configurable sheets URL and fix sync timeout
This commit is contained in:
@@ -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`):**
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
@@ -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}>'
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
<!-- Academic Data Purge / Reset Tool (Zona de Mantenimiento) -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4 border-start border-danger border-4">
|
||||||
|
<div class="card-header bg-danger bg-opacity-10 text-danger py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<small class="text-muted">Permite eliminar de forma limpia carreras, aulas y asignaciones para realizar pruebas completas del importador desde cero.</small>
|
||||||
|
</div>
|
||||||
|
<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 -->
|
<!-- Database Preview Table -->
|
||||||
<div class="card border-0 shadow-sm">
|
<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">
|
<div class="card-header bg-body-tertiary d-flex justify-content-between align-items-center py-3">
|
||||||
<h5 class="card-title h6 mb-0">
|
<h5 class="card-title h6 mb-0 fw-bold">
|
||||||
<i class="bi bi-table text-primary me-2"></i>Recently Synchronized Subjects & Comissions
|
<i class="bi bi-table text-primary me-2"></i>Muestra de Asignaturas en Base de Datos (Primeras 15)
|
||||||
</h5>
|
</h5>
|
||||||
<a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm">
|
<span class="badge bg-secondary-subtle text-secondary">
|
||||||
<i class="bi bi-calendar3 me-1"></i>View Calendar
|
Total: {{ metrics.total_subjects }} asignaturas
|
||||||
</a>
|
</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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user