From 291e53757402523451a39c0a9f208a8d9c06ce26 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Thu, 3 Sep 2026 03:48:49 -0300 Subject: [PATCH] feat: catalogo dinamico de tipificaciones de hitos, matriz RBAC y desacoplamiento en modal de eventos --- CHANGELOG_SESSION.md | 18 + app/models/__init__.py | 6 +- app/models/milestone.py | 176 ++++++++++ app/models/role.py | 7 + app/models/user.py | 7 +- app/routes/admin.py | 135 ++++++++ app/routes/main.py | 23 +- app/templates/admin/milestone_types/list.html | 322 ++++++++++++++++++ app/templates/base.html | 1 + .../evento-modal/evento-modal.component.html | 17 +- .../evento-modal/evento-modal.component.ts | 51 ++- .../src/app/services/data.ts | 2 +- init_db.py | 35 +- 13 files changed, 777 insertions(+), 23 deletions(-) create mode 100644 app/models/milestone.py create mode 100644 app/templates/admin/milestone_types/list.html diff --git a/CHANGELOG_SESSION.md b/CHANGELOG_SESSION.md index 1a7bce9..f1d9594 100644 --- a/CHANGELOG_SESSION.md +++ b/CHANGELOG_SESSION.md @@ -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)):** * 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. + + diff --git a/app/models/__init__.py b/app/models/__init__.py index 2547beb..ea165db 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,7 @@ from .subject import Subject, Commission from .reservation import Reservation, ReservationStatus from .genetic_algorithm import GeneticAlgorithm, ReservationOptimizer from .role import Role, Permission, SYSTEM_MODULES +from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES __all__ = [ 'User', @@ -21,5 +22,8 @@ __all__ = [ 'ReservationOptimizer', 'Role', 'Permission', - 'SYSTEM_MODULES' + 'SYSTEM_MODULES', + 'MilestoneType', + 'AcademicMilestone', + 'BASE_MILESTONE_TYPES' ] \ No newline at end of file diff --git a/app/models/milestone.py b/app/models/milestone.py new file mode 100644 index 0000000..f17b0d5 --- /dev/null +++ b/app/models/milestone.py @@ -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'' + + +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'' + + +# 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 + } +] diff --git a/app/models/role.py b/app/models/role.py index 8acee65..271527c 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -58,6 +58,13 @@ SYSTEM_MODULES = [ 'name_en': 'Users & Access RBAC', 'icon': 'bi-shield-lock', '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.' } ] diff --git a/app/models/user.py b/app/models/user.py index 2b454f7..1374776 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -55,7 +55,12 @@ class User(UserMixin, db.Model): def can_manage(self): """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): diff --git a/app/routes/admin.py b/app/routes/admin.py index f61a857..fa8a276 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -4,8 +4,10 @@ from flask_babel import gettext as _ from app import db from app.models.user import User 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 sqlalchemy import or_ +import re admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -340,3 +342,136 @@ def google_sheets_import(): 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/', 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/', 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]) + + diff --git a/app/routes/main.py b/app/routes/main.py index 8f64b91..29bb092 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -134,4 +134,25 @@ def set_language(language=None): current_user.preferred_language = language db.session.commit() - return redirect(request.referrer or url_for('main.dashboard')) \ No newline at end of file + 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]) \ No newline at end of file diff --git a/app/templates/admin/milestone_types/list.html b/app/templates/admin/milestone_types/list.html new file mode 100644 index 0000000..41ee5cf --- /dev/null +++ b/app/templates/admin/milestone_types/list.html @@ -0,0 +1,322 @@ +{% extends "base.html" %} + +{% block title %}{% trans %}Tipificaciones de Hitos y Categorías{% endtrans %} - Edu-Space{% endblock %} + +{% block content %} +
+ +
+
+ +

+ {% trans %}Tipificaciones de Hitos Académicos{% endtrans %} +

+

+ {% trans %}Catálogo dinámico y configurable de tipos de evaluaciones, parciales, TPs y eventos para el calendario institucional.{% endtrans %} +

+
+
+ {% if can_edit %} + + {% endif %} +
+
+ + +
+ +
+ {% 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 %} +
+
+ + +
+
+ + + + + + + + + + + + + + {% for item in types %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{% trans %}Color{% endtrans %}{% trans %}Nombre Visible{% endtrans %}{% trans %}Código / Slug{% endtrans %}{% trans %}Descripción{% endtrans %}{% trans %}Orden{% endtrans %}{% trans %}Estado{% endtrans %}{% trans %}Acciones{% endtrans %}
+ + +
{{ item.name }}
+ + {{ item.name }} + +
+ {{ item.code }} + + {{ item.description or '—' }} + + #{{ item.sort_order }} + + {% if can_edit %} +
+
+ +
+
+ {% else %} + {% if item.is_active %} + {% trans %}Activo{% endtrans %} + {% else %} + {% trans %}Inactivo{% endtrans %} + {% endif %} + {% endif %} +
+ {% if can_edit %} + + {% else %} + {% trans %}Solo lectura{% endtrans %} + {% endif %} +
+ + {% trans %}No se han encontrado tipificaciones registradas.{% endtrans %} +
+
+
+
+ + +{% if can_edit %} + + + + +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index cc6fa38..2209268 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -107,6 +107,7 @@
  • {% trans %}Users{% endtrans %}
  • {% trans %}Roles & Groups{% endtrans %}
  • {% trans %}Buildings{% endtrans %}
  • +
  • {% trans %}Tipificaciones{% endtrans %}
  • {% trans %}Import from Google Sheets{% endtrans %}
  • diff --git a/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html b/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html index a33be41..1e26151 100644 --- a/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html +++ b/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.html @@ -16,19 +16,10 @@ Tipo e Hito - - Parcial 1 - Parcial 2 - Trabajo Práctico 1 - Trabajo Práctico 2 - Trabajo Práctico 3 - Recuperatorio Parcial 1 - Recuperatorio Parcial 2 - Recuperatorio TP 1 - Recuperatorio TP 2 - Recuperatorio TP 3 - Final - Otro + + + {{ opt.name }} + diff --git a/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.ts b/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.ts index 1fe1231..f4a1f78 100644 --- a/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.ts +++ b/example/aulas-unicaba-mvp/src/app/components/evento-modal/evento-modal.component.ts @@ -7,6 +7,36 @@ import { EventosService } from '../../services/eventos.service'; 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({ selector: 'app-evento-modal', templateUrl: './evento-modal.component.html', @@ -19,7 +49,8 @@ export class EventoModalComponent implements OnInit { @Input() materiaNombre!: string; @Input() eventoExistente?: EventoAcademico; - tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial'; + tipo: string = 'Parcial 1'; + milestoneTypes: MilestoneTypeOption[] = DEFAULT_MILESTONE_TYPES; fecha: string = new Date().toISOString(); aula: string = ''; link: string = ''; @@ -34,7 +65,9 @@ export class EventoModalComponent implements OnInit { private toastCtrl: ToastController ) { } - ngOnInit() { + async ngOnInit() { + await this.cargarTipificaciones(); + if (this.eventoExistente) { this.isEdit = true; 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() { this.modalCtrl.dismiss(); } diff --git a/example/aulas-unicaba-mvp/src/app/services/data.ts b/example/aulas-unicaba-mvp/src/app/services/data.ts index 7f6bdec..8cd2703 100644 --- a/example/aulas-unicaba-mvp/src/app/services/data.ts +++ b/example/aulas-unicaba-mvp/src/app/services/data.ts @@ -18,7 +18,7 @@ export interface ClaseHorario { export interface EventoAcademico { id: string; materiaCodigo: string; - tipo: 'Parcial' | 'TP' | 'Final' | 'Otro'; + tipo: string; // Dinámico según catálogo MilestoneType fecha: string; // ISO string aula?: string; link?: string; diff --git a/init_db.py b/init_db.py index f04a82e..c696cdc 100644 --- a/init_db.py +++ b/init_db.py @@ -76,7 +76,8 @@ def init_database(): 'academic': 'read_write', 'import': 'read_write', 'optimizer': 'read_write', - 'users': 'read_write' + 'users': 'read_write', + 'milestone_types': 'read_write' } }, { @@ -91,7 +92,8 @@ def init_database(): 'academic': 'read', 'import': 'none', 'optimizer': 'read', - 'users': 'none' + 'users': 'none', + 'milestone_types': 'read' } }, { @@ -106,7 +108,8 @@ def init_database(): 'academic': 'read_write', 'import': 'read_write', 'optimizer': 'none', - 'users': 'none' + 'users': 'none', + 'milestone_types': 'read_write' } }, { @@ -121,7 +124,8 @@ def init_database(): 'academic': 'read', 'import': 'none', 'optimizer': 'none', - 'users': 'none' + 'users': 'none', + 'milestone_types': 'read' } } ] @@ -215,6 +219,29 @@ def init_database(): db.session.add(b_virtual) db.session.commit() 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.")