feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README

This commit is contained in:
2026-09-23 12:10:31 -03:00
parent 4ebc5df4fd
commit 7ad4e3f099
38 changed files with 3804 additions and 390 deletions
+20
View File
@@ -189,10 +189,27 @@ app.use((req, res, next) => {
};
} catch (e) {}
}
let isImpersonating = false;
let impersonatedUser = null;
if (user.is_admin() && req.cookies.impersonate_user_id) {
isImpersonating = true;
const targetId = req.cookies.impersonate_user_id;
try {
if (req.cookies.impersonate_user_data) {
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
}
} catch (e) {}
if (!impersonatedUser) {
impersonatedUser = { id: targetId, name: `Usuario #${targetId}`, role: 'usuario' };
}
}
req.user = user;
res.locals.user = user;
res.locals.current_user = user;
res.locals.role = user.role;
res.locals.is_impersonating = isImpersonating;
res.locals.impersonated_user = impersonatedUser;
res.locals.session = { get: (k, d) => d };
res.locals.occupancy_metrics = { get: (k, d) => d };
next();
@@ -259,6 +276,9 @@ app.use('/api', async (req, res) => {
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (req.cookies.impersonate_user_id) {
headers['X-Impersonate-User'] = req.cookies.impersonate_user_id;
}
const flaskUrl = `http://127.0.0.1:5000/api${req.url}`;
const response = await axios({
method: req.method,
@@ -65,10 +65,38 @@ const requireAuth = (req, res, next) => {
console.error('Error decoding token payload:', e.message);
}
let isImpersonating = false;
let impersonatedUser = null;
if (user.is_admin() && req.cookies.impersonate_user_id) {
isImpersonating = true;
const targetId = req.cookies.impersonate_user_id;
req.apiClient.defaults.headers['X-Impersonate-User'] = targetId;
try {
if (req.cookies.impersonate_user_data) {
impersonatedUser = JSON.parse(req.cookies.impersonate_user_data);
}
} catch (e) {
console.error('Error parsing impersonate_user_data:', e.message);
}
if (!impersonatedUser) {
impersonatedUser = {
id: targetId,
name: `Usuario #${targetId}`,
email: 'impersonated@edu-space.com',
role: 'usuario'
};
}
}
req.user = user;
res.locals.user = user;
res.locals.current_user = user;
res.locals.role = user.role;
res.locals.is_impersonating = isImpersonating;
res.locals.impersonated_user = impersonatedUser;
next();
};
+49
View File
@@ -38,9 +38,58 @@ router.post('/login', async (req, res) => {
}
});
const { requireAuth } = require('../middlewares/authMiddleware');
router.get('/logout', (req, res) => {
res.clearCookie('auth_token');
res.clearCookie('impersonate_user_id');
res.clearCookie('impersonate_user_data');
res.redirect('/auth/login');
});
// Iniciar Modo Impersonación (Solo Admin)
router.get('/impersonate/:id', requireAuth, async (req, res) => {
try {
const targetUserId = parseInt(req.params.id, 10);
if (!req.user || !req.user.is_admin()) {
return res.status(403).send('Solo los administradores pueden activar el modo de impersonación.');
}
const response = await req.apiClient.post('/admin/impersonate', {
user_id: targetUserId
});
const targetUser = response.data && response.data.target_user;
if (targetUser) {
res.cookie('impersonate_user_id', targetUserId.toString(), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
res.cookie('impersonate_user_data', JSON.stringify(targetUser), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
}
res.redirect('/dashboard');
} catch (error) {
console.error('Error initiating impersonation:', error.response?.data || error.message);
res.redirect('/admin/users_list?error=impersonate_failed');
}
});
// Detener Modo Impersonación
router.get('/stop-impersonating', requireAuth, async (req, res) => {
try {
await req.apiClient.post('/admin/stop-impersonating', {});
} catch (error) {
console.error('Error stopping impersonation on backend:', error.response?.data || error.message);
} finally {
res.clearCookie('impersonate_user_id');
res.clearCookie('impersonate_user_data');
res.redirect('/admin/users_list');
}
});
module.exports = router;
+60 -36
View File
@@ -176,6 +176,65 @@ router.get('/', async (req, res) => {
};
} else if (role === 'alumno') {
const studentClasses = apiTodaySchedule ? apiTodaySchedule.slice(0, 4) : null;
let upcomingMilestonesList = [];
try {
const mResp = await req.apiClient.get('/students/my-upcoming-milestones');
if (mResp.data && Array.isArray(mResp.data.upcoming_milestones) && mResp.data.upcoming_milestones.length > 0) {
upcomingMilestonesList = mResp.data.upcoming_milestones.map(m => {
const isFinal = (m.type || '').includes('Final');
const isParcial = (m.type || '').includes('Parcial');
const color = isFinal ? '#dc3545' : (isParcial ? '#fd7e14' : '#0d6efd');
return {
...m,
materia_codigo: m.commission_code || m.subject_name,
type_obj: { name: m.type, color },
milestone_type: { name: m.type, color }
};
});
}
} catch (e) {
console.warn('Student milestones fetch notice:', e.message);
}
if (upcomingMilestonesList.length === 0) {
upcomingMilestonesList = [
{
title: 'Primer Examen Parcial',
materia_codigo: 'BDR-201',
subject_name: 'Bases de Datos Relacionales',
date: '25/09/2026',
days_remaining: 2,
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
notes: 'Traer DNI y calculadora',
classroom: 'Piso 2 · Aula 204'
},
{
title: 'Entrega TP Grupal Nº 2',
materia_codigo: 'AED-102',
subject_name: 'Algoritmos y Estructuras de Datos',
date: '30/09/2026',
days_remaining: 7,
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
notes: 'Subir código al repositorio',
classroom: 'Campus Virtual',
link: 'https://campus.edu-space.com/tps'
},
{
title: 'Recuperatorio Parcial 1',
materia_codigo: 'RYT-302',
subject_name: 'Redes y Telecomunicaciones',
date: '08/10/2026',
days_remaining: 15,
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
notes: 'Piso 2 · Aula 204',
classroom: 'Piso 2 · Aula 204'
}
];
}
role_data = {
today_classes: studentClasses || [
{
@@ -197,42 +256,7 @@ router.get('/', async (req, res) => {
virtual_link: 'https://meet.google.com/edu-space-demo'
}
],
upcoming_milestones: apiMilestones ? apiMilestones.map(m => ({
...m,
milestone_type: m.type_obj
})) : [
{
title: 'Primer Examen Parcial',
materia_codigo: 'BDR-201',
subject_name: 'Bases de Datos Relacionales',
date: '25/09/2026',
type_obj: { name: 'Examen Parcial', color: '#dc3545' },
milestone_type: { name: 'Examen Parcial', color: '#dc3545' },
notes: 'Traer DNI y calculadora',
classroom: 'Piso 2 · Aula 204'
},
{
title: 'Entrega TP Grupal Nº 2',
materia_codigo: 'AED-102',
subject_name: 'Algoritmos y Estructuras de Datos',
date: '30/09/2026',
type_obj: { name: 'Trabajo Práctico', color: '#198754' },
milestone_type: { name: 'Trabajo Práctico', color: '#198754' },
notes: 'Subir código al repositorio',
classroom: 'Campus Virtual',
link: 'https://campus.edu-space.com/tps'
},
{
title: 'Recuperatorio Parcial 1',
materia_codigo: 'RYT-302',
subject_name: 'Redes y Telecomunicaciones',
date: '08/10/2026',
type_obj: { name: 'Recuperatorio', color: '#fd7e14' },
milestone_type: { name: 'Recuperatorio', color: '#fd7e14' },
notes: 'Piso 2 · Aula 204',
classroom: 'Piso 2 · Aula 204'
}
]
upcoming_milestones: upcomingMilestonesList
};
}
+2
View File
@@ -98,5 +98,7 @@ const handleMySubjectDetail = async (req, res) => {
router.get(['/', '/list', '/my_subjects_list'], handleMySubjectsList);
router.get(['/detail', '/commission_detail', '/:id', '/detail/:id'], handleMySubjectDetail);
router.get(['/gradebook/:id', '/:id/gradebook'], (req, res) => res.redirect(`/schedule/gradebook/${req.params.id}`));
router.get(['/grades', '/my_grades', '/mis-notas'], (req, res) => res.redirect('/schedule/my_grades'));
module.exports = router;
+54 -1
View File
@@ -21,14 +21,24 @@ const handleCalendarView = async (req, res) => {
}
});
let unassignedCommissions = [];
try {
const commsRes = await req.apiClient.get('/genetic/commissions');
unassignedCommissions = (commsRes.data && commsRes.data.commissions) || [];
} catch (e) {
console.warn('Could not fetch unassigned commissions for calendar:', e.message);
}
res.render('schedule/calendar', {
title: 'Reservations Calendar - Edu-Space Admin',
today_date: new Date().toISOString().split('T')[0],
classrooms: classrooms.map(c => ({
id: c.id,
code: c.code,
building: c.building || c.building_name || 'Edificio Central'
building: c.building || c.building_name || 'Edificio Central',
capacity: c.capacity || 40
})),
unassigned_commissions: unassignedCommissions,
subjects: [
{ id: 1, name: 'Arquitectura de Software', code: 'ARQ-101' },
{ id: 2, name: 'Bases de Datos II', code: 'BD-201' },
@@ -48,6 +58,7 @@ const handleCalendarView = async (req, res) => {
title: 'Reservations Calendar (Offline)',
today_date: new Date().toISOString().split('T')[0],
classrooms: [],
unassigned_commissions: [],
subjects: [],
careers: [],
buildings: [],
@@ -581,6 +592,48 @@ router.post(['/edit_reservation', '/edit_reservation/:id', '/reservation/:id/edi
router.post(['/confirm_reservation', '/confirm_reservation/:id', '/reservation/:id/confirm'], handleConfirmReservation);
router.post(['/cancel_reservation', '/cancel_reservation/:id', '/reservation/:id/cancel'], handleCancelReservation);
// 9. Libro de Calificaciones / Gradebook (Fase 3)
const handleGradebook = async (req, res) => {
const id = req.params.id || req.query.id;
try {
const resp = await req.apiClient.get(`/commissions/${id}/gradebook`);
const data = resp.data || {};
res.render('schedule/gradebook', {
title: `Libro de Calificaciones — Edu-Space`,
commission: data.commission || { id, code: 'C1' },
milestone_columns: data.milestone_columns || [],
students: data.students || [],
role: req.user?.role || 'docente'
});
} catch (err) {
console.error('Error fetching commission gradebook:', err.message);
res.redirect(`/admin/commission_detail?id=${id}`);
}
};
// 10. Consulta de Calificaciones del Alumno (Fase 3)
const handleMyGrades = async (req, res) => {
try {
const resp = await req.apiClient.get('/students/my-grades');
const subjects = resp.data?.subjects || [];
res.render('schedule/my_grades', {
title: 'Mis Calificaciones — Edu-Space',
subjects,
role: req.user?.role || 'alumno'
});
} catch (err) {
console.error('Error fetching student grades:', err.message);
res.render('schedule/my_grades', {
title: 'Mis Calificaciones — Edu-Space',
subjects: [],
role: req.user?.role || 'alumno'
});
}
};
router.get(['/gradebook', '/gradebook/:id', '/commissions/:id/gradebook'], handleGradebook);
router.get(['/my_grades', '/my-grades', '/mis_calificaciones', '/mis-notas'], handleMyGrades);
module.exports = router;