feat: completar Fase 2 del Roadmap MVP (Co-docencia, Matriz de Conflictos y Regla Diaria) y auditoria de seguridad

This commit is contained in:
2026-09-19 14:05:16 -03:00
parent 05e197e49f
commit ada39248d7
36 changed files with 3037 additions and 443 deletions
+102 -40
View File
@@ -1,24 +1,39 @@
{% extends "base.html" %}
{% block title %}User Management - Edu-Space Admin{% endblock %}
{% block title %}Gestión de Usuarios — 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-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-people text-primary me-2"></i>User Management
<h1 class="h3 mb-1 fw-bold">
<i class="bi bi-people text-primary me-2"></i>Gestión de Usuarios
</h1>
<p class="text-muted small mb-0">Manage user accounts, assign roles, and control active status.</p>
<p class="text-muted small mb-0">
Administración de cuentas, identidad y documentación, datos de contacto y asignación de roles institucionales (Bedelía, Docentes, Alumnos).
</p>
</div>
<div>
<a href="/admin/user_add" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>New User
<a href="/admin/user_add" class="btn btn-primary shadow-sm">
<i class="bi bi-person-plus me-1"></i>Nuevo Usuario
</a>
</div>
</div>
<!-- Feedback Alerts -->
{% if msg == 'saved' %}
<div class="alert alert-success alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>Los datos del usuario han sido guardados exitosamente.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif error %}
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Filters & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
@@ -29,13 +44,13 @@
<i class="bi bi-search text-muted"></i>
</span>
<input type="text" name="search" class="form-control border-start-0"
placeholder="Search by name or email..."
placeholder="Buscar por nombre, apellido, email o nro de documento..."
value="{{ search }}">
</div>
</div>
<div class="col-md-3">
<select name="role" class="form-select form-select-sm">
<option value="">All Roles</option>
<option value="">Todos los Roles</option>
{% for r in roles %}
<option value="{{ r.id }}" {% if role_filter == (r.id|string) %}selected{% endif %}>{{ r.name }}</option>
{% endfor %}
@@ -43,17 +58,17 @@
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="">Todos los Estados</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Solo Activos</option>
<option value="inactive" {% if status_filter == 'inactive' %}selected{% endif %}>Solo Inactivos</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-filter me-1"></i>Filter
<i class="bi bi-filter me-1"></i>Filtrar
</button>
{% if search or role_filter or status_filter %}
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Clear filters">
<a href="/admin/users_list" class="btn btn-outline-secondary btn-sm" title="Limpiar filtros">
<i class="bi bi-x-lg"></i>
</a>
{% endif %}
@@ -68,12 +83,13 @@
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4">User</th>
<th>Email</th>
<th>Role / Group</th>
<th>Status</th>
<th>Last Login</th>
<th class="text-end pe-4">Actions</th>
<th class="ps-4">Usuario / Identidad</th>
<th>Documento</th>
<th>Contacto y Domicilio</th>
<th>Rol Institucional</th>
<th>Estado</th>
<th>Último Acceso</th>
<th class="text-end pe-4">Acciones</th>
</tr>
</thead>
<tbody>
@@ -81,17 +97,54 @@
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="user-avatar-small me-2">
{{ (u.first_name[0] if u.first_name else 'U')|upper }}
<div class="user-avatar-small me-2 rounded-circle bg-primary-subtle text-primary d-flex align-items-center justify-content-center fw-bold" style="width: 36px; height: 36px;">
{{ (u.first_name[0] if u.first_name else (u.name[0] if u.name else 'U'))|upper }}
</div>
<div>
<div class="fw-bold">{{ u.name }}</div>
<small class="text-muted">ID: #{{ u.id }}</small>
<div class="text-muted small">
{% if u.first_name and u.last_name %}
<span class="opacity-75">{{ u.last_name }}, {{ u.first_name }}</span> ·
{% endif %}
<span class="font-monospace">#{{ u.id }}</span>
</div>
</div>
</div>
</td>
<td>
<span class="font-monospace small">{{ u.email }}</span>
{% if u.document_number %}
<div>
<span class="badge {% if u.document_type in ['PAS', 'PASEXT'] %}bg-info-subtle text-info border border-info-subtle{% elif u.document_type == 'CUIL' %}bg-primary-subtle text-primary border border-primary-subtle{% else %}bg-secondary-subtle text-secondary border{% endif %} py-1 px-2 font-monospace">
{{ u.document_type or 'DNI' }}: {{ u.document_formatted or u.document_number }}
</span>
</div>
{% else %}
<span class="text-muted small fst-italic"><i class="bi bi-dash"></i> Sin registrar</span>
{% endif %}
</td>
<td>
<div>
<i class="bi bi-building me-1 text-primary" title="Email Institucional"></i>
<span class="font-monospace small fw-semibold">{{ u.email }}</span>
</div>
{% if u.personal_email %}
<div class="text-muted small">
<i class="bi bi-envelope-at me-1 text-secondary" title="Email Personal"></i>
<span class="font-monospace" style="font-size: 0.8rem;">{{ u.personal_email }}</span>
</div>
{% endif %}
{% if u.phone or u.address %}
<div class="text-muted small mt-1 d-flex flex-column gap-0">
{% if u.phone %}
<span><i class="bi bi-telephone me-1 text-secondary"></i>{{ u.phone }}</span>
{% endif %}
{% if u.address %}
<span class="text-truncate" style="max-width: 220px;" title="{{ u.address }}">
<i class="bi bi-geo-alt me-1 text-secondary"></i>{{ u.address }}
</span>
{% endif %}
</div>
{% endif %}
</td>
<td>
{% if u.role_obj %}
@@ -100,40 +153,40 @@
</span>
{% else %}
<span class="badge bg-secondary">
{{ u.role or _('No Role') }}
{{ u.role or 'Docente' }}
</span>
{% endif %}
</td>
<td>
{% if u.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle px-2 py-1">
<i class="bi bi-check-circle me-1"></i>Active
<i class="bi bi-check-circle me-1"></i>Activo
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2 py-1">
<i class="bi bi-dash-circle me-1"></i>Inactive
<i class="bi bi-dash-circle me-1"></i>Inactivo
</span>
{% endif %}
</td>
<td>
<small class="text-muted">
{% if u.last_login %}
{{ format_datetime(u.last_login, format='short') }}
{{ u.last_login[:16] if u.last_login is string else u.last_login }}
{% else %}
Never
Nunca
{% endif %}
</small>
</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Edit User">
<a href="/admin/users/edit/{{ u.id }}" class="btn btn-outline-primary" title="Editar Ficha de Usuario">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != user.id %}
<form action="/admin/user_toggle" method="POST" class="d-inline">
<input type="hidden" name="id" value="{{ u.id }}">
<button type="submit" class="btn {% if u.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if u.is_active %}Deactivate User{% else %}Activate User{% endif %}">
title="{% if u.is_active %}Desactivar Usuario{% else %}Activar Usuario{% endif %}">
<i class="bi {% if u.is_active %}bi-person-x{% else %}bi-person-check{% endif %}"></i>
</button>
</form>
@@ -151,9 +204,9 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-2 text-secondary"></i>
No users found matching the criteria.
No se encontraron usuarios coincidentes con los criterios de búsqueda.
</td>
</tr>
{% endfor %}
@@ -162,11 +215,8 @@
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary d-flex justify-content-between align-items-center py-2 px-4">
<small class="text-muted">
Showing page {{ pagination.page }} of {{ pagination.pages }} ({{ pagination.total }} total)
</small>
{% if pagination and pagination.pages > 1 %}
<div class="card-footer bg-body-tertiary border-0 py-2 d-flex justify-content-center">
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item"><a class="page-link" href="/admin/users_list">&laquo;</a></li>
@@ -189,7 +239,7 @@
</div>
</div>
<!-- Modal Global de Eliminación de Usuario (fuera de la tabla) -->
<!-- Modal Global de Eliminación de Usuario -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px;">
@@ -237,9 +287,21 @@ document.addEventListener('DOMContentLoaded', function() {
var name = this.getAttribute('data-user-name');
var email = this.getAttribute('data-user-email');
var actionUrl = this.getAttribute('data-action-url');
var userId = this.getAttribute('data-user-id');
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email;
if (formEl) formEl.action = actionUrl;
if (formEl) {
formEl.action = actionUrl;
// Add hidden input with id
var hiddenId = formEl.querySelector('input[name="id"]');
if (!hiddenId) {
hiddenId = document.createElement('input');
hiddenId.type = 'hidden';
hiddenId.name = 'id';
formEl.appendChild(hiddenId);
}
hiddenId.value = userId;
}
if (deleteModal) deleteModal.show();
});
});