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
+114 -26
View File
@@ -16,27 +16,57 @@ const MOCK_RECENT_SUBJECTS = [
// 1. Google Sheets Import
const handleGoogleSheetsImport = async (req, res, justSynced = false) => {
try {
let totalClassrooms = 31;
let totalReservations = 19;
let totalClassrooms = 0;
let totalReservations = 0;
let totalCareers = 0;
let totalSubjects = 0;
let totalCommissions = 0;
let recentSubjects = [];
let sheetsConfig = {
active_url: 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv',
default_url: 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv',
source: 'default'
};
try {
const classRes = await req.apiClient.get('/classrooms');
if (classRes.data && classRes.data.classrooms) {
totalClassrooms = classRes.data.classrooms.length;
const [classRes, resRes, carRes, subjRes, commRes, cfgRes] = await Promise.allSettled([
req.apiClient.get('/classrooms'),
req.apiClient.get('/reservations'),
req.apiClient.get('/careers'),
req.apiClient.get('/subjects'),
req.apiClient.get('/commissions'),
req.apiClient.get('/admin/sheets-config')
]);
if (classRes.status === 'fulfilled' && classRes.value.data?.classrooms) {
totalClassrooms = classRes.value.data.classrooms.length;
}
const resRes = await req.apiClient.get('/reservations');
if (resRes.data && resRes.data.reservations) {
totalReservations = resRes.data.reservations.length;
if (resRes.status === 'fulfilled' && resRes.value.data?.reservations) {
totalReservations = resRes.value.data.reservations.length;
}
if (carRes.status === 'fulfilled' && carRes.value.data?.careers) {
totalCareers = carRes.value.data.careers.length;
}
if (subjRes.status === 'fulfilled' && subjRes.value.data?.subjects) {
const subjs = subjRes.value.data.subjects;
totalSubjects = subjs.length;
recentSubjects = subjs.slice(0, 15);
}
if (commRes.status === 'fulfilled' && commRes.value.data?.commissions) {
totalCommissions = commRes.value.data.commissions.length;
}
if (cfgRes.status === 'fulfilled' && cfgRes.value.data?.config) {
sheetsConfig = cfgRes.value.data.config;
}
} catch (apiErr) {
console.warn('Google Sheets import live stats fallback:', apiErr.message);
}
const metrics = {
total_careers: 5,
total_subjects: 18,
total_careers: totalCareers,
total_subjects: totalSubjects,
total_classrooms: totalClassrooms,
total_commissions: 24,
total_commissions: totalCommissions,
total_reservations: totalReservations,
sheets: [
{ name: 'LUNES' },
@@ -49,34 +79,53 @@ const handleGoogleSheetsImport = async (req, res, justSynced = false) => {
};
let stats = null;
let syncError = null;
if (justSynced) {
try {
const syncRes = await req.apiClient.post('/dashboard/sync-sheets');
const syncPayload = {};
if (req.body && req.body.sheets_url) {
syncPayload.sheets_url = req.body.sheets_url;
}
const syncRes = await req.apiClient.post('/dashboard/sync-sheets', syncPayload, { timeout: 90000 });
if (syncRes.data && syncRes.data.stats) {
stats = syncRes.data.stats;
}
// Refresh counts after sync
try {
const [cR, rR, crR, sR, cmR] = await Promise.allSettled([
req.apiClient.get('/classrooms'),
req.apiClient.get('/reservations'),
req.apiClient.get('/careers'),
req.apiClient.get('/subjects'),
req.apiClient.get('/commissions')
]);
if (cR.status === 'fulfilled') metrics.total_classrooms = cR.value.data?.classrooms?.length || 0;
if (rR.status === 'fulfilled') metrics.total_reservations = rR.value.data?.reservations?.length || 0;
if (crR.status === 'fulfilled') metrics.total_careers = crR.value.data?.careers?.length || 0;
if (sR.status === 'fulfilled') {
metrics.total_subjects = sR.value.data?.subjects?.length || 0;
recentSubjects = (sR.value.data?.subjects || []).slice(0, 15);
}
if (cmR.status === 'fulfilled') metrics.total_commissions = cmR.value.data?.commissions?.length || 0;
} catch (e) {}
} catch (err) {
console.warn('Backend sync-sheets API notice:', err.message);
stats = {
total_rows_processed: 239,
careers_created: 0,
classrooms_created: 0,
subjects_created: 0,
commissions_created: 0,
reservations_created: 0,
errors: []
};
}
if (stats && (!stats.errors || stats.errors.length === 0)) {
stats.errors = null;
console.error('Backend sync-sheets API error:', err.response?.data || err.message);
syncError = err.response?.data?.message || err.message;
}
}
const flashMsg = req.query.msg || null;
const flashType = req.query.type || 'info';
res.render('admin/import', {
title: 'Import from Google Sheets - Edu-Space Admin',
metrics,
stats,
recent_subjects: MOCK_RECENT_SUBJECTS
sync_error: syncError,
sheets_config: sheetsConfig,
recent_subjects: recentSubjects.length > 0 ? recentSubjects : MOCK_RECENT_SUBJECTS,
flash_msg: flashMsg,
flash_type: flashType
});
} catch (error) {
console.error('Error rendering google sheets import:', error.message);
@@ -87,6 +136,45 @@ const handleGoogleSheetsImport = async (req, res, justSynced = false) => {
router.get(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, false));
router.post(['/google_sheets_import', '/import'], (req, res) => handleGoogleSheetsImport(req, res, true));
// Guardar configuración del enlace de Google Sheets
router.post('/google_sheets_config', async (req, res) => {
try {
const url = (req.body.google_sheets_url || req.body.url || '').trim();
if (!url) {
return res.redirect('/admin/google_sheets_import?msg=La+URL+no+puede+estar+vac%C3%ADa&type=danger');
}
await req.apiClient.post('/admin/sheets-config', { url });
res.redirect('/admin/google_sheets_import?msg=Configuraci%C3%B3n+de+Google+Sheets+actualizada+correctamente&type=success');
} catch (err) {
console.error('Error updating sheets config:', err.message);
const errMsg = encodeURIComponent(err.response?.data?.message || 'Error al guardar la configuración');
res.redirect(`/admin/google_sheets_import?msg=${errMsg}&type=danger`);
}
});
// Purga / Reinicio de datos académicos
router.post('/reset_academic_data', async (req, res) => {
try {
const payload = {
all: req.body.reset_all === 'true' || req.body.reset_all === 'on',
reservations: req.body.reset_reservations === 'true' || req.body.reset_reservations === 'on',
commissions: req.body.reset_commissions === 'true' || req.body.reset_commissions === 'on',
subjects: req.body.reset_subjects === 'true' || req.body.reset_subjects === 'on',
careers: req.body.reset_careers === 'true' || req.body.reset_careers === 'on',
classrooms: req.body.reset_classrooms === 'true' || req.body.reset_classrooms === 'on'
};
const purgeRes = await req.apiClient.post('/admin/reset-academic-data', payload);
const del = purgeRes.data?.deleted || {};
const summary = encodeURIComponent(`Datos reiniciados: ${del.reservations || 0} reservas, ${del.commissions || 0} comisiones, ${del.subjects || 0} materias, ${del.careers || 0} carreras, ${del.classrooms || 0} aulas.`);
res.redirect(`/admin/google_sheets_import?msg=${summary}&type=warning`);
} catch (err) {
console.error('Error resetting academic data:', err.message);
const errMsg = encodeURIComponent(err.response?.data?.message || 'Error al purgar los datos');
res.redirect(`/admin/google_sheets_import?msg=${errMsg}&type=danger`);
}
});
// 2. Occupancy Metrics
const handleOccupancyMetrics = async (req, res) => {
try {
+1 -1
View File
@@ -3,7 +3,7 @@ const axios = require('axios');
// Configuración base de Axios conectando hacia Flask
const apiClient = axios.create({
baseURL: process.env.FLASK_API_URL || 'http://localhost:5000/api/v1',
timeout: 5000,
timeout: 25000,
headers: {
'Content-Type': 'application/json'
}
+313 -68
View File
@@ -1,63 +1,139 @@
{% extends "base.html" %}
{% block title %}Import from Google Sheets - Edu-Space Admin{% endblock %}
{% block title %}Sincronización Google Sheets y Purga Académica - Edu-Space Admin{% endblock %}
{% block content %}
<div class="container-fluid pt-5 mt-4">
<!-- 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>
<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>
<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>
<a href="/main/dashboard" class="btn btn-outline-secondary">
<div class="d-flex gap-2">
<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
</a>
</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 -->
<div class="row g-3 mb-4">
<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="h4 mb-0 fw-bold">{{ metrics.total_careers }}</div>
<small class="text-muted">Careers</small>
<div class="h4 mb-0 fw-bold" id="metric-careers">{{ metrics.total_careers }}</div>
<small class="text-muted">Carreras</small>
</div>
</div>
<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="h4 mb-0 fw-bold">{{ metrics.total_subjects }}</div>
<small class="text-muted">Subjects</small>
<div class="h4 mb-0 fw-bold" id="metric-subjects">{{ metrics.total_subjects }}</div>
<small class="text-muted">Asignaturas</small>
</div>
</div>
<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="h4 mb-0 fw-bold">{{ metrics.total_classrooms }}</div>
<small class="text-muted">Classrooms</small>
<div class="h4 mb-0 fw-bold" id="metric-classrooms">{{ metrics.total_classrooms }}</div>
<small class="text-muted">Aulas (Físicas/Virt.)</small>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card border-0 shadow-sm text-center p-3 h-100">
<div class="text-warning mb-1"><i class="bi bi-people fs-3"></i></div>
<div class="h4 mb-0 fw-bold">{{ metrics.total_commissions }}</div>
<small class="text-muted">Commissions</small>
<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-diagram-3 fs-3"></i></div>
<div class="h4 mb-0 fw-bold" id="metric-commissions">{{ metrics.total_commissions }}</div>
<small class="text-muted">Comisiones</small>
</div>
</div>
<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="h4 mb-0 fw-bold">{{ metrics.total_reservations }}</div>
<small class="text-muted">Calendar Classes</small>
<div class="h4 mb-0 fw-bold" id="metric-reservations">{{ metrics.total_reservations }}</div>
<small class="text-muted">Asignaciones en Calendario</small>
</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 -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
@@ -65,13 +141,13 @@
<div class="col-lg-8 mb-3 mb-lg-0">
<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">
<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 class="badge bg-light text-muted border">UniCABA 2026</span>
</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">
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>
<div class="d-flex flex-wrap gap-1">
{% for s in metrics.sheets %}
@@ -82,14 +158,15 @@
</div>
</div>
<div class="col-lg-4 text-lg-end">
<form method="POST" id="syncForm" onsubmit="startSync()">
<button type="submit" id="syncBtn" class="btn btn-success btn-lg px-4 py-2 shadow fw-semibold">
<i class="bi bi-arrow-repeat me-2"></i>Sync Data Now
<form method="POST" id="syncForm" action="/admin/google_sheets_import" onsubmit="startSync()">
<input type="hidden" name="sheets_url" id="syncSheetsUrl" value="{{ sheets_config.active_url }}">
<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>
</form>
<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>
Connecting to Google Sheets and processing records...
Conectando a Google Sheets y procesando registros...
</div>
</div>
</div>
@@ -98,55 +175,56 @@
<!-- Sync Results (if just completed) -->
{% if stats %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 text-success py-3">
<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 d-flex justify-content-between align-items-center">
<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>
<span class="badge bg-success text-white">Completado</span>
</div>
<div class="card-body">
<div class="row g-3 text-center mb-3">
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5">{{ stats.total_rows_processed }}</div>
<small class="text-muted">Rows Processed</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-secondary">{{ stats.total_rows_processed }}</div>
<small class="text-muted fw-semibold">Filas Procesadas</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted">Careers Created</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-primary">+{{ stats.careers_created }}</div>
<small class="text-muted fw-semibold">Carreras Creadas</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted">Classrooms Created</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-success">+{{ stats.classrooms_created }}</div>
<small class="text-muted fw-semibold">Aulas Creadas</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted">Subjects Created</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-info">+{{ stats.subjects_created }}</div>
<small class="text-muted fw-semibold">Materias Creadas</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted">Commissions Created</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-warning">+{{ stats.commissions_created }}</div>
<small class="text-muted fw-semibold">Comisiones Creadas</small>
</div>
</div>
<div class="col-md-2">
<div class="p-2 border rounded bg-body-tertiary">
<div class="fw-bold fs-5 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted">Reservations Created</small>
<div class="p-3 border rounded bg-body-tertiary">
<div class="fw-bold fs-4 text-danger">+{{ stats.reservations_created }}</div>
<small class="text-muted fw-semibold">Clases / Reservas</small>
</div>
</div>
</div>
{% if stats.errors and (stats.errors|length > 0) %}
<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">
{% for err in stats.errors %}
<li>{{ err }}</li>
@@ -158,25 +236,109 @@
</div>
{% 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 -->
<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">
<h5 class="card-title h6 mb-0">
<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-table text-primary me-2"></i>Muestra de Asignaturas en Base de Datos (Primeras 15)
</h5>
<a href="/schedule/calendar_view" class="btn btn-outline-primary btn-sm">
<i class="bi bi-calendar3 me-1"></i>View Calendar
</a>
<span class="badge bg-secondary-subtle text-secondary">
Total: {{ metrics.total_subjects }} asignaturas
</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">Code</th>
<th>Subject Name</th>
<th>Career</th>
<th>Commissions</th>
<th>Status</th>
<th class="ps-4">Código</th>
<th>Nombre de la Asignatura</th>
<th>Carrera / Departamento</th>
<th>Comisiones</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
@@ -191,22 +353,22 @@
<div class="fw-semibold">{{ s.name }}</div>
</td>
<td>
<span class="badge badge-brand small">
{{ s.career_obj.name if s.career_obj else (s.department or _('General')) }}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle small">
{{ s.career_obj.name if s.career_obj else (s.career_name or s.department or 'General') }}
</span>
</td>
<td>
<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>
</td>
<td>
{% if s.is_active %}
<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>
{% else %}
<span class="badge bg-secondary small">Inactive</span>
<span class="badge bg-secondary small">Inactiva</span>
{% endif %}
</td>
</tr>
@@ -214,7 +376,7 @@
<tr>
<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>
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>
</tr>
{% endfor %}
@@ -223,6 +385,41 @@
</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 %}
{% block extra_js %}
@@ -230,6 +427,13 @@
function startSync() {
const btn = document.getElementById('syncBtn');
const spinner = document.getElementById('syncSpinner');
const urlInput = document.getElementById('google_sheets_url');
const syncUrlInput = document.getElementById('syncSheetsUrl');
if (urlInput && syncUrlInput) {
syncUrlInput.value = urlInput.value.trim();
}
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Sincronizando...';
@@ -238,5 +442,46 @@ function startSync() {
spinner.classList.remove('d-none');
}
}
function restoreDefaultUrl() {
const defaultUrl = "{{ sheets_config.default_url }}";
const input = document.getElementById('google_sheets_url');
if (input && defaultUrl) {
input.value = defaultUrl;
}
}
function toggleSelectAll(checked) {
document.getElementById('chk_reservations').checked = checked;
document.getElementById('chk_classrooms').checked = checked;
document.getElementById('chk_commissions').checked = checked;
document.getElementById('chk_subjects').checked = checked;
document.getElementById('chk_careers').checked = checked;
}
function openPurgeModal() {
const anyChecked = document.getElementById('chk_reservations').checked ||
document.getElementById('chk_classrooms').checked ||
document.getElementById('chk_commissions').checked ||
document.getElementById('chk_subjects').checked ||
document.getElementById('chk_careers').checked ||
document.getElementById('chk_all').checked;
if (!anyChecked) {
alert('Por favor seleccione al menos una entidad para purgar.');
return;
}
const modalEl = document.getElementById('purgeConfirmModal');
if (modalEl) {
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
}
function confirmPurge() {
const form = document.getElementById('purgeForm');
if (form) {
form.submit();
}
}
</script>
{% endblock %}