feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia

This commit is contained in:
2026-09-18 23:04:48 -03:00
parent 47b50ede7e
commit 1080acd43a
240 changed files with 8354 additions and 8886 deletions
+19
View File
@@ -0,0 +1,19 @@
from .sheets_importer import GoogleSheetsImporter
from .classroom_service import ClassroomService
from .reservation_service import ReservationService
from .user_service import UserService
from .jwt_service import JWTService
from .audit_service import AuditService
from .optimizer_service import OptimizerService, OptimizerJob, OptimizerJobStatus
__all__ = [
'GoogleSheetsImporter',
'ClassroomService',
'ReservationService',
'UserService',
'JWTService',
'AuditService',
'OptimizerService',
'OptimizerJob',
'OptimizerJobStatus'
]
+76
View File
@@ -0,0 +1,76 @@
from typing import Optional, List, Dict, Any
from flask import request, g, has_request_context
from flask_login import current_user
import json
from app import db
from app.models.audit_log import AuditLog
from app.models.user import User
class AuditService:
"""Servicio para la captura y consulta de la bitácora inmutable de auditoría."""
@staticmethod
def log(action: str, module: str, entity_id: Optional[int] = None,
details: Optional[Any] = None, user: Optional[User] = None,
ip_address: Optional[str] = None) -> AuditLog:
"""
Registra un evento de auditoría de forma segura, infiriendo el usuario e IP
desde el contexto actual de la petición si no se especifican.
"""
user_id = None
user_email = None
# 1. Determinar usuario actuante
if user:
user_id = user.id
user_email = user.email
elif has_request_context():
if hasattr(g, 'jwt_user') and g.jwt_user:
user_id = g.jwt_user.id
user_email = g.jwt_user.email
elif current_user and current_user.is_authenticated:
user_id = current_user.id
user_email = current_user.email
# 2. Determinar IP de origen
if not ip_address and has_request_context():
if request.headers.get('X-Forwarded-For'):
ip_address = request.headers.get('X-Forwarded-For').split(',')[0].strip()
else:
ip_address = request.remote_addr
# 3. Serializar detalles si es estructura de datos
details_str = None
if details is not None:
if isinstance(details, (dict, list)):
try:
details_str = json.dumps(details, ensure_ascii=False)
except Exception:
details_str = str(details)
else:
details_str = str(details)
log_entry = AuditLog(
user_id=user_id,
user_email=user_email,
action=action.upper(),
module=module.lower(),
entity_id=entity_id,
details=details_str,
ip_address=ip_address
)
try:
db.session.add(log_entry)
db.session.commit()
except Exception:
db.session.rollback()
return log_entry
@staticmethod
def get_recent_logs(limit: int = 50, module: Optional[str] = None) -> List[AuditLog]:
"""Obtiene las entradas más recientes de auditoría."""
query = AuditLog.query
if module:
query = query.filter_by(module=module.lower())
return query.order_by(AuditLog.created_at.desc()).limit(limit).all()
+118
View File
@@ -0,0 +1,118 @@
from typing import Optional, List, Dict, Any
import re
from sqlalchemy import func
from app.models.classroom import Classroom
from app.models.building import Building
from app.repositories.classroom_repository import ClassroomRepository
from app.schemas.classroom_dto import ClassroomCreateDTO, ClassroomUpdateDTO
from app import db
class ClassroomService:
"""Capa de servicios para la lógica de negocio y validación de aulas."""
def __init__(self, repository: Optional[ClassroomRepository] = None):
self.repository = repository or ClassroomRepository()
def get_classroom(self, classroom_id: int) -> Optional[Classroom]:
"""Obtiene un aula por su identificador."""
return self.repository.get_by_id(classroom_id)
def list_classrooms(self, building_id: Optional[int] = None, floor: Optional[str] = None,
only_virtual: bool = False, only_physical: bool = False) -> List[Classroom]:
"""Lista aulas filtradas según criterios de ubicación o modalidad."""
return self.repository.get_active_classrooms(
building_id=building_id,
floor=floor,
only_virtual=only_virtual,
only_physical=only_physical
)
def create_classroom(self, dto: ClassroomCreateDTO) -> Classroom:
"""
Crea una nueva aula aplicando reglas de negocio:
- Resolución o creación dinámica del edificio.
- Unicidad de código por piso y edificio.
- Capacidad adaptativa para aulas virtuales.
"""
building = None
if dto.building_id:
building = Building.query.get(dto.building_id)
if not building:
raise ValueError(f"El edificio con ID {dto.building_id} no existe.")
elif dto.building_name:
b_name = dto.building_name.strip()
building = Building.query.filter(func.lower(Building.name) == func.lower(b_name)).first()
if not building:
code_slug = re.sub(r'[^a-zA-Z0-9]+', '_', b_name.upper()).strip('_')
building = Building(name=b_name, code=code_slug, is_active=True)
db.session.add(building)
db.session.flush()
else:
raise ValueError("Debe especificar un edificio válido o el nombre de un nuevo edificio.")
# Verificar unicidad
existing = self.repository.get_by_code_and_building(building.id, dto.code, dto.floor)
if existing:
raise ValueError(f"Ya existe un aula con el código '{dto.code}' en el piso {dto.floor} de {building.name}.")
capacity = dto.capacity
is_virtual = ('VIRTUAL' in dto.code.upper()) or ('VIRTUAL' in building.name.upper())
if is_virtual and capacity < 999:
capacity = 9999
classroom = Classroom(
building_id=building.id,
building=building.name,
code=dto.code,
floor=dto.floor,
capacity=capacity,
description=dto.description,
is_active=dto.is_active
)
return self.repository.save(classroom)
def update_classroom(self, classroom_id: int, dto: ClassroomUpdateDTO) -> Classroom:
"""Actualiza atributos de un aula existente."""
classroom = self.repository.get_by_id(classroom_id)
if not classroom:
raise ValueError(f"Aula con ID {classroom_id} no encontrada.")
building_id = dto.building_id if dto.building_id is not None else classroom.building_id
code = dto.code.strip() if dto.code is not None else classroom.code
floor = dto.floor.strip() if dto.floor is not None else classroom.floor
# Si cambia código, piso o edificio, validar colisión
if (building_id != classroom.building_id or code != classroom.code or floor != classroom.floor):
existing = self.repository.get_by_code_and_building(building_id, code, floor)
if existing and existing.id != classroom.id:
raise ValueError(f"Ya existe otra aula con el código '{code}' en el piso {floor}.")
if dto.building_id is not None:
building = Building.query.get(dto.building_id)
if building:
classroom.building_id = building.id
classroom.building = building.name
if dto.code is not None:
classroom.code = code
if dto.floor is not None:
classroom.floor = floor
if dto.capacity is not None:
classroom.capacity = dto.capacity
if dto.description is not None:
classroom.description = dto.description
if dto.is_active is not None:
classroom.is_active = dto.is_active
return self.repository.save(classroom)
def delete_classroom(self, classroom_id: int) -> bool:
"""Elimina o desactiva un aula verificando dependencias."""
classroom = self.repository.get_by_id(classroom_id)
if not classroom:
return False
# Si tiene reservas históricas, se desactiva lógicamente para preservar integridad
if classroom.reservations:
classroom.is_active = False
self.repository.save(classroom)
return True
return self.repository.delete(classroom)
+116
View File
@@ -0,0 +1,116 @@
import jwt
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from flask import current_app
from app.models.user import User
# Lista negra de tokens revocados en memoria (jti -> timestamp de expiración)
_token_blacklist: Dict[str, datetime] = {}
class JWTService:
"""Servicio de emisión, verificación y revocación de tokens JWT (Dual Token Pattern)."""
ACCESS_TOKEN_EXPIRES = timedelta(days=1)
REFRESH_TOKEN_EXPIRES = timedelta(days=7)
ALGORITHM = "HS256"
@classmethod
def _clean_expired_blacklist(cls):
"""Limpia periódicamente identificadores de tokens que ya han expirado naturalmente."""
now = datetime.now(timezone.utc)
expired_jtis = [jti for jti, exp in _token_blacklist.items() if exp < now]
for jti in expired_jtis:
_token_blacklist.pop(jti, None)
@classmethod
def _get_secret(cls) -> str:
"""Obtiene la clave secreta desde la configuración de Flask."""
return current_app.config.get('SECRET_KEY', 'default-edu-space-secret-key')
@classmethod
def create_access_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
"""Emite un token de acceso de corta duración (15 min) con claims de rol y permisos."""
now = datetime.now(timezone.utc)
delta = expires_delta or cls.ACCESS_TOKEN_EXPIRES
role_name = user.role_obj.name if user.role_obj else user.role
# Mapeo de permisos para claims del token
permissions = {}
if user.role_obj and hasattr(user.role_obj, 'permissions'):
for p in user.role_obj.permissions:
permissions[p.module] = p.access_level
payload = {
'sub': str(user.id),
'email': user.email,
'name': user.name,
'role': role_name,
'is_admin': user.is_admin(),
'permissions': permissions,
'type': 'access',
'jti': str(uuid.uuid4()),
'iat': now,
'exp': now + delta
}
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
@classmethod
def create_refresh_token(cls, user: User, expires_delta: Optional[timedelta] = None) -> str:
"""Emite un token de refresco de larga duración (7 días) para renovación silenciosa."""
now = datetime.now(timezone.utc)
delta = expires_delta or cls.REFRESH_TOKEN_EXPIRES
payload = {
'sub': str(user.id),
'type': 'refresh',
'jti': str(uuid.uuid4()),
'iat': now,
'exp': now + delta
}
return jwt.encode(payload, cls._get_secret(), algorithm=cls.ALGORITHM)
@classmethod
def generate_tokens(cls, user: User) -> Dict[str, Any]:
"""Genera el par de tokens (Access + Refresh) junto con los metadatos del usuario."""
access_token = cls.create_access_token(user)
refresh_token = cls.create_refresh_token(user)
return {
'access_token': access_token,
'refresh_token': refresh_token,
'token_type': 'Bearer',
'expires_in': int(cls.ACCESS_TOKEN_EXPIRES.total_seconds())
}
@classmethod
def decode_token(cls, token: str, expected_type: str = 'access') -> Dict[str, Any]:
"""
Decodifica y valida la firma, expiración y tipo del token.
Lanza jwt.PyJWTError si el token es inválido, expirado o fue revocado.
"""
cls._clean_expired_blacklist()
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM])
jti = payload.get('jti')
if jti and jti in _token_blacklist:
raise jwt.InvalidTokenError("El token ha sido revocado.")
token_type = payload.get('type')
if token_type != expected_type:
raise jwt.InvalidTokenError(f"Tipo de token inválido. Se esperaba '{expected_type}', recibido '{token_type}'.")
return payload
@classmethod
def revoke_token(cls, token: str) -> bool:
"""Agrega el identificador del token a la lista negra hasta su fecha natural de expiración."""
try:
payload = jwt.decode(token, cls._get_secret(), algorithms=[cls.ALGORITHM], options={"verify_exp": False})
jti = payload.get('jti')
exp_ts = payload.get('exp')
if jti and exp_ts:
exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
_token_blacklist[jti] = exp_dt
return True
except Exception:
pass
return False
+282
View File
@@ -0,0 +1,282 @@
import time
import uuid
import threading
from datetime import datetime, timedelta
from enum import Enum
from typing import List, Dict, Any, Optional
from flask import current_app
from app.models.genetic_algorithm import (
ReservationOptimizer,
GeneticAlgorithm,
ReservationRequest,
Classroom
)
from app.models.subject import Commission
from app.schemas.optimizer_dto import SpringBootSyncPayloadDTO
class OptimizerJobStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class OptimizerJob:
"""Representa un trabajo asíncrono en la cola del optimizador heurístico."""
def __init__(self, job_id: str, params: Dict[str, Any]):
self.job_id = job_id
self.status = OptimizerJobStatus.PENDING
self.progress = 0
self.generation = 0
self.total_generations = params.get("generations", 80)
self.fitness_score = 0.0
self.total_requests = 0
self.assigned_reservations = 0
self.reservations: List[Dict[str, Any]] = []
self.conflicts: List[Dict[str, Any]] = []
self.error: Optional[str] = None
self.created_at = datetime.utcnow()
self.started_at: Optional[datetime] = None
self.completed_at: Optional[datetime] = None
self.execution_time_ms: Optional[float] = None
self.params = params
def to_dict(self) -> Dict[str, Any]:
return {
"job_id": self.job_id,
"status": self.status.value,
"progress": self.progress,
"generation": self.generation,
"total_generations": self.total_generations,
"fitness_score": round(self.fitness_score, 2),
"total_requests": self.total_requests,
"assigned_reservations": self.assigned_reservations,
"reservations": self.reservations,
"conflict_count": len(self.conflicts),
"conflicts": self.conflicts,
"error": self.error,
"created_at": self.created_at.isoformat() if self.created_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"execution_time_ms": self.execution_time_ms,
"params": {k: v for k, v in self.params.items() if k not in ("app",)}
}
class OptimizerService:
"""
Servicio de alto rendimiento para el Optimizador Heurístico y Cola de Trabajos Asíncronos.
Soporta evaluación concurrente multi-hilo y sincronización interoperable con Spring Boot.
"""
_jobs: Dict[str, OptimizerJob] = {}
_lock = threading.Lock()
@classmethod
def submit_job(
cls,
commission_ids: List[int],
admin_user_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
generations: int = 80,
population_size: int = 40,
workers: int = 4,
app = None
) -> OptimizerJob:
"""
Registra y despacha un trabajo de optimización en segundo plano (asíncrono).
"""
job_id = str(uuid.uuid4())
params = {
"commission_ids": commission_ids,
"admin_user_id": admin_user_id,
"start_date": start_date,
"end_date": end_date,
"generations": generations,
"population_size": population_size,
"workers": workers
}
job = OptimizerJob(job_id, params)
with cls._lock:
cls._jobs[job_id] = job
# Si no se pasó app explícitamente, intentar obtener current_app
if app is None:
try:
app = current_app._get_current_object()
except Exception:
app = None
thread = threading.Thread(
target=cls._run_job_worker,
args=(job_id, app),
daemon=True,
name=f"optimizer-worker-{job_id[:8]}"
)
thread.start()
return job
@classmethod
def _run_job_worker(cls, job_id: str, app):
"""Worker en segundo plano para ejecutar el algoritmo genético."""
job = cls.get_job(job_id)
if not job:
return
def _execute():
t_start = time.perf_counter()
with cls._lock:
job.status = OptimizerJobStatus.RUNNING
job.started_at = datetime.utcnow()
def progress_callback(gen: int, total_gen: int, pct: int, best_fit: float):
with cls._lock:
job.generation = gen
job.total_generations = total_gen
job.progress = pct
job.fitness_score = best_fit
optimizer = ReservationOptimizer(
population_size=job.params.get("population_size", 40),
generations=job.params.get("generations", 80),
workers=job.params.get("workers", 4)
)
result = optimizer.optimize_schedule(
commission_ids=job.params["commission_ids"],
admin_user_id=job.params["admin_user_id"],
start_date=job.params.get("start_date"),
end_date=job.params.get("end_date"),
progress_callback=progress_callback
)
t_end = time.perf_counter()
exec_time_ms = round((t_end - t_start) * 1000, 2)
with cls._lock:
job.status = OptimizerJobStatus.COMPLETED
job.progress = 100
job.fitness_score = result.get("fitness_score", 0.0)
job.total_requests = result.get("total_requests", 0)
job.assigned_reservations = result.get("assigned_reservations", 0)
job.reservations = result.get("reservations", [])
job.conflicts = result.get("conflicts", [])
job.completed_at = datetime.utcnow()
job.execution_time_ms = exec_time_ms
try:
if app:
with app.app_context():
_execute()
else:
_execute()
except Exception as ex:
with cls._lock:
job.status = OptimizerJobStatus.FAILED
job.error = str(ex)
job.completed_at = datetime.utcnow()
@classmethod
def get_job(cls, job_id: str) -> Optional[OptimizerJob]:
"""Obtiene un trabajo por su identificador único."""
with cls._lock:
return cls._jobs.get(job_id)
@classmethod
def list_jobs(cls, limit: int = 50) -> List[Dict[str, Any]]:
"""Lista los trabajos más recientes ordenados por fecha de creación desc."""
with cls._lock:
sorted_jobs = sorted(cls._jobs.values(), key=lambda j: j.created_at, reverse=True)
return [j.to_dict() for j in sorted_jobs[:limit]]
@classmethod
def sync_with_spring_boot(
cls,
payload: SpringBootSyncPayloadDTO,
admin_user_id: int = 1
) -> Dict[str, Any]:
"""
Procesa una carga de optimización interoperable proveniente de microservicios Spring Boot.
Garantiza respuesta conforme al contrato JSON del microservicio.
"""
t_start = time.perf_counter()
# 1. Resolver aulas disponibles
if payload.classrooms and len(payload.classrooms) > 0:
classrooms = []
for c_dto in payload.classrooms:
room = Classroom(
room_number=c_dto.roomNumber,
capacity=c_dto.capacity,
is_virtual=c_dto.isVirtual,
is_active=True
)
room.id = c_dto.classroomId
classrooms.append(room)
else:
classrooms = Classroom.query.filter_by(is_active=True).all()
# 2. Generar ReservationRequests desde el DTO de Spring Boot
requests: List[ReservationRequest] = []
for c in payload.commissions:
req = ReservationRequest(
commission_id=c.commissionId,
expected_attendees=c.expectedAttendees,
purpose=f"Spring Boot Class: {c.subjectCode} - {c.subjectName}",
preferred_start_time=c.preferredStart,
preferred_end_time=c.preferredEnd,
priority=1,
flexibility_hours=2
)
requests.append(req)
# 3. Opciones de configuración de algoritmo
options = payload.options or {}
generations = options.get("generations", 60)
pop_size = options.get("populationSize", 30)
workers = options.get("workers", 4)
ga = GeneticAlgorithm(
population_size=pop_size,
generations=generations,
workers=workers
)
start_date = min(c.preferredStart for c in payload.commissions)
end_date = max(c.preferredEnd for c in payload.commissions)
best_individual = ga.optimize_reservations(requests, classrooms, start_date, end_date)
t_end = time.perf_counter()
exec_ms = round((t_end - t_start) * 1000, 2)
scheduled_assignments = []
for gene in best_individual.genes:
scheduled_assignments.append({
"commissionId": gene.commission_id,
"classroomId": gene.classroom_id,
"startTime": gene.start_time.isoformat() if gene.start_time else None,
"endTime": gene.end_time.isoformat() if gene.end_time else None,
"purpose": gene.purpose,
"expectedAttendees": gene.expected_attendees,
"status": "SCHEDULED"
})
return {
"requestId": payload.requestId,
"status": "SUCCESS",
"academicTerm": payload.academicTerm,
"campusCode": payload.campusCode,
"totalCommissions": len(payload.commissions),
"scheduledAssignments": scheduled_assignments,
"conflictCount": len(best_individual.conflicts),
"fitnessScore": round(best_individual.fitness, 2),
"executionTimeMs": exec_ms,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
+122
View File
@@ -0,0 +1,122 @@
from typing import Optional, List
from datetime import datetime
from app.models.reservation import Reservation, ReservationStatus
from app.models.classroom import Classroom
from app.repositories.reservation_repository import ReservationRepository
from app.repositories.classroom_repository import ClassroomRepository
from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO
from app import db
class ReservationService:
"""Capa de servicios para la orquestación y validación de reservas y cronograma."""
def __init__(self, reservation_repo: Optional[ReservationRepository] = None,
classroom_repo: Optional[ClassroomRepository] = None):
self.reservation_repo = reservation_repo or ReservationRepository()
self.classroom_repo = classroom_repo or ClassroomRepository()
def get_reservation(self, reservation_id: int) -> Optional[Reservation]:
"""Obtiene una reserva por su ID."""
return self.reservation_repo.get_by_id(reservation_id)
def list_reservations(self, start_dt: datetime, end_dt: datetime,
classroom_id: Optional[int] = None) -> List[Reservation]:
"""Obtiene reservas dentro de un rango temporal."""
return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id)
def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime,
exclude_id: Optional[int] = None) -> List[Reservation]:
"""Verifica si existen solapamientos en un aula específica."""
classroom = self.classroom_repo.get_by_id(classroom_id)
# Las aulas virtuales tienen concurrencia ilimitada (sin colisiones)
if classroom and classroom.is_virtual:
return []
return self.reservation_repo.find_conflicts(
classroom_id=classroom_id,
start_time=start_time,
end_time=end_time,
exclude_reservation_id=exclude_id
)
def create_reservation(self, dto: ReservationCreateDTO) -> Reservation:
"""
Crea una nueva reserva aplicando validaciones de aforo y detección de colisiones.
"""
classroom = self.classroom_repo.get_by_id(dto.classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {dto.classroom_id} no existe o no se encuentra activa.")
# Detección de colisiones para aulas físicas
if not classroom.is_virtual:
conflicts = self.check_conflicts(dto.classroom_id, dto.start_time, dto.end_time)
if conflicts:
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in conflicts])
raise ValueError(f"Conflicto de horario en {classroom.code}: se solapa con las reservas {conflict_details}.")
if dto.expected_attendees > classroom.capacity:
raise ValueError(f"La cantidad de alumnos ({dto.expected_attendees}) supera la capacidad del aula ({classroom.capacity}).")
reservation = Reservation(
classroom_id=dto.classroom_id,
commission_id=dto.commission_id,
user_id=dto.user_id,
start_time=dto.start_time,
end_time=dto.end_time,
purpose=dto.purpose,
expected_attendees=dto.expected_attendees,
shift=dto.shift,
virtual_link=dto.virtual_link,
notes=dto.notes,
status=dto.status or 'CONFIRMED'
)
return self.reservation_repo.save(reservation)
def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation:
"""Actualiza una reserva existente verificando disponibilidad horaria."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
raise ValueError(f"Reserva con ID {reservation_id} no encontrada.")
classroom_id = dto.classroom_id or reservation.classroom_id
start_time = dto.start_time or reservation.start_time
end_time = dto.end_time or reservation.end_time
classroom = self.classroom_repo.get_by_id(classroom_id)
if not classroom or not classroom.is_active:
raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.")
if not classroom.is_virtual:
conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=reservation.id)
if conflicts:
conflict_details = ", ".join([f"#{c.id} ({c.start_time.strftime('%H:%M')} - {c.end_time.strftime('%H:%M')})" for c in conflicts])
raise ValueError(f"Conflicto de horario en {classroom.code} con: {conflict_details}.")
if dto.classroom_id is not None:
reservation.classroom_id = dto.classroom_id
if dto.start_time is not None:
reservation.start_time = dto.start_time
if dto.end_time is not None:
reservation.end_time = dto.end_time
if dto.purpose is not None:
reservation.purpose = dto.purpose
if dto.expected_attendees is not None:
reservation.expected_attendees = dto.expected_attendees
if dto.shift is not None:
reservation.shift = dto.shift
if dto.virtual_link is not None:
reservation.virtual_link = dto.virtual_link
if dto.notes is not None:
reservation.notes = dto.notes
if dto.status is not None:
reservation.status = dto.status
return self.reservation_repo.save(reservation)
def cancel_reservation(self, reservation_id: int) -> bool:
"""Cancela una reserva estableciendo su estado en CANCELLED."""
reservation = self.reservation_repo.get_by_id(reservation_id)
if not reservation:
return False
reservation.status = ReservationStatus.CANCELLED.value
self.reservation_repo.save(reservation)
return True
+436
View File
@@ -0,0 +1,436 @@
import urllib.request
import csv
import io
import re
from datetime import datetime, date, time, timedelta
from app import db
from app.models.classroom import Classroom
from app.models.career import Career
from app.models.subject import Subject, Commission
from app.models.reservation import Reservation, ReservationStatus
from app.models.user import User
BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv'
SHEETS_CONFIG = [
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
]
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
class GoogleSheetsImporter:
"""
Service to fetch, parse, and synchronize academic schedules,
classrooms, careers, and subjects from the official UniCABA Google Sheet.
"""
def __init__(self, base_url=None):
self.base_url = base_url or BASE_CSV_URL
def fetch_sheet_csv(self, gid):
"""Fetch CSV string for a specific sheet gid"""
url = f"{self.base_url}&gid={gid}"
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
)
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode('utf-8', errors='replace')
def parse_sheet_rows(self, csv_content, sheet_name):
"""Parse raw CSV text into a structured list of class records"""
reader = csv.reader(io.StringIO(csv_content))
rows = list(reader)
header_idx = -1
col_offset = 0
for r_idx, row in enumerate(rows):
for c_idx, cell in enumerate(row):
cell_clean = cell.lower().strip()
if 'codigo' in cell_clean or 'código' in cell_clean:
header_idx = r_idx
col_offset = c_idx
break
if header_idx != -1:
break
if header_idx == -1:
return []
records = []
current_shift = 'Mañana'
for row in rows[header_idx + 1:]:
if len(row) >= col_offset + 5:
raw_cell = row[col_offset].strip().replace('\n', ' - ')
row_full_text = ' '.join(row).upper()
# Detect shift delimiters
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
current_shift = 'Mañana'
continue
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
current_shift = 'Tarde'
continue
elif raw_cell.lower().startswith('turno'):
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
current_shift = 'Mañana'
else:
current_shift = 'Tarde'
continue
codigo = raw_cell
asignatura = row[col_offset + 1].strip().replace('\n', ' ')
aula = row[col_offset + 2].strip().replace('\n', ' ')
carrera = row[col_offset + 3].strip().replace('\n', ' ')
horario = row[col_offset + 4].strip().replace('\n', ' ')
# Check valid data row
if codigo and asignatura and not 'codigo' in codigo.lower():
# Double-check shift from start hour if possible
record_shift = current_shift
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
if time_match:
h = int(time_match.group(1))
if h >= 14:
record_shift = 'Tarde'
elif h < 13:
record_shift = 'Mañana'
records.append({
'dia': sheet_name,
'codigo': codigo,
'asignatura': asignatura,
'aula': aula,
'carrera': carrera,
'horario': horario,
'turno': record_shift
})
return records
def normalize_room(self, aula_raw):
"""
Normalize room string into (building, room_number, floor, capacity)
"""
aula_upper = aula_raw.upper().strip()
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
return None
if 'VIRTUAL' in aula_upper:
return {
'building': 'Campus Virtual',
'code': 'VIRTUAL',
'floor': 0,
'capacity': 0,
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
}
if 'AUDITORIO' in aula_upper:
return {
'building': 'Edificio Central',
'code': 'Auditorio',
'floor': 0,
'capacity': 150,
'description': 'Auditorio principal UniCABA'
}
if 'SALA DE REUNI' in aula_upper:
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
return {
'building': 'Edificio Central',
'code': f'Sala Reunión {floor}°P',
'floor': floor,
'capacity': 15,
'description': f'Sala de reunión piso {floor}'
}
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
num_match = re.search(r'\d+', aula_raw)
if num_match:
num = num_match.group(0)
floor = int(num[0]) if len(num) >= 3 else 1
return {
'building': 'Edificio Central',
'code': f'Aula {num}',
'floor': floor,
'capacity': 35,
'description': f'Aula física {num} - Piso {floor}'
}
# Generic fallback
return {
'building': 'Edificio Central',
'code': aula_raw,
'floor': 1,
'capacity': 30,
'description': aula_raw
}
def parse_time_range(self, horario_str):
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
m = TIME_REGEX.search(horario_str)
if not m:
return None, None
try:
h1 = int(m.group(1))
m1 = int(m.group(2) or 0)
h2 = int(m.group(3))
m2 = int(m.group(4) or 0)
return time(h1, m1), time(h2, m2)
except Exception:
return None, None
def sync(self):
"""
Main synchronization process:
1. Fetches all 6 day sheets.
2. Upserts Careers, Classrooms, Subjects, Commissions.
3. Generates Reservations in the calendar for the semester.
"""
stats = {
'total_rows_processed': 0,
'careers_created': 0,
'careers_updated': 0,
'classrooms_created': 0,
'classrooms_updated': 0,
'subjects_created': 0,
'subjects_updated': 0,
'commissions_created': 0,
'reservations_created': 0,
'errors': [],
'sample_records': []
}
all_raw_records = []
for sheet_cfg in SHEETS_CONFIG:
try:
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
except Exception as e:
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
stats['total_rows_processed'] = len(all_raw_records)
if not all_raw_records:
return stats
# Default admin user for reservations
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
default_user_id = default_user.id if default_user else 1
# Ensure institutional buildings exist with official address
from app.models.building import Building
b_central = Building.query.filter_by(name='Edificio Central').first()
if not b_central:
b_central = Building(
name='Edificio Central',
code='CENTRAL',
address='Tte. Gral. Juan Domingo Perón 802, CABA',
floors=4,
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
)
db.session.add(b_central)
db.session.flush()
else:
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
if not b_virtual:
b_virtual = Building(
name='Campus Virtual',
code='VIRTUAL',
address='Entorno Digital / Microsoft Teams - Moodle',
floors=0,
description='Entorno de aulas virtuales y clases a distancia'
)
db.session.add(b_virtual)
db.session.flush()
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
# Calculate current semester dates (anchored on academic year 2026)
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
base_monday = date(2026, 8, 31)
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
for rec, day_idx in all_raw_records:
try:
codigo = rec['codigo']
asignatura = rec['asignatura']
aula_raw = rec['aula']
carrera_name = rec['carrera'].strip()
horario_raw = rec['horario']
dia = rec['dia']
turno = rec.get('turno', 'Mañana')
# 1. Upsert Career
carrera_key = carrera_name.upper()
career = careers_cache.get(carrera_key)
if not career:
career = Career(name=carrera_name)
db.session.add(career)
db.session.flush()
careers_cache[carrera_key] = career
stats['careers_created'] += 1
# 2. Upsert Classroom
room_info = self.normalize_room(aula_raw)
classroom = None
if room_info:
room_key = (room_info['building'].upper(), room_info['code'].upper())
classroom = classrooms_cache.get(room_key)
b_entity = buildings_cache.get(room_info['building'].upper())
if not classroom:
classroom = Classroom(
building=room_info['building'],
building_id=b_entity.id if b_entity else None,
floor=room_info['floor'],
capacity=room_info['capacity'],
description=room_info['description'],
is_active=True
)
classroom.code = room_info['code']
db.session.add(classroom)
db.session.flush()
classrooms_cache[room_key] = classroom
stats['classrooms_created'] += 1
else:
classroom.capacity = max(classroom.capacity, room_info['capacity'])
if not classroom.building_id and b_entity:
classroom.building_id = b_entity.id
# 3. Upsert Subject
subject_key = codigo.upper()
subject = subjects_cache.get(subject_key)
if not subject:
subject = Subject(
code=codigo,
name=asignatura,
career_id=career.id,
department=career.name,
credits=4,
is_active=True
)
db.session.add(subject)
db.session.flush()
subjects_cache[subject_key] = subject
stats['subjects_created'] += 1
else:
if subject.name != asignatura:
subject.name = asignatura
stats['subjects_updated'] += 1
subject.career_id = career.id
subject.department = career.name
# 4. Upsert Commission
# Identify commission subcode e.g. from code suffix or default 'C1'
comm_code = 'C1'
if '-E' in codigo:
comm_code = codigo.split('-')[-1]
commission = Commission.query.filter_by(
subject_id=subject.id,
code=comm_code,
semester='2026-2',
year=2026
).first()
if not commission:
commission = Commission(
subject_id=subject.id,
code=comm_code,
semester='2026-2',
year=2026,
teacher_id=default_user_id,
max_students=classroom.capacity if classroom else 35,
current_students=0,
schedule=f"{dia} {horario_raw}",
shift=turno,
active=True
)
db.session.add(commission)
db.session.flush()
stats['commissions_created'] += 1
else:
commission.schedule = f"{dia} {horario_raw}"
commission.shift = turno
# 5. Generate Calendar Reservations
t_start, t_end = self.parse_time_range(horario_raw)
if classroom and t_start and t_end and day_idx is not None:
is_room_virtual = bool(classroom.is_virtual or 'VIRTUAL' in (classroom.code or '').upper())
# Generar enlace de videollamada para clases virtuales si no tienen uno asignado
v_link = None
if is_room_virtual:
clean_subj = re.sub(r'[^a-zA-Z0-9]', '', subject.code or 'cls').lower()
clean_comm = re.sub(r'[^a-zA-Z0-9]', '', commission.code or 'c1').lower()
v_link = f"https://meet.google.com/edu-{clean_subj}-{clean_comm}"
if not commission.virtual_link:
commission.virtual_link = v_link
for w in range(weeks_to_generate):
class_date = base_monday + timedelta(days=day_idx, weeks=w)
dt_start = datetime.combine(class_date, t_start)
dt_end = datetime.combine(class_date, t_end)
# En aulas virtuales la capacidad es infinita y pueden cursar múltiples comisiones en paralelo
if is_room_virtual:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
commission_id=commission.id,
start_time=dt_start,
end_time=dt_end
).first()
else:
existing_res = Reservation.query.filter_by(
classroom_id=classroom.id,
start_time=dt_start,
end_time=dt_end
).first()
if not existing_res:
res = Reservation(
classroom_id=classroom.id,
commission_id=commission.id,
user_id=default_user_id,
start_time=dt_start,
end_time=dt_end,
purpose=f"{subject.code} - {subject.name}",
status='CONFIRMED',
expected_attendees=min(classroom.capacity, 30) if not is_room_virtual else 35,
shift=turno,
virtual_link=v_link,
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
)
db.session.add(res)
stats['reservations_created'] += 1
else:
existing_res.shift = turno
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
if is_room_virtual and not existing_res.virtual_link:
existing_res.virtual_link = v_link
if len(stats['sample_records']) < 10:
stats['sample_records'].append({
'dia': dia,
'codigo': codigo,
'asignatura': asignatura,
'carrera': career.name,
'aula': classroom.code_display if classroom else aula_raw,
'horario': horario_raw
})
except Exception as row_err:
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
db.session.commit()
return stats
+53
View File
@@ -0,0 +1,53 @@
from typing import Optional, Dict, Any
from datetime import datetime
from app.models.user import User
from app.repositories.user_repository import UserRepository
from app import db
class UserService:
"""Capa de servicios para la autenticación, roles y perfil de usuarios."""
def __init__(self, repository: Optional[UserRepository] = None):
self.repository = repository or UserRepository()
def authenticate(self, email: str, password: str) -> Optional[User]:
"""
Valida credenciales de acceso de un usuario.
Retorna la entidad User si es válido y activo; None en caso contrario.
"""
user = self.repository.get_by_email(email)
if not user or not user.is_active:
return None
if user.check_password(password):
user.last_login = datetime.utcnow()
self.repository.save(user)
return user
return None
def get_by_id(self, user_id: int) -> Optional[User]:
"""Obtiene un usuario por ID."""
return self.repository.get_by_id(user_id)
def get_by_email(self, email: str) -> Optional[User]:
"""Obtiene un usuario por email."""
return self.repository.get_by_email(email)
def get_profile_data(self, user: User) -> Dict[str, Any]:
"""Genera un diccionario estructurado del perfil y permisos del usuario."""
role_name = user.role_obj.name if user.role_obj else user.role
permissions = {}
if user.role_obj and hasattr(user.role_obj, 'permissions'):
for p in user.role_obj.permissions:
permissions[p.module] = p.access_level
return {
'id': user.id,
'name': user.name,
'email': user.email,
'role': role_name,
'is_admin': user.is_admin(),
'permissions': permissions,
'preferred_language': user.preferred_language,
'theme_preference': user.theme_preference
}