feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
from .user import User
|
||||
from .building import Building
|
||||
from .classroom import Classroom, ClassroomResource
|
||||
from .career import Career
|
||||
from .academic_term import AcademicTerm
|
||||
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
|
||||
from .audit_log import AuditLog
|
||||
from .enrollment import StudentEnrollment
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
'Building',
|
||||
'Classroom',
|
||||
'ClassroomResource',
|
||||
'Career',
|
||||
'AcademicTerm',
|
||||
'Subject',
|
||||
'Commission',
|
||||
'Reservation',
|
||||
'ReservationStatus',
|
||||
'GeneticAlgorithm',
|
||||
'ReservationOptimizer',
|
||||
'Role',
|
||||
'Permission',
|
||||
'SYSTEM_MODULES',
|
||||
'MilestoneType',
|
||||
'AcademicMilestone',
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment'
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AcademicTerm(db.Model):
|
||||
"""Modelo para Ciclos Lectivos y Períodos Académicos"""
|
||||
__tablename__ = 'academic_terms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True) # ej. 2026-2, 2026-1, 2025-2
|
||||
name = db.Column(db.String(150), nullable=False) # ej. Ciclo Lectivo 2026 - 2º Cuatrimestre
|
||||
year = db.Column(db.Integer, nullable=False, default=2026, index=True)
|
||||
period_type = db.Column(db.String(50), nullable=False, default='Segundo Cuatrimestre') # Primer Cuatrimestre, Segundo Cuatrimestre, Anual, Verano
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
is_current = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'year': self.year,
|
||||
'period_type': self.period_type,
|
||||
'start_date': self.start_date.isoformat() if self.start_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'is_current': self.is_current,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AcademicTerm {self.code} - {self.name}>'
|
||||
@@ -0,0 +1,25 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""
|
||||
Bitácora inmutable de auditoría para registro de eventos críticos
|
||||
y trazabilidad de operaciones institucionales.
|
||||
"""
|
||||
__tablename__ = 'audit_logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_email = db.Column(db.String(255), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, LOGOUT
|
||||
module = db.Column(db.String(50), nullable=False, index=True) # classrooms, reservations, auth, users
|
||||
entity_id = db.Column(db.Integer, nullable=True) # ID del registro afectado
|
||||
details = db.Column(db.Text, nullable=True) # Detalles de cambios o parámetros
|
||||
ip_address = db.Column(db.String(45), nullable=True) # Soporta IPv4 e IPv6
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relación con usuario
|
||||
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('audit_records', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog #{self.id} {self.action} on {self.module} by {self.user_email or 'System'}>"
|
||||
@@ -0,0 +1,45 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Building(db.Model):
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=True)
|
||||
address = db.Column(db.String(255), nullable=True)
|
||||
floors = db.Column(db.Integer, default=1, nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
classrooms = db.relationship('Classroom', backref='building_entity', lazy=True, foreign_keys='Classroom.building_id')
|
||||
|
||||
@property
|
||||
def total_capacity(self):
|
||||
"""Total student capacity across active classrooms in this building"""
|
||||
return sum(c.capacity for c in self.classrooms if c.is_active)
|
||||
|
||||
@property
|
||||
def classrooms_count(self):
|
||||
"""Number of active classrooms"""
|
||||
return len([c for c in self.classrooms if c.is_active])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'address': self.address,
|
||||
'floors': self.floors,
|
||||
'description': self.description,
|
||||
'is_active': self.is_active,
|
||||
'classrooms_count': self.classrooms_count,
|
||||
'total_capacity': self.total_capacity,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Building {self.name}>'
|
||||
@@ -0,0 +1,27 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Career(db.Model):
|
||||
__tablename__ = 'careers'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(150), unique=True, nullable=False, index=True)
|
||||
code = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
subjects = db.relationship('Subject', backref='career_obj', lazy=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'subjects_count': len(self.subjects) if self.subjects else 0,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Career {self.name}>'
|
||||
@@ -0,0 +1,217 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Classroom(db.Model):
|
||||
__tablename__ = 'classrooms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
campus_id = db.Column(db.Integer, nullable=True) # Maps to campus_id in DB
|
||||
building_id = db.Column(db.Integer, db.ForeignKey('buildings.id'), nullable=True)
|
||||
building = db.Column(db.String(100), nullable=False)
|
||||
code = db.Column('room_number', db.String(50), nullable=False) # Maps to room_number column in DB
|
||||
floor = db.Column(db.String(50), nullable=False, default='1')
|
||||
capacity = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relación con Building (mapeada vía backref 'building_entity' en Building.classrooms)
|
||||
@property
|
||||
def building_rel(self):
|
||||
return self.building_entity
|
||||
|
||||
@property
|
||||
def building_name(self):
|
||||
"""Nombre del edificio desacoplado"""
|
||||
if self.building_rel:
|
||||
return self.building_rel.name
|
||||
return self.building or 'Edificio Central'
|
||||
|
||||
@property
|
||||
def floor_display(self):
|
||||
"""Formato amigable para el piso: PB, Planta Baja, Piso X"""
|
||||
f = str(self.floor).strip() if self.floor is not None else ''
|
||||
if f in ['0', 'PB', 'pb', 'Planta Baja', 'planta baja']:
|
||||
return 'Planta Baja'
|
||||
if f.lower().startswith('piso') or '°' in f:
|
||||
return f
|
||||
return f"Piso {f}"
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Indica si el aula es virtual"""
|
||||
return (self.code and 'VIRTUAL' in self.code.upper()) or (self.building and 'VIRTUAL' in self.building.upper())
|
||||
|
||||
@property
|
||||
def location_display(self):
|
||||
"""Ubicación completa y limpia: Edificio · Piso · Aula"""
|
||||
if self.is_virtual:
|
||||
return '🌐 Campus Virtual'
|
||||
return f"{self.building_name} · {self.floor_display} · {self.code}"
|
||||
|
||||
# Virtual properties for compatibility
|
||||
@property
|
||||
def name(self):
|
||||
"""Generate name from building and code"""
|
||||
return f"{self.building_name}-{self.code}"
|
||||
|
||||
@property
|
||||
def campus_location(self):
|
||||
"""Placeholder for campus location"""
|
||||
return self.building_rel.address if self.building_rel else None
|
||||
|
||||
# Virtual field for compatibility - combines building and code
|
||||
@property
|
||||
def room_number(self):
|
||||
return self.code
|
||||
|
||||
@room_number.setter
|
||||
def room_number(self, value):
|
||||
self.code = value
|
||||
|
||||
# Override code property for consistency
|
||||
@property
|
||||
def code_display(self):
|
||||
return f"{self.building_name} - {self.code}"
|
||||
|
||||
@property
|
||||
def capacity_display(self):
|
||||
"""Retorna '∞' para aulas virtuales o capacidad ilimitada, o el número entero."""
|
||||
if self.capacity == 0 or self.is_virtual:
|
||||
return '∞'
|
||||
return str(self.capacity)
|
||||
|
||||
@property
|
||||
def is_unlimited(self):
|
||||
return self.capacity == 0 or self.is_virtual
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert classroom to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'building_id': self.building_id,
|
||||
'building': self.building_name,
|
||||
'code': self.code,
|
||||
'code_display': self.code_display,
|
||||
'name': self.name,
|
||||
'floor': self.floor,
|
||||
'floor_display': self.floor_display,
|
||||
'location_display': self.location_display,
|
||||
'capacity': self.capacity,
|
||||
'capacity_display': self.capacity_display,
|
||||
'is_virtual': self.is_virtual,
|
||||
'description': self.description,
|
||||
'campus_location': self.campus_location,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'resources': [resource.to_dict() for resource in self.resources] if hasattr(self, 'resources') else []
|
||||
}
|
||||
|
||||
def get_full_location(self):
|
||||
"""Get full classroom location"""
|
||||
if self.is_virtual:
|
||||
return 'Campus Virtual (Remoto)'
|
||||
return f"{self.building_name} - {self.floor_display}"
|
||||
|
||||
@staticmethod
|
||||
def find_best_classroom(max_students, start_time, end_time, exclude_reservation_id=None):
|
||||
"""Find the best suitable classroom for a commission based on capacity and availability"""
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
# Get all active classrooms with sufficient capacity
|
||||
suitable_classrooms = Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.capacity >= max_students
|
||||
).order_by(Classroom.capacity.asc()).all() # Start with smallest suitable
|
||||
|
||||
# Check availability for each classroom
|
||||
available_classrooms = []
|
||||
for classroom in suitable_classrooms:
|
||||
# Check for conflicts
|
||||
conflicts = Reservation.find_conflicts(
|
||||
classroom.id, start_time, end_time, exclude_reservation_id
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
# Calculate efficiency score (how well the capacity matches)
|
||||
efficiency = classroom.capacity / max_students if max_students > 0 else float('inf')
|
||||
available_classrooms.append({
|
||||
'classroom': classroom,
|
||||
'efficiency': efficiency,
|
||||
'wasted_capacity': classroom.capacity - max_students
|
||||
})
|
||||
|
||||
# Sort by efficiency (closest capacity match first)
|
||||
available_classrooms.sort(key=lambda x: x['efficiency'])
|
||||
|
||||
return available_classrooms
|
||||
|
||||
@staticmethod
|
||||
def get_availability_status(start_time, end_time, exclude_reservation_id=None):
|
||||
"""Get all classrooms classified as available or occupied for a time slot"""
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
available = []
|
||||
occupied = []
|
||||
|
||||
for classroom in all_classrooms:
|
||||
conflicts = Reservation.find_conflicts(
|
||||
classroom.id, start_time, end_time, exclude_reservation_id
|
||||
)
|
||||
|
||||
classroom_info = {
|
||||
'classroom': classroom,
|
||||
'conflicts': conflicts
|
||||
}
|
||||
|
||||
if conflicts:
|
||||
occupied.append(classroom_info)
|
||||
else:
|
||||
available.append(classroom_info)
|
||||
|
||||
return {
|
||||
'available': available,
|
||||
'occupied': occupied,
|
||||
'total_available': len(available),
|
||||
'total_occupied': len(occupied)
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Classroom {self.code_display}>'
|
||||
|
||||
class ClassroomResource(db.Model):
|
||||
__tablename__ = 'classroom_resources'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
|
||||
resource_type = db.Column(db.String(50), nullable=False) # PROJECTOR, WHITEBOARD, COMPUTER, etc.
|
||||
brand = db.Column(db.String(100))
|
||||
model = db.Column(db.String(100))
|
||||
specifications = db.Column(db.Text)
|
||||
functional = db.Column(db.Boolean, default=True)
|
||||
last_maintenance_date = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('resources', lazy=True, cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert resource to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'classroom_id': self.classroom_id,
|
||||
'resource_type': self.resource_type,
|
||||
'brand': self.brand,
|
||||
'model': self.model,
|
||||
'specifications': self.specifications,
|
||||
'functional': self.functional,
|
||||
'last_maintenance_date': self.last_maintenance_date.isoformat() if self.last_maintenance_date else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Resource {self.resource_type} for Classroom {self.classroom_id}>'
|
||||
@@ -0,0 +1,49 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StudentEnrollment(db.Model):
|
||||
"""Association table: student (User) ↔ Commission enrollment managed by Bedelía."""
|
||||
__tablename__ = 'student_enrollments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
commission_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('commissions.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
status = db.Column(db.String(20), nullable=False, default='activo') # activo | retirado | condicional
|
||||
enrolled_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Unique constraint: a student can only be enrolled once per commission
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('student_id', 'commission_id', name='uq_student_commission'),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
student = db.relationship('User', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('enrollments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'commission_id': self.commission_id,
|
||||
'status': self.status,
|
||||
'enrolled_at': self.enrolled_at.isoformat() if self.enrolled_at else None,
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<StudentEnrollment student={self.student_id} commission={self.commission_id} status={self.status}>'
|
||||
@@ -0,0 +1,435 @@
|
||||
import random
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from app import db
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.subject import Commission
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReservationRequest:
|
||||
"""Represents a reservation request from the teaching committee"""
|
||||
commission_id: int
|
||||
expected_attendees: int
|
||||
purpose: str
|
||||
preferred_start_time: datetime
|
||||
preferred_end_time: datetime
|
||||
priority: int = 1 # 1=highest, 5=lowest
|
||||
flexibility_hours: int = 2 # How flexible the start time can be
|
||||
subject_requirements: Dict = None # Special requirements for the subject
|
||||
|
||||
def __post_init__(self):
|
||||
if self.subject_requirements is None:
|
||||
self.subject_requirements = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Gene:
|
||||
"""Represents a single reservation assignment"""
|
||||
commission_id: int
|
||||
classroom_id: int
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
expected_attendees: int
|
||||
purpose: str
|
||||
|
||||
|
||||
class Individual:
|
||||
"""Represents a complete reservation schedule (chromosome)"""
|
||||
|
||||
def __init__(self, genes: List[Gene]):
|
||||
self.genes = genes
|
||||
self.fitness = 0.0
|
||||
self.conflicts = []
|
||||
|
||||
def calculate_fitness(self, classrooms: Dict[int, Classroom], requests: Dict[int, ReservationRequest]) -> float:
|
||||
"""Calculate fitness score based on multiple factors with fast O(K log K) interval conflict detection"""
|
||||
score = 0.0
|
||||
self.conflicts = []
|
||||
|
||||
# Pre-group genes by classroom to calculate conflicts in O(K log K)
|
||||
by_classroom = {}
|
||||
for g in self.genes:
|
||||
by_classroom.setdefault(g.classroom_id, []).append(g)
|
||||
|
||||
conflict_count_by_id = {}
|
||||
for cid, room_genes in by_classroom.items():
|
||||
if len(room_genes) <= 1:
|
||||
continue
|
||||
# Sort chronologically
|
||||
room_genes.sort(key=lambda x: x.start_time)
|
||||
for i in range(len(room_genes)):
|
||||
g_i = room_genes[i]
|
||||
for j in range(i + 1, len(room_genes)):
|
||||
g_j = room_genes[j]
|
||||
if g_i.end_time > g_j.start_time:
|
||||
conflict_count_by_id[id(g_i)] = conflict_count_by_id.get(id(g_i), 0) + 1
|
||||
conflict_count_by_id[id(g_j)] = conflict_count_by_id.get(id(g_j), 0) + 1
|
||||
else:
|
||||
break # Gene j and subsequent ones start later than gene i ends
|
||||
|
||||
for gene in self.genes:
|
||||
classroom = classrooms.get(gene.classroom_id)
|
||||
request = requests.get(gene.commission_id)
|
||||
|
||||
if not classroom or not request:
|
||||
continue
|
||||
|
||||
# Factor 1: Capacity efficiency (40% weight)
|
||||
capacity_score = self._calculate_capacity_score(classroom, gene.expected_attendees)
|
||||
score += capacity_score * 0.4
|
||||
|
||||
# Factor 2: Time preference satisfaction (30% weight)
|
||||
time_score = self._calculate_time_score(gene, request)
|
||||
score += time_score * 0.3
|
||||
|
||||
# Factor 3: Conflict penalty (20% weight)
|
||||
c_count = conflict_count_by_id.get(id(gene), 0)
|
||||
if c_count > 0:
|
||||
conflict_score = -c_count * 0.5
|
||||
self.conflicts.append({
|
||||
'gene': gene,
|
||||
'conflict_type': 'time_overlap'
|
||||
})
|
||||
else:
|
||||
conflict_score = 1.0
|
||||
score += conflict_score * 0.2
|
||||
|
||||
# Factor 4: Resource matching (10% weight)
|
||||
resource_score = self._calculate_resource_score(classroom, request.subject_requirements)
|
||||
score += resource_score * 0.1
|
||||
|
||||
self.fitness = score
|
||||
return score
|
||||
|
||||
def _calculate_capacity_score(self, classroom: Classroom, expected_attendees: int) -> float:
|
||||
"""Calculate how well the classroom capacity matches the expected attendees"""
|
||||
if classroom.capacity < expected_attendees:
|
||||
return 0.0 # Overfilled classroom
|
||||
|
||||
# Calculate efficiency: closer capacity match = higher score
|
||||
waste_ratio = (classroom.capacity - expected_attendees) / max(1, expected_attendees)
|
||||
if waste_ratio <= 0.1: # Less than 10% waste
|
||||
return 1.0
|
||||
elif waste_ratio <= 0.3: # Less than 30% waste
|
||||
return 0.8
|
||||
elif waste_ratio <= 0.5: # Less than 50% waste
|
||||
return 0.6
|
||||
else:
|
||||
return 0.4
|
||||
|
||||
def _calculate_time_score(self, gene: Gene, request: ReservationRequest) -> float:
|
||||
"""Calculate how well the assigned time matches preferences"""
|
||||
# Calculate time difference from preferred time
|
||||
time_diff = abs((gene.start_time - request.preferred_start_time).total_seconds() / 3600)
|
||||
|
||||
if time_diff <= 0.5: # Within 30 minutes
|
||||
return 1.0
|
||||
elif time_diff <= request.flexibility_hours: # Within flexible window
|
||||
return 0.8 - (time_diff / max(1, request.flexibility_hours)) * 0.3
|
||||
else:
|
||||
return max(0.0, 0.5 - (time_diff - request.flexibility_hours) * 0.1)
|
||||
|
||||
def _calculate_conflict_penalty(self, gene: Gene, all_genes: List[Gene]) -> float:
|
||||
"""Check for time conflicts in the same classroom (kept for backward compatibility)"""
|
||||
conflicts = 0
|
||||
for other in all_genes:
|
||||
if gene != other and gene.classroom_id == other.classroom_id:
|
||||
if self._times_overlap(gene.start_time, gene.end_time, other.start_time, other.end_time):
|
||||
conflicts += 1
|
||||
|
||||
if conflicts > 0:
|
||||
return -conflicts * 0.5 # Penalty for each conflict
|
||||
return 1.0
|
||||
|
||||
def _calculate_resource_score(self, classroom: Classroom, requirements: Dict) -> float:
|
||||
"""Check if classroom meets subject requirements"""
|
||||
if not requirements:
|
||||
return 1.0
|
||||
return 0.8
|
||||
|
||||
def _times_overlap(self, start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
|
||||
"""Check if two time periods overlap"""
|
||||
return start1 < end2 and end1 > start2
|
||||
|
||||
|
||||
class GeneticAlgorithm:
|
||||
"""Main genetic algorithm for room reservation optimization with concurrency and progress tracking"""
|
||||
|
||||
def __init__(self, population_size: int = 50, generations: int = 100,
|
||||
mutation_rate: float = 0.1, crossover_rate: float = 0.8,
|
||||
workers: int = 4):
|
||||
self.population_size = population_size
|
||||
self.generations = generations
|
||||
self.mutation_rate = mutation_rate
|
||||
self.crossover_rate = crossover_rate
|
||||
self.workers = max(1, workers)
|
||||
|
||||
def optimize_reservations(self, requests: List[ReservationRequest],
|
||||
available_classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime,
|
||||
progress_callback=None) -> Individual:
|
||||
"""Run genetic algorithm with high concurrency to find optimal reservation schedule"""
|
||||
import concurrent.futures
|
||||
|
||||
classrooms_dict = {c.id: c for c in available_classrooms}
|
||||
requests_dict = {r.commission_id: r for r in requests}
|
||||
|
||||
# Initialize population
|
||||
population = self._initialize_population(requests, available_classrooms, start_date, end_date)
|
||||
|
||||
# Evolve population
|
||||
for generation in range(self.generations):
|
||||
# Calculate fitness for all individuals concurrently when workers > 1
|
||||
if self.workers > 1 and len(population) > 10:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=self.workers) as executor:
|
||||
futures = [executor.submit(ind.calculate_fitness, classrooms_dict, requests_dict) for ind in population]
|
||||
concurrent.futures.wait(futures)
|
||||
else:
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
|
||||
# Sort by fitness (best first)
|
||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||
|
||||
# Report progress if callback is provided
|
||||
if progress_callback:
|
||||
pct = int(((generation + 1) / self.generations) * 100)
|
||||
best_fit = population[0].fitness if population else 0.0
|
||||
progress_callback(generation + 1, self.generations, pct, best_fit)
|
||||
|
||||
# Create new generation
|
||||
new_population = []
|
||||
|
||||
# Elitism: keep best 10%
|
||||
elite_size = max(2, int(self.population_size * 0.1))
|
||||
new_population.extend(population[:elite_size])
|
||||
|
||||
# Crossover and mutation for remaining
|
||||
while len(new_population) < self.population_size:
|
||||
if random.random() < self.crossover_rate:
|
||||
parent1 = self._tournament_selection(population)
|
||||
parent2 = self._tournament_selection(population)
|
||||
child1, child2 = self._crossover(parent1, parent2)
|
||||
|
||||
# Mutate children
|
||||
if random.random() < self.mutation_rate:
|
||||
child1 = self._mutate(child1, available_classrooms, start_date, end_date)
|
||||
if random.random() < self.mutation_rate:
|
||||
child2 = self._mutate(child2, available_classrooms, start_date, end_date)
|
||||
|
||||
new_population.extend([child1, child2])
|
||||
else:
|
||||
# Direct copy with potential mutation
|
||||
selected = self._tournament_selection(population)
|
||||
if random.random() < self.mutation_rate:
|
||||
selected = self._mutate(selected, available_classrooms, start_date, end_date)
|
||||
new_population.append(selected)
|
||||
|
||||
population = new_population[:self.population_size]
|
||||
|
||||
# Calculate fitness for final generation
|
||||
for individual in population:
|
||||
individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
population.sort(key=lambda x: x.fitness, reverse=True)
|
||||
return population[0]
|
||||
|
||||
def _initialize_population(self, requests: List[ReservationRequest],
|
||||
classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> List[Individual]:
|
||||
"""Create initial population with random assignments"""
|
||||
population = []
|
||||
|
||||
for _ in range(self.population_size):
|
||||
genes = []
|
||||
for request in requests:
|
||||
# Random classroom selection (with capacity constraint)
|
||||
suitable_classrooms = [c for c in classrooms if c.capacity >= request.expected_attendees]
|
||||
if not suitable_classrooms:
|
||||
continue
|
||||
|
||||
classroom = random.choice(suitable_classrooms)
|
||||
|
||||
# Random time slot (within flexible window)
|
||||
time_offset = random.uniform(-request.flexibility_hours, request.flexibility_hours)
|
||||
start_time = request.preferred_start_time + timedelta(hours=time_offset)
|
||||
duration = request.preferred_end_time - request.preferred_start_time
|
||||
end_time = start_time + duration
|
||||
|
||||
gene = Gene(
|
||||
commission_id=request.commission_id,
|
||||
classroom_id=classroom.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
expected_attendees=request.expected_attendees,
|
||||
purpose=request.purpose
|
||||
)
|
||||
genes.append(gene)
|
||||
|
||||
population.append(Individual(genes))
|
||||
|
||||
return population
|
||||
|
||||
def _tournament_selection(self, population: List[Individual], tournament_size: int = 3) -> Individual:
|
||||
"""Select individual using tournament selection"""
|
||||
tournament = random.sample(population, min(tournament_size, len(population)))
|
||||
return max(tournament, key=lambda x: x.fitness)
|
||||
|
||||
def _crossover(self, parent1: Individual, parent2: Individual) -> Tuple[Individual, Individual]:
|
||||
"""Perform crossover between two parents"""
|
||||
# Simple one-point crossover
|
||||
if len(parent1.genes) <= 1 or len(parent2.genes) <= 1:
|
||||
return Individual(copy.deepcopy(parent1.genes)), Individual(copy.deepcopy(parent2.genes))
|
||||
|
||||
crossover_point = random.randint(1, min(len(parent1.genes), len(parent2.genes)) - 1)
|
||||
|
||||
child1_genes = copy.deepcopy(parent1.genes[:crossover_point] + parent2.genes[crossover_point:])
|
||||
child2_genes = copy.deepcopy(parent2.genes[:crossover_point] + parent1.genes[crossover_point:])
|
||||
|
||||
return Individual(child1_genes), Individual(child2_genes)
|
||||
|
||||
def _mutate(self, individual: Individual, classrooms: List[Classroom],
|
||||
start_date: datetime, end_date: datetime) -> Individual:
|
||||
"""Apply mutation to an individual"""
|
||||
if not individual.genes:
|
||||
return individual
|
||||
|
||||
# Random gene mutation
|
||||
gene_to_mutate = random.choice(individual.genes)
|
||||
|
||||
# Mutation types
|
||||
mutation_type = random.choice(['classroom', 'time'])
|
||||
|
||||
if mutation_type == 'classroom':
|
||||
# Change classroom
|
||||
suitable_classrooms = [c for c in classrooms if c.capacity >= gene_to_mutate.expected_attendees]
|
||||
if suitable_classrooms:
|
||||
gene_to_mutate.classroom_id = random.choice(suitable_classrooms).id
|
||||
|
||||
elif mutation_type == 'time':
|
||||
# Slightly adjust time
|
||||
time_adjustment = timedelta(hours=random.uniform(-1, 1))
|
||||
gene_to_mutate.start_time += time_adjustment
|
||||
gene_to_mutate.end_time += time_adjustment
|
||||
|
||||
return individual
|
||||
|
||||
|
||||
class ReservationOptimizer:
|
||||
"""High-level interface for the reservation optimization system"""
|
||||
|
||||
def __init__(self, population_size: int = 50, generations: int = 100, workers: int = 4):
|
||||
self.ga = GeneticAlgorithm(population_size=population_size, generations=generations, workers=workers)
|
||||
|
||||
def optimize_schedule(self, commission_ids: List[int],
|
||||
admin_user_id: int,
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None,
|
||||
progress_callback = None) -> Dict:
|
||||
"""Optimize reservation schedule for given commissions"""
|
||||
|
||||
# Default date range if not provided
|
||||
if not start_date:
|
||||
start_date = datetime.utcnow()
|
||||
if not end_date:
|
||||
end_date = start_date + timedelta(days=30)
|
||||
|
||||
# Get commissions and create requests
|
||||
commissions = Commission.query.filter(
|
||||
Commission.id.in_(commission_ids),
|
||||
Commission.active == True
|
||||
).all()
|
||||
|
||||
requests = []
|
||||
for commission in commissions:
|
||||
# Calculate preferred time based on semester/year
|
||||
preferred_time = self._calculate_preferred_time(commission)
|
||||
|
||||
request = ReservationRequest(
|
||||
commission_id=commission.id,
|
||||
expected_attendees=commission.max_students,
|
||||
purpose=f"Regular class - {commission.subject.name if commission.subject else 'Unknown subject'}",
|
||||
preferred_start_time=preferred_time['start'],
|
||||
preferred_end_time=preferred_time['end'],
|
||||
priority=1, # All regular classes have same priority
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(request)
|
||||
|
||||
# Get available classrooms
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Run genetic algorithm
|
||||
best_individual = self.ga.optimize_reservations(requests, classrooms, start_date, end_date, progress_callback=progress_callback)
|
||||
|
||||
# Convert to reservation format
|
||||
optimized_reservations = []
|
||||
for gene in best_individual.genes:
|
||||
reservation_data = {
|
||||
'commission_id': gene.commission_id,
|
||||
'classroom_id': gene.classroom_id,
|
||||
'user_id': admin_user_id,
|
||||
'start_time': gene.start_time.isoformat() if gene.start_time else None,
|
||||
'end_time': gene.end_time.isoformat() if gene.end_time else None,
|
||||
'purpose': gene.purpose,
|
||||
'expected_attendees': gene.expected_attendees,
|
||||
'status': ReservationStatus.PENDING.value # Convert enum to string
|
||||
}
|
||||
optimized_reservations.append(reservation_data)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'fitness_score': best_individual.fitness,
|
||||
'reservations': optimized_reservations,
|
||||
'conflicts': best_individual.conflicts,
|
||||
'total_requests': len(requests),
|
||||
'assigned_reservations': len(optimized_reservations)
|
||||
}
|
||||
|
||||
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
||||
"""Calculate preferred time slots for a commission"""
|
||||
# This is a simplified version - would need to be based on actual semester schedule
|
||||
current_date = datetime.utcnow()
|
||||
|
||||
# Default to weekday mornings for regular classes
|
||||
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
|
||||
if days_ahead == 0: # If today is Monday, use next week
|
||||
days_ahead = 7
|
||||
|
||||
preferred_date = current_date + timedelta(days=days_ahead)
|
||||
|
||||
return {
|
||||
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
}
|
||||
|
||||
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
||||
"""Apply the optimized reservations to the database"""
|
||||
applied_reservations = []
|
||||
|
||||
for data in reservation_data:
|
||||
# Check for conflicts before creating
|
||||
conflicts = Reservation.find_conflicts(
|
||||
data['classroom_id'],
|
||||
data['start_time'],
|
||||
data['end_time']
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
reservation = Reservation(**data)
|
||||
db.session.add(reservation)
|
||||
applied_reservations.append(reservation)
|
||||
else:
|
||||
# Log conflict - could be handled differently
|
||||
print(f"Conflict detected for reservation data: {data}")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
return applied_reservations
|
||||
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id', ondelete='SET NULL'), 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 y Comisión
|
||||
subject = db.relationship('Subject', backref=db.backref('milestones', lazy='dynamic'))
|
||||
commission = db.relationship('Commission', backref=db.backref('academic_milestones', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
|
||||
@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,
|
||||
'subject_id': self.subject_id,
|
||||
'commission_id': self.commission_id,
|
||||
'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 Practico 1',
|
||||
'code': 'tp_1',
|
||||
'color': '#198754',
|
||||
'description': 'Primera entrega o evaluacion practica',
|
||||
'sort_order': 3
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 2',
|
||||
'code': 'tp_2',
|
||||
'color': '#157347',
|
||||
'description': 'Segunda entrega o evaluacion practica',
|
||||
'sort_order': 4
|
||||
},
|
||||
{
|
||||
'name': 'Trabajo Practico 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 recuperacion para el Parcial 1',
|
||||
'sort_order': 6
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio Parcial 2',
|
||||
'code': 'recu_p2',
|
||||
'color': '#e76b08',
|
||||
'description': 'Instancia de recuperacion para el Parcial 2',
|
||||
'sort_order': 7
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 1',
|
||||
'code': 'recu_tp1',
|
||||
'color': '#ffc107',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 1',
|
||||
'sort_order': 8
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 2',
|
||||
'code': 'recu_tp2',
|
||||
'color': '#e0a800',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 2',
|
||||
'sort_order': 9
|
||||
},
|
||||
{
|
||||
'name': 'Recuperatorio TP 3',
|
||||
'code': 'recu_tp3',
|
||||
'color': '#c69500',
|
||||
'description': 'Reentrega o recuperacion de Trabajo Practico 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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
from app import db
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from sqlalchemy.types import TypeDecorator, String
|
||||
|
||||
class StatusString(str):
|
||||
"""String wrapper providing .value property for backward compatibility with Enum usage"""
|
||||
@property
|
||||
def value(self):
|
||||
return str(self)
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, 'value'):
|
||||
return str(self) == str(other.value)
|
||||
return str(self) == str(other)
|
||||
|
||||
class StatusType(TypeDecorator):
|
||||
impl = String(20)
|
||||
cache_ok = True
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return StatusString(value) if value is not None else None
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if hasattr(value, 'value'):
|
||||
return str(value.value)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
class ReservationStatus(Enum):
|
||||
PENDING = 'PENDING'
|
||||
CONFIRMED = 'CONFIRMED'
|
||||
CANCELLED = 'CANCELLED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
NO_SHOW = 'NO_SHOW'
|
||||
|
||||
class Reservation(db.Model):
|
||||
__tablename__ = 'reservations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
classroom_id = db.Column(db.Integer, db.ForeignKey('classrooms.id'), nullable=False)
|
||||
commission_id = db.Column(db.Integer, db.ForeignKey('commissions.id'), nullable=False)
|
||||
user_id = db.Column('teacher_id', db.Integer, db.ForeignKey('users.id'), nullable=False) # Maps to teacher_id in DB
|
||||
|
||||
start_time = db.Column(db.DateTime, nullable=False)
|
||||
end_time = db.Column(db.DateTime, nullable=False)
|
||||
purpose = db.Column(db.String(500), nullable=False)
|
||||
status = db.Column(StatusType, default='PENDING', nullable=False) # Maps to VARCHAR(20) with StatusString
|
||||
expected_attendees = db.Column(db.Integer, nullable=False)
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Zoom, Meet, Teams, etc.
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property to convert status string to enum
|
||||
@property
|
||||
def status_enum(self):
|
||||
"""Get status as enum"""
|
||||
try:
|
||||
return ReservationStatus(str(self.status))
|
||||
except (ValueError, AttributeError):
|
||||
return ReservationStatus.PENDING
|
||||
|
||||
@property
|
||||
def subject_code(self):
|
||||
"""Get exact subject code (e.g. INGA0003, ASIG00165)"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def subject_name(self):
|
||||
"""Get subject name"""
|
||||
if self.commission and self.commission.subject:
|
||||
return self.commission.subject.name
|
||||
return self.purpose or ''
|
||||
|
||||
@property
|
||||
def classroom_name(self):
|
||||
"""Get classroom code or name"""
|
||||
if self.classroom:
|
||||
return self.classroom.code
|
||||
return ''
|
||||
|
||||
@property
|
||||
def effective_virtual_link(self):
|
||||
"""Devuelve el enlace explícito de la reserva o hereda el de la comisión"""
|
||||
if self.virtual_link and self.virtual_link.strip():
|
||||
return self.virtual_link.strip()
|
||||
if self.commission and self.commission.virtual_link:
|
||||
return self.commission.virtual_link.strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
"""Verifica si la reserva es virtual por aula o por contar con enlace virtual"""
|
||||
if self.classroom and self.classroom.is_virtual:
|
||||
return True
|
||||
if self.effective_virtual_link:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
commission = db.relationship('Commission', backref=db.backref('reservation_list', lazy=True, cascade='all, delete-orphan'))
|
||||
# User already defines the relationship back to reservation
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert reservation to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'classroom_id': self.classroom_id,
|
||||
'commission_id': self.commission_id,
|
||||
'user_id': self.user_id,
|
||||
'start_time': self.start_time.isoformat() if self.start_time else None,
|
||||
'end_time': self.end_time.isoformat() if self.end_time else None,
|
||||
'purpose': self.purpose,
|
||||
'status': self.status, # Already a string in DB
|
||||
'expected_attendees': self.expected_attendees,
|
||||
'notes': self.notes,
|
||||
'virtual_link': self.effective_virtual_link,
|
||||
'is_virtual': self.is_virtual,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'classroom': self.classroom.to_dict() if self.classroom else None,
|
||||
'commission': self.commission.to_dict() if self.commission else None,
|
||||
'user': self.user.to_dict() if self.user else None
|
||||
}
|
||||
|
||||
def is_confirmed(self):
|
||||
"""Check if reservation is confirmed"""
|
||||
return self.status == ReservationStatus.CONFIRMED.value
|
||||
|
||||
def can_be_cancelled(self):
|
||||
"""Check if reservation can be cancelled (24 hours before start)"""
|
||||
from datetime import timedelta
|
||||
return self.start_time > datetime.utcnow() + timedelta(hours=24)
|
||||
|
||||
def check_conflict(self, other_reservation):
|
||||
"""Check if this reservation conflicts with another"""
|
||||
if self.classroom_id != other_reservation.classroom_id:
|
||||
return False
|
||||
if self.status != ReservationStatus.CONFIRMED.value:
|
||||
return False
|
||||
if other_reservation.status != ReservationStatus.CONFIRMED.value:
|
||||
return False
|
||||
|
||||
# Check time overlap
|
||||
return (self.start_time < other_reservation.end_time and
|
||||
self.end_time > other_reservation.start_time)
|
||||
|
||||
@staticmethod
|
||||
def find_conflicts(classroom_id, start_time, end_time, exclude_reservation_id=None):
|
||||
"""Find conflicting reservations for a specific classroom and time slot"""
|
||||
query = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom_id,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value,
|
||||
Reservation.start_time < end_time,
|
||||
Reservation.end_time > start_time
|
||||
)
|
||||
|
||||
if exclude_reservation_id:
|
||||
query = query.filter(Reservation.id != exclude_reservation_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Reservation {self.id}>'
|
||||
@@ -0,0 +1,163 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
# Definition of system modules available for RBAC permissions
|
||||
SYSTEM_MODULES = [
|
||||
{
|
||||
'id': 'classrooms',
|
||||
'name': 'Aulas y Espacios',
|
||||
'name_en': 'Classrooms & Spaces',
|
||||
'icon': 'bi-door-open',
|
||||
'description': 'Gestión de aulas físicas, aulas virtuales (∞) y capacidades.'
|
||||
},
|
||||
{
|
||||
'id': 'buildings',
|
||||
'name': 'Edificios y Sedes',
|
||||
'name_en': 'Buildings & Campuses',
|
||||
'icon': 'bi-buildings',
|
||||
'description': 'Administración de sede central, campus virtual, pisos y ubicaciones.'
|
||||
},
|
||||
{
|
||||
'id': 'reservations',
|
||||
'name': 'Reservas de Aulas',
|
||||
'name_en': 'Classroom Reservations',
|
||||
'icon': 'bi-calendar-check',
|
||||
'description': 'Creación, modificación, aprobación, confirmación y cancelación de reservas.'
|
||||
},
|
||||
{
|
||||
'id': 'schedule',
|
||||
'name': 'Cartelera y Cronograma',
|
||||
'name_en': 'Schedule & Billboard',
|
||||
'icon': 'bi-calendar3',
|
||||
'description': 'Visualización de calendario interactivo, cartelera de hoy y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'academic',
|
||||
'name': 'Gestión Académica',
|
||||
'name_en': 'Academic Management',
|
||||
'icon': 'bi-book',
|
||||
'description': 'Administración de carreras, asignaturas, códigos y comisiones.'
|
||||
},
|
||||
{
|
||||
'id': 'import',
|
||||
'name': 'Sincronización Sheets',
|
||||
'name_en': 'Sheets Sync & Import',
|
||||
'icon': 'bi-file-earmark-spreadsheet',
|
||||
'description': 'Importación y sincronización de datos académicos desde Google Sheets.'
|
||||
},
|
||||
{
|
||||
'id': 'optimizer',
|
||||
'name': 'Optimizador IA',
|
||||
'name_en': 'AI Room Optimizer',
|
||||
'icon': 'bi-cpu',
|
||||
'description': 'Ejecución y configuración del algoritmo genético de asignación.'
|
||||
},
|
||||
{
|
||||
'id': 'users',
|
||||
'name': 'Usuarios y Accesos RBAC',
|
||||
'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.'
|
||||
},
|
||||
{
|
||||
'id': 'metrics',
|
||||
'name': 'Métricas de Ocupación',
|
||||
'name_en': 'Occupancy Metrics',
|
||||
'icon': 'bi-graph-up',
|
||||
'description': 'Visualización y análisis de porcentajes de ocupación por aula (día, semana y mes).'
|
||||
},
|
||||
{
|
||||
'id': 'enrollment',
|
||||
'name': 'Inscripciones y Cursadas',
|
||||
'name_en': 'Enrollments & Courses',
|
||||
'icon': 'bi-diagram-3',
|
||||
'description': 'Gestión de inscripciones de alumnos a comisiones y asignación de docentes.'
|
||||
}
|
||||
]
|
||||
|
||||
ACCESS_LEVELS = ['none', 'read', 'read_write']
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = 'roles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(255))
|
||||
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
permissions = db.relationship('Permission', backref='role', lazy='joined', cascade='all, delete-orphan')
|
||||
users = db.relationship('User', backref='role_obj', lazy='select')
|
||||
|
||||
def get_permission(self, module):
|
||||
"""Get access level string for a given module ('none', 'read', 'read_write')"""
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
return p.access_level
|
||||
return 'none'
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""
|
||||
Check if role has sufficient permission level.
|
||||
Hierarchy: 'read_write' > 'read' > 'none'
|
||||
"""
|
||||
current_level = self.get_permission(module)
|
||||
if current_level == 'none':
|
||||
return False
|
||||
if min_level == 'read':
|
||||
return current_level in ['read', 'read_write']
|
||||
if min_level == 'read_write':
|
||||
return current_level == 'read_write'
|
||||
return False
|
||||
|
||||
def set_permission(self, module, access_level):
|
||||
"""Set or update permission level for a module"""
|
||||
if access_level not in ACCESS_LEVELS:
|
||||
access_level = 'none'
|
||||
|
||||
for p in self.permissions:
|
||||
if p.module == module:
|
||||
p.access_level = access_level
|
||||
return p
|
||||
|
||||
new_perm = Permission(role=self, module=module, access_level=access_level)
|
||||
db.session.add(new_perm)
|
||||
return new_perm
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'is_system': self.is_system,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'user_count': len(self.users) if self.users else 0,
|
||||
'permissions': {p.module: p.access_level for p in self.permissions}
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Role {self.name}>'
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
module = db.Column(db.String(50), nullable=False)
|
||||
access_level = db.Column(db.String(20), default='none', nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('role_id', 'module', name='uq_role_module'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Permission role={self.role_id} module={self.module} level={self.access_level}>'
|
||||
@@ -0,0 +1,118 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
class Subject(db.Model):
|
||||
__tablename__ = 'subjects'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False, default='')
|
||||
credits = db.Column(db.Integer, nullable=False, default=4)
|
||||
career_id = db.Column(db.Integer, db.ForeignKey('careers.id', ondelete='SET NULL'), nullable=True)
|
||||
is_active = db.Column('active', db.Boolean, default=True, nullable=False) # Maps to active column in DB
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
commissions = db.relationship('Commission', backref='subject', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subject to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'career_id': self.career_id,
|
||||
'career_name': self.career_obj.name if self.career_obj else None,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Subject {self.code}>'
|
||||
|
||||
class Commission(db.Model):
|
||||
__tablename__ = 'commissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
subject_id = db.Column(db.Integer, db.ForeignKey('subjects.id'), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
semester = db.Column(db.String(20), nullable=False)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
teacher_id = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
def teacher_name(self):
|
||||
return f"{self.teacher.first_name} {self.teacher.last_name}" if self.teacher else "TBD"
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('subject_id', 'code', 'semester', 'year'),)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert commission to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'subject_id': self.subject_id,
|
||||
'code': self.code,
|
||||
'semester': self.semester,
|
||||
'year': self.year,
|
||||
'teacher_name': self.teacher_name,
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
}
|
||||
|
||||
def get_full_code(self):
|
||||
"""Get full commission code"""
|
||||
subj = self.subject.code if self.subject else 'N/A'
|
||||
code = self.code or ''
|
||||
sem = str(self.semester or '').strip()
|
||||
yr = str(self.year or '').strip()
|
||||
|
||||
if yr and yr in sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif sem and yr:
|
||||
return f"{subj}-{code}-{sem}{yr}"
|
||||
elif sem:
|
||||
return f"{subj}-{code}-{sem}"
|
||||
elif yr:
|
||||
return f"{subj}-{code}-{yr}"
|
||||
return f"{subj}-{code}"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Commission {self.get_full_code()}>'
|
||||
@@ -0,0 +1,145 @@
|
||||
from app import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
role = db.Column(db.String(50), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime)
|
||||
preferred_language = db.Column(db.String(10), default=None, nullable=True)
|
||||
theme_preference = db.Column(db.String(10), default='auto', nullable=False)
|
||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
# Virtual fields for future compatibility
|
||||
@property
|
||||
def first_name(self):
|
||||
return self.name.split()[0] if self.name else ''
|
||||
|
||||
@property
|
||||
def last_name(self):
|
||||
parts = self.name.split()
|
||||
return ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def set_password(self, password):
|
||||
"""Set password hash"""
|
||||
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
|
||||
def check_password(self, password):
|
||||
"""Check password"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def is_admin(self):
|
||||
"""Check if user is admin"""
|
||||
if self.role_obj and self.role_obj.name.lower() in ['admin', 'administrador']:
|
||||
return True
|
||||
return str(self.role).upper() in ['ADMIN', 'ADMINISTRADOR']
|
||||
|
||||
def get_institutional_role(self):
|
||||
"""Returns normalized institutional role: 'admin', 'bedelia', 'docente', 'alumno'"""
|
||||
raw = ''
|
||||
if self.role_obj and self.role_obj.name:
|
||||
raw = self.role_obj.name.lower().strip()
|
||||
elif self.role:
|
||||
raw = str(self.role).lower().strip()
|
||||
|
||||
if 'admin' in raw:
|
||||
return 'admin'
|
||||
if 'bedel' in raw:
|
||||
return 'bedelia'
|
||||
if 'docent' in raw or 'prof' in raw or 'teacher' in raw:
|
||||
return 'docente'
|
||||
if 'alumn' in raw or 'estud' in raw or 'student' in raw:
|
||||
return 'alumno'
|
||||
return 'docente'
|
||||
|
||||
def is_bedelia(self):
|
||||
return self.get_institutional_role() == 'bedelia'
|
||||
|
||||
def is_docente(self):
|
||||
return self.get_institutional_role() == 'docente'
|
||||
|
||||
def is_alumno(self):
|
||||
return self.get_institutional_role() == 'alumno'
|
||||
|
||||
@property
|
||||
def role_badge_display(self):
|
||||
role_map = {
|
||||
'admin': {'label': 'Administrador', 'class': 'badge-admin', 'icon': 'bi-shield-check', 'color': 'primary'},
|
||||
'bedelia': {'label': 'Bedelía', 'class': 'badge-bedelia', 'icon': 'bi-building-gear', 'color': 'warning'},
|
||||
'docente': {'label': 'Profesor / Docente', 'class': 'badge-docente', 'icon': 'bi-person-video3', 'color': 'info'},
|
||||
'alumno': {'label': 'Alumno / Estudiante', 'class': 'badge-alumno', 'icon': 'bi-mortarboard', 'color': 'success'}
|
||||
}
|
||||
return role_map.get(self.get_institutional_role(), {'label': self.role or 'Docente', 'class': 'bg-secondary', 'icon': 'bi-person', 'color': 'secondary'})
|
||||
|
||||
def has_permission(self, module, min_level='read'):
|
||||
"""Check if user has granular permission on a given module"""
|
||||
if self.is_admin():
|
||||
return True
|
||||
if self.role_obj:
|
||||
return self.role_obj.has_permission(module, min_level)
|
||||
return False
|
||||
|
||||
def can_manage(self):
|
||||
"""Check if user can access the Management / Admin section"""
|
||||
return (
|
||||
self.is_admin() or
|
||||
self.is_bedelia() or
|
||||
self.has_permission('users', 'read') or
|
||||
self.has_permission('milestone_types', 'read') or
|
||||
self.has_permission('import', 'read') or
|
||||
self.has_permission('reservations', 'read_write') or
|
||||
self.has_permission('classrooms', 'read_write') or
|
||||
self.has_permission('buildings', 'read_write') or
|
||||
self.has_permission('metrics', 'read')
|
||||
)
|
||||
|
||||
def can_edit_reservation(self, reservation):
|
||||
"""Check if user has permission to edit a given reservation"""
|
||||
if not reservation:
|
||||
return False
|
||||
if self.is_admin():
|
||||
return True
|
||||
role_name = (self.role_obj.name if self.role_obj else self.role or '').lower()
|
||||
if role_name in ['admin', 'administrador', 'bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
if self.has_permission('reservations', 'read_write'):
|
||||
# El docente creador puede modificar su propia reserva
|
||||
if reservation.user_id == self.id:
|
||||
return True
|
||||
# Bedelía o roles de gestión con permiso de escritura total
|
||||
if role_name in ['bedelia', 'bedelía', 'operador']:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_last_login(self):
|
||||
"""Update last login timestamp"""
|
||||
self.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert user to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'email': self.email,
|
||||
'name': self.name,
|
||||
'role': self.role,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
'preferred_language': self.preferred_language,
|
||||
'theme_preference': self.theme_preference
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email}>'
|
||||
Reference in New Issue
Block a user