feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos

This commit is contained in:
Carlos Tello
2026-09-03 03:48:49 -03:00
parent 0c867e0031
commit 291e537574
13 changed files with 777 additions and 23 deletions
+18
View File
@@ -245,6 +245,24 @@
* **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):** * **Próximas Clases: Visualización Exclusiva del Horario ([app/templates/dashboard.html](file:///c:/Workspace/admin-edu-space/app/templates/dashboard.html), [app/templates/classrooms/view.html](file:///c:/Workspace/admin-edu-space/app/templates/classrooms/view.html)):**
* En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`). * En la tabla de *Próximas Reservas* del panel y en la ficha del aula se cambió la columna a **"Horario"**, removiendo la fecha del calendario y destacando únicamente el rango horario de cursada (ej. `17:30 - 21:30 hs`).
---
## 16. Desacoplamiento de Hitos Académicos: Catálogo Dinámico de Tipificaciones y Matriz RBAC
* **Modelos de Datos ([app/models/milestone.py](file:///c:/Workspace/admin-edu-space/app/models/milestone.py), [app/models/__init__.py](file:///c:/Workspace/admin-edu-space/app/models/__init__.py)):**
* `MilestoneType`: Catálogo dinámico y administrable con `id`, `name`, `code` (slug único), `color` (hexadecimal), `description`, `sort_order` e `is_active`.
* `AcademicMilestone`: Relacionado mediante clave foránea `milestone_type_id` a `MilestoneType` en lugar de strings estáticos.
* Sembrado de 12 categorías iniciales predeterminadas (`Parcial 1`, `Parcial 2`, `TP 1`, `TP 2`, `TP 3`, `Recu. P1`, `Recu. P2`, `Recu. TP 1`, `Recu. TP 2`, `Recu. TP 3`, `Final`, `Otro`).
* **Matriz de Permisos RBAC ([app/models/role.py](file:///c:/Workspace/admin-edu-space/app/models/role.py), [init_db.py](file:///c:/Workspace/admin-edu-space/init_db.py)):**
* Se agregó el módulo `milestone_types` a `SYSTEM_MODULES` cubriendo los 9 módulos de la plataforma.
* Niveles asignados: `Admin` (RW), `Docente` (R), `Operador` (RW), `Consulta` (R).
* **Vistas y ABM en Management ([app/routes/admin.py](file:///c:/Workspace/admin-edu-space/app/routes/admin.py), [app/templates/admin/milestone_types/list.html](file:///c:/Workspace/admin-edu-space/app/templates/admin/milestone_types/list.html)):**
* Se añadió el ítem de navegación **"Tipificaciones"** al menú desplegable *Management*.
* Tabla completa con badges de color, slugs, switches interactivos de activación/desactivación y modales para dar de alta y editar tipificaciones.
* **API y Modal del Docente ([app/routes/main.py](file:///c:/Workspace/admin-edu-space/app/routes/main.py), [example/aulas-unicaba-mvp/...](file:///c:/Workspace/admin-edu-space/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html)):**
* Se expuso el endpoint `GET /api/milestone-types` para consumo desacoplado.
* Se actualizó el modal de eventos académicos (`evento-modal.component.html` y `.ts`) desacoplando las opciones estáticas y cargando dinámicamente el catálogo de tipificaciones activas con fallback automático para funcionamiento offline.
+5 -1
View File
@@ -6,6 +6,7 @@ from .subject import Subject, Commission
from .reservation import Reservation, ReservationStatus from .reservation import Reservation, ReservationStatus
from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer
from .role import Role, Permission, SYSTEM_MODULES from .role import Role, Permission, SYSTEM_MODULES
from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
__all__ = [ __all__ = [
'User', 'User',
@@ -21,5 +22,8 @@ __all__ = [
'ReservationOptimizer', 'ReservationOptimizer',
'Role', 'Role',
'Permission', 'Permission',
'SYSTEM_MODULES' 'SYSTEM_MODULES',
'MilestoneType',
'AcademicMilestone',
'BASE_MILESTONE_TYPES'
] ]
+176
View File
@@ -0,0 +1,176 @@
from datetime import datetime
from app import db
class MilestoneType(db.Model):
"""
Catálogo dinámico y administrable de tipificaciones de hitos y evaluaciones académicas.
(Parciales, Trabajos Prácticos, Recuperatorios, Finales, etc.)
"""
__tablename__ = 'milestone_types'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) # Ej: "Parcial 1", "Trabajo Práctico 1"
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # Ej: "parcial_1", "tp_1"
color = db.Column(db.String(30), default='#0d6efd') # Hex color para badges y calendario
description = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, default=0) # Orden de aparición en selects
is_active = db.Column(db.Boolean, default=True, nullable=False) # Activo / Inactivo
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación uno-a-muchos con AcademicMilestone
milestones = db.relationship('AcademicMilestone', backref='type_obj', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'color': self.color or '#0d6efd',
'description': self.description or '',
'sort_order': self.sort_order or 0,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<MilestoneType {self.code}: {self.name}>'
class AcademicMilestone(db.Model):
"""
Hito o evento académico programado para una materia/comisión (Examen, TP, Entrega).
"""
__tablename__ = 'academic_milestones'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=True)
materia_codigo = db.Column(db.String(50), nullable=False, index=True) # Código de materia (ej: INGA0003)
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=True)
milestone_type_id = db.Column(db.Integer, db.ForeignKey('milestone_types.id'), nullable=False)
date = db.Column(db.DateTime, nullable=False) # Fecha y hora del hito
classroom = db.Column(db.String(100), nullable=True) # Aula o enlace virtual asignado
link = db.Column(db.String(255), nullable=True) # Link Teams / Meet / Zoom si aplica
notes = db.Column(db.Text, nullable=True) # Temario, consignas o notas
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relación con Asignatura
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
@property
def tipo(self):
return self.type_obj.name if self.type_obj else 'Otro'
@property
def color(self):
return self.type_obj.color if self.type_obj and self.type_obj.color else '#0d6efd'
def to_dict(self):
return {
'id': self.id,
'title': self.title or self.tipo,
'materiaCodigo': self.materia_codigo,
'tipo': self.tipo,
'milestone_type_id': self.milestone_type_id,
'tipo_info': self.type_obj.to_dict() if self.type_obj else None,
'color': self.color,
'fecha': self.date.isoformat() if self.date else None,
'aula': self.classroom,
'link': self.link,
'notas': self.notes,
'is_active': self.is_active
}
def __repr__(self):
return f'<AcademicMilestone {self.materia_codigo} - {self.tipo} ({self.date})>'
# Datos semilla estándar para inicialización
BASE_MILESTONE_TYPES = [
{
'name': 'Parcial 1',
'code': 'parcial_1',
'color': '#0d6efd',
'description': 'Primer examen parcial de la cursada',
'sort_order': 1
},
{
'name': 'Parcial 2',
'code': 'parcial_2',
'color': '#0b5ed7',
'description': 'Segundo examen parcial de la cursada',
'sort_order': 2
},
{
'name': 'Trabajo Práctico 1',
'code': 'tp_1',
'color': '#198754',
'description': 'Primera entrega o evaluación práctica',
'sort_order': 3
},
{
'name': 'Trabajo Práctico 2',
'code': 'tp_2',
'color': '#157347',
'description': 'Segunda entrega o evaluación práctica',
'sort_order': 4
},
{
'name': 'Trabajo Práctico 3',
'code': 'tp_3',
'color': '#0f5132',
'description': 'Tercera entrega o proyecto integrador',
'sort_order': 5
},
{
'name': 'Recuperatorio Parcial 1',
'code': 'recu_p1',
'color': '#fd7e14',
'description': 'Instancia de recuperación para el Parcial 1',
'sort_order': 6
},
{
'name': 'Recuperatorio Parcial 2',
'code': 'recu_p2',
'color': '#e76b08',
'description': 'Instancia de recuperación para el Parcial 2',
'sort_order': 7
},
{
'name': 'Recuperatorio TP 1',
'code': 'recu_tp1',
'color': '#ffc107',
'description': 'Reentrega o recuperación de Trabajo Práctico 1',
'sort_order': 8
},
{
'name': 'Recuperatorio TP 2',
'code': 'recu_tp2',
'color': '#e0a800',
'description': 'Reentrega o recuperación de Trabajo Práctico 2',
'sort_order': 9
},
{
'name': 'Recuperatorio TP 3',
'code': 'recu_tp3',
'color': '#c69500',
'description': 'Reentrega o recuperación de Trabajo Práctico 3',
'sort_order': 10
},
{
'name': 'Final',
'code': 'final',
'color': '#dc3545',
'description': 'Examen final regular o libre',
'sort_order': 11
},
{
'name': 'Otro',
'code': 'otro',
'color': '#6c757d',
'description': 'Taller, seminario, clase especial o hito no tipificado',
'sort_order': 12
}
]
+7
View File
@@ -58,6 +58,13 @@ SYSTEM_MODULES = [
'name_en': 'Users & Access RBAC', 'name_en': 'Users & Access RBAC',
'icon': 'bi-shield-lock', 'icon': 'bi-shield-lock',
'description': 'Administración de cuentas, roles y matriz granular de permisos.' 'description': 'Administración de cuentas, roles y matriz granular de permisos.'
},
{
'id': 'milestone_types',
'name': 'Tipificaciones de Hitos',
'name_en': 'Milestone Types',
'icon': 'bi-tags',
'description': 'Catálogo dinámico de tipos de evaluaciones, parciales, TPs y eventos.'
} }
] ]
+6 -1
View File
@@ -55,7 +55,12 @@ class User(UserMixin, db.Model):
def can_manage(self): def can_manage(self):
"""Check if user can access the Management / Admin section""" """Check if user can access the Management / Admin section"""
return self.is_admin() or self.has_permission('users', 'read') return (
self.is_admin() or
self.has_permission('users', 'read') or
self.has_permission('milestone_types', 'read') or
self.has_permission('import', 'read')
)
def update_last_login(self): def update_last_login(self):
+135
View File
@@ -4,8 +4,10 @@ from flask_babel import gettext as _
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
from app.models.milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
from app.utils.decorators import admin_required, permission_required from app.utils.decorators import admin_required, permission_required
from sqlalchemy import or_ from sqlalchemy import or_
import re
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -340,3 +342,136 @@ def google_sheets_import():
recent_subjects=recent_subjects recent_subjects=recent_subjects
) )
# ---------------------------------------------------------
# MILESTONE TYPES & ACADEMIC CATEGORIES (ABM / CRUD)
# ---------------------------------------------------------
@admin_bp.route('/milestone-types')
@permission_required('milestone_types', 'read')
def milestone_types_list():
"""List all milestone categories / types"""
# Preload default milestone types if table is empty
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
can_edit = current_user.has_permission('milestone_types', 'read_write')
return render_template('admin/milestone_types/list.html', types=types, can_edit=can_edit)
@admin_bp.route('/milestone-types/add', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_add():
"""Create a new milestone type"""
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip().lower()
color = request.form.get('color', '#0d6efd').strip()
description = request.form.get('description', '').strip()
sort_order = request.form.get('sort_order', 0, type=int)
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
if not name:
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
return redirect(url_for('admin.milestone_types_list'))
# Auto-generate code slug if not provided
if not code:
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
# Check code uniqueness
existing = MilestoneType.query.filter_by(code=code).first()
if existing:
flash(_('Ya existe una tipificación con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.milestone_types_list'))
new_type = MilestoneType(
name=name,
code=code,
color=color or '#0d6efd',
description=description,
sort_order=sort_order,
is_active=is_active
)
db.session.add(new_type)
db.session.commit()
flash(_('Tipificación "%(name)s" creada exitosamente.', name=new_type.name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/edit/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_edit(id):
"""Edit an existing milestone type"""
item = MilestoneType.query.get_or_404(id)
name = request.form.get('name', '').strip()
code = request.form.get('code', '').strip().lower()
color = request.form.get('color', '#0d6efd').strip()
description = request.form.get('description', '').strip()
sort_order = request.form.get('sort_order', item.sort_order, type=int)
is_active = True if request.form.get('is_active') in ['1', 'true', 'True', 'on'] else False
if not name:
flash(_('El nombre de la tipificación es obligatorio.'), 'error')
return redirect(url_for('admin.milestone_types_list'))
if not code:
code = re.sub(r'[^a-zA-Z0-9_]+', '_', name.lower()).strip('_')
# Check code uniqueness against other records
existing = MilestoneType.query.filter(MilestoneType.code == code, MilestoneType.id != id).first()
if existing:
flash(_('Ya existe otra tipificación con el código "%(code)s".', code=code), 'error')
return redirect(url_for('admin.milestone_types_list'))
item.name = name
item.code = code
item.color = color or item.color
item.description = description
item.sort_order = sort_order
item.is_active = is_active
db.session.commit()
flash(_('Tipificación "%(name)s" actualizada correctamente.', name=item.name), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/milestone-types/toggle/<int:id>', methods=['POST'])
@permission_required('milestone_types', 'read_write')
def milestone_type_toggle(id):
"""Toggle active status of a milestone type"""
item = MilestoneType.query.get_or_404(id)
item.is_active = not item.is_active
db.session.commit()
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'id': item.id,
'is_active': item.is_active,
'message': _('Tipificación "%(name)s" %(status)s.', name=item.name, status=_('activada') if item.is_active else _('desactivada'))
})
status_str = _('activada') if item.is_active else _('desactivada')
flash(_('Tipificación "%(name)s" ha sido %(status)s.', name=item.name, status=status_str), 'success')
return redirect(url_for('admin.milestone_types_list'))
@admin_bp.route('/api/milestone-types')
@login_required
def api_milestone_types():
"""API endpoint to get active milestone types for forms and dropdowns"""
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])
+21
View File
@@ -135,3 +135,24 @@ def set_language(language=None):
db.session.commit() db.session.commit()
return redirect(request.referrer or url_for('main.dashboard')) return redirect(request.referrer or url_for('main.dashboard'))
@main_bp.route('/api/milestone-types')
def api_milestone_types():
"""Retorna las tipificaciones de hitos activas en JSON para consumo de frontend/mobile"""
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
if MilestoneType.query.count() == 0:
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
db.session.commit()
types = MilestoneType.query.filter_by(is_active=True).order_by(MilestoneType.sort_order.asc(), MilestoneType.id.asc()).all()
return jsonify([t.to_dict() for t in types])␍
@@ -0,0 +1,322 @@
{% extends "base.html" %}
{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1 small">
<li class="breadcrumb-item"><a href="{{ url_for('main.dashboard') }}">{% trans %}Dashboard{% endtrans %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans %}Tipificaciones{% endtrans %}</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1">
<i class="bi bi-tags text-warning me-2"></i>{% trans %}Tipificaciones de Hitos Académicos{% endtrans %}
</h1>
<p class="text-muted mb-0">
{% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %}
</p>
</div>
<div class="d-flex gap-2">
{% if can_edit %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalAddType">
<i class="bi bi-plus-lg me-1"></i>{% trans %}Nueva Tipificación{% endtrans %}
</button>
{% endif %}
</div>
</div>
<!-- Alert / Help Info -->
<div class="alert alert-info border-0 shadow-sm d-flex align-items-center mb-4 py-2 px-3">
<i class="bi bi-info-circle-fill text-info fs-5 me-3"></i>
<div class="small">
{% trans %}Las tipificaciones activas aparecen de forma inmediata en el selector de tipo de evento/hito tanto en el portal web como en la app móvil. Puedes desactivar una tipificación sin eliminar registros históricos asociados.{% endtrans %}
</div>
</div>
<!-- Table Card -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-body-tertiary">
<tr>
<th class="ps-4" style="width: 70px;">{% trans %}Color{% endtrans %}</th>
<th>{% trans %}Nombre Visible{% endtrans %}</th>
<th>{% trans %}Código / Slug{% endtrans %}</th>
<th>{% trans %}Descripción{% endtrans %}</th>
<th class="text-center">{% trans %}Orden{% endtrans %}</th>
<th class="text-center">{% trans %}Estado{% endtrans %}</th>
<th class="text-end pe-4">{% trans %}Acciones{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for item in types %}
<tr id="row-{{ item.id }}">
<td class="ps-4">
<span class="d-inline-block rounded-circle shadow-sm border border-light"
style="width: 26px; height: 26px; background-color: {{ item.color or '#0d6efd' }}; vertical-align: middle;"
title="{{ item.color }}"></span>
</td>
<td>
<div class="fw-bold fs-6">{{ item.name }}</div>
<span class="badge rounded-pill text-white shadow-xs" style="background-color: {{ item.color or '#0d6efd' }}; font-size: 0.72rem;">
{{ item.name }}
</span>
</td>
<td>
<code class="text-primary fw-semibold">{{ item.code }}</code>
</td>
<td>
<span class="text-muted small">{{ item.description or '—' }}</span>
</td>
<td class="text-center">
<span class="badge bg-body-tertiary text-secondary border font-monospace">#{{ item.sort_order }}</span>
</td>
<td class="text-center">
{% if can_edit %}
<form action="{{ url_for('admin.milestone_type_toggle', id=item.id) }}" method="POST" class="d-inline">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input status-toggle" type="checkbox" role="switch"
onchange="this.form.submit()"
{% if item.is_active %}checked{% endif %}
title="{% trans %}Cambiar estado{% endtrans %}">
</div>
</form>
{% else %}
{% if item.is_active %}
<span class="badge bg-success-subtle text-success border border-success-subtle">{% trans %}Activo{% endtrans %}</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">{% trans %}Inactivo{% endtrans %}</span>
{% endif %}
{% endif %}
</td>
<td class="text-end pe-4">
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-primary py-1 px-2 btn-edit"
data-id="{{ item.id }}"
data-name="{{ item.name }}"
data-code="{{ item.code }}"
data-color="{{ item.color or '#0d6efd' }}"
data-description="{{ item.description or '' }}"
data-sort-order="{{ item.sort_order }}"
data-is-active="{{ 'true' if item.is_active else 'false' }}">
<i class="bi bi-pencil-square me-1"></i>{% trans %}Editar{% endtrans %}
</button>
{% else %}
<span class="text-muted small"><i class="bi bi-lock me-1"></i>{% trans %}Solo lectura{% endtrans %}</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
{% trans %}No se han encontrado tipificaciones registradas.{% endtrans %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal: Nueva Tipificación -->
{% if can_edit %}
<div class="modal fade" id="modalAddType" tabindex="-1" aria-labelledby="modalAddTypeLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-body-tertiary">
<h5 class="modal-title fw-bold" id="modalAddTypeLabel">
<i class="bi bi-plus-circle text-primary me-2"></i>{% trans %}Nueva Tipificación de Hito{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('admin.milestone_type_add') }}" method="POST">
<div class="modal-body p-4">
<div class="mb-3">
<label for="add_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="add_name" name="name" placeholder="Ej: Parcial 3, TP Integrador" required>
<div class="form-text small">{% trans %}Texto que se mostrará en los selectores y tarjetas.{% endtrans %}</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label for="add_code" class="form-label fw-semibold">{% trans %}Código / Identificador{% endtrans %}</label>
<input type="text" class="form-control font-monospace" id="add_code" name="code" placeholder="ej. parcial_3">
<div class="form-text small">{% trans %}Slug único (se autogenera si se deja vacío).{% endtrans %}</div>
</div>
<div class="col-md-5">
<label for="add_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
<div class="input-group">
<input type="color" class="form-control form-control-color w-100" id="add_color" name="color" value="#0d6efd" title="Elegir color">
</div>
</div>
</div>
<!-- Paleta rápida de colores UniCABA -->
<div class="mb-3">
<label class="form-label small text-muted d-block mb-1">{% trans %}Colores sugeridos{% endtrans %}:</label>
<div class="d-flex gap-2 flex-wrap" id="presetColorsAdd">
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0d6efd;" data-color="#0d6efd"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #198754;" data-color="#198754"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #fd7e14;" data-color="#fd7e14"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #ffc107;" data-color="#ffc107"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #dc3545;" data-color="#dc3545"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6f42c1;" data-color="#6f42c1"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #0dcaf0;" data-color="#0dcaf0"></button>
<button type="button" class="btn p-0 rounded-circle border" style="width: 24px; height: 24px; background-color: #6c757d;" data-color="#6c757d"></button>
</div>
</div>
<div class="mb-3">
<label for="add_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="add_description" name="description" rows="2" placeholder="Opcional: detalles sobre el tipo de evaluación..."></textarea>
</div>
<div class="row g-3 mb-2">
<div class="col-6">
<label for="add_sort_order" class="form-label fw-semibold">{% trans %}Orden de Aparición{% endtrans %}</label>
<input type="number" class="form-control" id="add_sort_order" name="sort_order" value="10" min="0">
</div>
<div class="col-6 d-flex align-items-center pt-3">
<div class="form-check form-switch pt-2">
<input class="form-check-input" type="checkbox" id="add_is_active" name="is_active" value="1" checked>
<label class="form-check-label fw-semibold" for="add_is_active">{% trans %}Activo{% endtrans %}</label>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Crear Tipificación{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Editar Tipificación -->
<div class="modal fade" id="modalEditType" tabindex="-1" aria-labelledby="modalEditTypeLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-body-tertiary">
<h5 class="modal-title fw-bold" id="modalEditTypeLabel">
<i class="bi bi-pencil-square text-primary me-2"></i>{% trans %}Editar Tipificación{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="formEditType" method="POST">
<div class="modal-body p-4">
<div class="mb-3">
<label for="edit_name" class="form-label fw-semibold">{% trans %}Nombre Visible{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit_name" name="name" required>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label for="edit_code" class="form-label fw-semibold">{% trans %}Código / Slug{% endtrans %} <span class="text-danger">*</span></label>
<input type="text" class="form-control font-monospace" id="edit_code" name="code" required>
</div>
<div class="col-md-5">
<label for="edit_color" class="form-label fw-semibold">{% trans %}Color Distintivo{% endtrans %}</label>
<input type="color" class="form-control form-control-color w-100" id="edit_color" name="color" title="Elegir color">
</div>
</div>
<div class="mb-3">
<label for="edit_description" class="form-label fw-semibold">{% trans %}Descripción{% endtrans %}</label>
<textarea class="form-control" id="edit_description" name="description" rows="2"></textarea>
</div>
<div class="row g-3 mb-2">
<div class="col-6">
<label for="edit_sort_order" class="form-label fw-semibold">{% trans %}Orden{% endtrans %}</label>
<input type="number" class="form-control" id="edit_sort_order" name="sort_order" min="0">
</div>
<div class="col-6 d-flex align-items-center pt-3">
<div class="form-check form-switch pt-2">
<input class="form-check-input" type="checkbox" id="edit_is_active" name="is_active" value="1">
<label class="form-check-label fw-semibold" for="edit_is_active">{% trans %}Activo{% endtrans %}</label>
</div>
</div>
</div>
</div>
<div class="modal-footer bg-body-tertiary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans %}Cancelar{% endtrans %}</button>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% trans %}Guardar Cambios{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Autogenerate code slug in Add Modal
var addNameInput = document.getElementById('add_name');
var addCodeInput = document.getElementById('add_code');
if (addNameInput && addCodeInput) {
addNameInput.addEventListener('input', function() {
if (!addCodeInput.dataset.touched) {
var slug = addNameInput.value.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
addCodeInput.value = slug;
}
});
addCodeInput.addEventListener('input', function() {
addCodeInput.dataset.touched = "true";
});
}
// Color preset clicks
document.querySelectorAll('#presetColorsAdd button').forEach(function(btn) {
btn.addEventListener('click', function() {
var color = this.getAttribute('data-color');
var input = document.getElementById('add_color');
if (input) input.value = color;
});
});
// Edit modal bindings
var modalEditEl = document.getElementById('modalEditType');
var modalEdit = modalEditEl ? new bootstrap.Modal(modalEditEl) : null;
var formEdit = document.getElementById('formEditType');
document.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.getAttribute('data-id');
var name = this.getAttribute('data-name');
var code = this.getAttribute('data-code');
var color = this.getAttribute('data-color');
var description = this.getAttribute('data-description');
var sortOrder = this.getAttribute('data-sort-order');
var isActive = this.getAttribute('data-is-active') === 'true';
if (formEdit) {
formEdit.action = "{{ url_for('admin.milestone_types_list') }}/edit/" + id;
document.getElementById('edit_name').value = name;
document.getElementById('edit_code').value = code;
document.getElementById('edit_color').value = color;
document.getElementById('edit_description').value = description;
document.getElementById('edit_sort_order').value = sortOrder;
document.getElementById('edit_is_active').checked = isActive;
if (modalEdit) modalEdit.show();
}
});
});
});
</script>
{% endblock %}
+1
View File
@@ -107,6 +107,7 @@
<li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.users_list') }}"><i class="bi bi-people me-2"></i>{% trans %}Users{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.roles_list') }}"><i class="bi bi-person-badge me-2"></i>{% trans %}Roles & Groups{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('buildings.list_buildings') }}"><i class="bi bi-buildings me-2"></i>{% trans %}Buildings{% endtrans %}</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.milestone_types_list') }}"><i class="bi bi-tags me-2 text-warning"></i>{% trans %}Tipificaciones{% endtrans %}</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.google_sheets_import') }}"><i class="bi bi-file-earmark-spreadsheet me-2 text-success"></i>{% trans %}Import from Google Sheets{% endtrans %}</a></li>
</ul> </ul>
@@ -16,19 +16,10 @@
<ion-list lines="inset" class="compact-list"> <ion-list lines="inset" class="compact-list">
<ion-item class="input-item"> <ion-item class="input-item">
<ion-label position="stacked">Tipo e Hito</ion-label> <ion-label position="stacked">Tipo e Hito</ion-label>
<ion-select [(ngModel)]="tipo" interface="popover"> <ion-select [(ngModel)]="tipo" interface="popover" placeholder="Seleccionar tipo de hito">
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option> <ion-select-option *ngFor="let opt of milestoneTypes" [value]="opt.name">
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option> {{ opt.name }}
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option> </ion-select-option>
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
<ion-select-option value="Final">Final</ion-select-option>
<ion-select-option value="Otro">Otro</ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>
@@ -7,6 +7,36 @@ import { EventosService } from '../../services/eventos.service';
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar'; import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
@Component({
selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html',
styleUrls: ['./evento-modal.component.scss'],
standalone: true,
imports: [CommonModule, IonicModule, FormsModule]
})
export interface MilestoneTypeOption {
id?: number;
name: string;
code?: string;
color?: string;
is_active?: boolean;
}
export const DEFAULT_MILESTONE_TYPES: MilestoneTypeOption[] = [
{ name: 'Parcial 1', code: 'parcial_1', color: '#0d6efd' },
{ name: 'Parcial 2', code: 'parcial_2', color: '#0b5ed7' },
{ name: 'Trabajo Práctico 1', code: 'tp_1', color: '#198754' },
{ name: 'Trabajo Práctico 2', code: 'tp_2', color: '#157347' },
{ name: 'Trabajo Práctico 3', code: 'tp_3', color: '#0f5132' },
{ name: 'Recuperatorio Parcial 1', code: 'recu_p1', color: '#fd7e14' },
{ name: 'Recuperatorio Parcial 2', code: 'recu_p2', color: '#e76b08' },
{ name: 'Recuperatorio TP 1', code: 'recu_tp1', color: '#ffc107' },
{ name: 'Recuperatorio TP 2', code: 'recu_tp2', color: '#e0a800' },
{ name: 'Recuperatorio TP 3', code: 'recu_tp3', color: '#c69500' },
{ name: 'Final', code: 'final', color: '#dc3545' },
{ name: 'Otro', code: 'otro', color: '#6c757d' }
];
@Component({ @Component({
selector: 'app-evento-modal', selector: 'app-evento-modal',
templateUrl: './evento-modal.component.html', templateUrl: './evento-modal.component.html',
@@ -19,7 +49,8 @@ export class EventoModalComponent implements OnInit {
@Input() materiaNombre!: string; @Input() materiaNombre!: string;
@Input() eventoExistente?: EventoAcademico; @Input() eventoExistente?: EventoAcademico;
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial'; tipo: string = 'Parcial 1';
milestoneTypes: MilestoneTypeOption[] = DEFAULT_MILESTONE_TYPES;
fecha: string = new Date().toISOString(); fecha: string = new Date().toISOString();
aula: string = ''; aula: string = '';
link: string = ''; link: string = '';
@@ -34,7 +65,9 @@ export class EventoModalComponent implements OnInit {
private toastCtrl: ToastController private toastCtrl: ToastController
) { } ) { }
ngOnInit() { async ngOnInit() {
await this.cargarTipificaciones();
if (this.eventoExistente) { if (this.eventoExistente) {
this.isEdit = true; this.isEdit = true;
this.tipo = this.eventoExistente.tipo; this.tipo = this.eventoExistente.tipo;
@@ -45,6 +78,20 @@ export class EventoModalComponent implements OnInit {
} }
} }
async cargarTipificaciones() {
try {
const response = await fetch('/api/milestone-types');
if (response.ok) {
const data: MilestoneTypeOption[] = await response.json();
if (Array.isArray(data) && data.length > 0) {
this.milestoneTypes = data.filter(d => d.is_active !== false);
}
}
} catch {
// Mantiene catálogo predeterminado si no hay conexión al backend
}
}
dismiss() { dismiss() {
this.modalCtrl.dismiss(); this.modalCtrl.dismiss();
} }
@@ -18,7 +18,7 @@ export interface ClaseHorario {
export interface EventoAcademico { export interface EventoAcademico {
id: string; id: string;
materiaCodigo: string; materiaCodigo: string;
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro'; tipo: string; // Dinámico según catálogo MilestoneType
fecha: string; // ISO string fecha: string; // ISO string
aula?: string; aula?: string;
link?: string; link?: string;
+31 -4
View File
@@ -76,7 +76,8 @@ def init_database():
'academic': 'read_write', 'academic': 'read_write',
'import': 'read_write', 'import': 'read_write',
'optimizer': 'read_write', 'optimizer': 'read_write',
'users': 'read_write' 'users': 'read_write',
'milestone_types': 'read_write'
} }
}, },
{ {
@@ -91,7 +92,8 @@ def init_database():
'academic': 'read', 'academic': 'read',
'import': 'none', 'import': 'none',
'optimizer': 'read', 'optimizer': 'read',
'users': 'none' 'users': 'none',
'milestone_types': 'read'
} }
}, },
{ {
@@ -106,7 +108,8 @@ def init_database():
'academic': 'read_write', 'academic': 'read_write',
'import': 'read_write', 'import': 'read_write',
'optimizer': 'none', 'optimizer': 'none',
'users': 'none' 'users': 'none',
'milestone_types': 'read_write'
} }
}, },
{ {
@@ -121,7 +124,8 @@ def init_database():
'academic': 'read', 'academic': 'read',
'import': 'none', 'import': 'none',
'optimizer': 'none', 'optimizer': 'none',
'users': 'none' 'users': 'none',
'milestone_types': 'read'
} }
} }
] ]
@@ -216,6 +220,29 @@ def init_database():
db.session.commit() db.session.commit()
print("[OK] Edificios institucionales configurados con dirección oficial.") print("[OK] Edificios institucionales configurados con dirección oficial.")
# 6. Inicializar catálogo dinámico de tipificaciones de hitos académicos
print("[*] Verificando catálogo de tipificaciones de hitos académicos...")
from app.models.milestone import MilestoneType, BASE_MILESTONE_TYPES
for item in BASE_MILESTONE_TYPES:
mt = MilestoneType.query.filter_by(code=item['code']).first()
if not mt:
mt = MilestoneType(
name=item['name'],
code=item['code'],
color=item['color'],
description=item['description'],
sort_order=item['sort_order'],
is_active=True
)
db.session.add(mt)
else:
if not mt.color:
mt.color = item['color']
if not mt.sort_order:
mt.sort_order = item['sort_order']
db.session.commit()
print(f"[OK] Catálogo de tipificaciones inicializado ({MilestoneType.query.count()} tipos disponibles).")
print("\n[LISTO] Base de datos inicializada y lista para usar.") print("\n[LISTO] Base de datos inicializada y lista para usar.")
except Exception as e: except Exception as e: