feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -134,4 +134,25 @@ def set_language(language=None):
|
|||||||
current_user.preferred_language = language
|
current_user.preferred_language = language
|
||||||
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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
+4
-13
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+49
-2
@@ -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
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -215,6 +219,29 @@ def init_database():
|
|||||||
db.session.add(b_virtual)
|
db.session.add(b_virtual)
|
||||||
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.")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user