feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from .main import main_bp
|
||||
from .auth import auth_bp
|
||||
from .classrooms import classrooms_bp
|
||||
from .buildings import buildings_bp
|
||||
from .schedule import schedule_bp
|
||||
from .genetic_algorithm import genetic_bp, genetic_web_bp
|
||||
from .admin import admin_bp
|
||||
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp']
|
||||
__all__ = ['main_bp', 'auth_bp', 'classrooms_bp', 'buildings_bp', 'schedule_bp', 'genetic_bp', 'genetic_web_bp', 'admin_bp']
|
||||
@@ -0,0 +1,342 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort
|
||||
from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role, Permission, SYSTEM_MODULES, ACCESS_LEVELS
|
||||
from app.utils.decorators import admin_required, permission_required
|
||||
from sqlalchemy import or_
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# USERS MANAGEMENT
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@admin_required
|
||||
def users_list():
|
||||
"""List system users with filtering and role information"""
|
||||
search = request.args.get('search', '').strip()
|
||||
role_filter = request.args.get('role', '').strip()
|
||||
status_filter = request.args.get('status', '').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 15
|
||||
|
||||
query = User.query
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.name.ilike(f'%{search}%'),
|
||||
User.email.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
if role_filter:
|
||||
if role_filter.isdigit():
|
||||
query = query.filter(User.role_id == int(role_filter))
|
||||
else:
|
||||
query = query.filter(User.role.ilike(role_filter))
|
||||
|
||||
if status_filter in ['active', 'inactive']:
|
||||
query = query.filter(User.is_active == (status_filter == 'active'))
|
||||
|
||||
pagination = query.order_by(User.id.asc()).paginate(page=page, per_page=per_page, error_out=False)
|
||||
roles = Role.query.order_by(Role.name.asc()).all()
|
||||
|
||||
return render_template(
|
||||
'admin/users/list.html',
|
||||
users=pagination.items,
|
||||
pagination=pagination,
|
||||
roles=roles,
|
||||
search=search,
|
||||
role_filter=role_filter,
|
||||
status_filter=status_filter
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/users/add', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def user_add():
|
||||
"""Create a new user and assign a role"""
|
||||
roles = Role.query.order_by(Role.name.asc()).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
email = request.form.get('email', '').strip().lower()
|
||||
password = request.form.get('password', '')
|
||||
role_id = request.form.get('role_id', type=int)
|
||||
is_active = request.form.get('is_active') == 'on'
|
||||
|
||||
errors = []
|
||||
if not name:
|
||||
errors.append(_('El nombre es requerido.'))
|
||||
if not email:
|
||||
errors.append(_('El correo electrónico es requerido.'))
|
||||
elif User.query.filter_by(email=email).first():
|
||||
errors.append(_('Ya existe una cuenta con este correo electrónico.'))
|
||||
if not password or len(password) < 6:
|
||||
errors.append(_('La contraseña debe tener al menos 6 caracteres.'))
|
||||
|
||||
role_obj = Role.query.get(role_id) if role_id else None
|
||||
if not role_obj:
|
||||
errors.append(_('Debes seleccionar un rol válido.'))
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
flash(err, 'error')
|
||||
return render_template('admin/users/form.html', user=None, roles=roles)
|
||||
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
role_id=role_obj.id,
|
||||
role=role_obj.name,
|
||||
is_active=is_active
|
||||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Usuario "%(name)s" creado exitosamente.', name=user.name), 'success')
|
||||
return redirect(url_for('admin.users_list'))
|
||||
|
||||
return render_template('admin/users/form.html', user=None, roles=roles)
|
||||
|
||||
|
||||
@admin_bp.route('/users/edit/<int:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def user_edit(id):
|
||||
"""Edit user details, role and status"""
|
||||
user = User.query.get_or_404(id)
|
||||
roles = Role.query.order_by(Role.name.asc()).all()
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
email = request.form.get('email', '').strip().lower()
|
||||
password = request.form.get('password', '')
|
||||
role_id = request.form.get('role_id', type=int)
|
||||
is_active = request.form.get('is_active') == 'on'
|
||||
|
||||
errors = []
|
||||
if not name:
|
||||
errors.append(_('El nombre es requerido.'))
|
||||
if not email:
|
||||
errors.append(_('El correo electrónico es requerido.'))
|
||||
else:
|
||||
existing = User.query.filter_by(email=email).first()
|
||||
if existing and existing.id != user.id:
|
||||
errors.append(_('Ya existe otra cuenta con este correo electrónico.'))
|
||||
|
||||
role_obj = Role.query.get(role_id) if role_id else None
|
||||
if not role_obj:
|
||||
errors.append(_('Debes seleccionar un rol válido.'))
|
||||
|
||||
# Do not allow deactivating the currently logged in admin user
|
||||
if user.id == current_user.id and not is_active:
|
||||
errors.append(_('No puedes desactivar tu propia cuenta mientras estás en sesión.'))
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
flash(err, 'error')
|
||||
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||
|
||||
user.name = name
|
||||
user.email = email
|
||||
user.role_id = role_obj.id
|
||||
user.role = role_obj.name
|
||||
user.is_active = is_active
|
||||
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
flash(_('La nueva contraseña debe tener al menos 6 caracteres.'), 'error')
|
||||
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Usuario "%(name)s" actualizado correctamente.', name=user.name), 'success')
|
||||
return redirect(url_for('admin.users_list'))
|
||||
|
||||
return render_template('admin/users/form.html', user=user, roles=roles)
|
||||
|
||||
|
||||
@admin_bp.route('/users/toggle/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def user_toggle(id):
|
||||
"""Toggle user active status"""
|
||||
user = User.query.get_or_404(id)
|
||||
if user.id == current_user.id:
|
||||
flash(_('No puedes desactivar tu propia cuenta.'), 'error')
|
||||
return redirect(url_for('admin.users_list'))
|
||||
|
||||
user.is_active = not user.is_active
|
||||
db.session.commit()
|
||||
status_str = _('activada') if user.is_active else _('desactivada')
|
||||
flash(_('La cuenta de %(name)s ha sido %(status)s.', name=user.name, status=status_str), 'success')
|
||||
return redirect(url_for('admin.users_list'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ROLES & PERMISSIONS MANAGEMENT (FortiGate Matrix)
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/roles')
|
||||
@admin_required
|
||||
def roles_list():
|
||||
"""List all roles and permission summaries"""
|
||||
roles = Role.query.order_by(Role.id.asc()).all()
|
||||
return render_template(
|
||||
'admin/roles/list.html',
|
||||
roles=roles,
|
||||
modules=SYSTEM_MODULES
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/roles/add', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def role_add():
|
||||
"""Create a new role with the FortiGate permission matrix"""
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
flash(_('El nombre del rol es requerido.'), 'error')
|
||||
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||
|
||||
if Role.query.filter_by(name=name).first():
|
||||
flash(_('Ya existe un rol con ese nombre.'), 'error')
|
||||
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||
|
||||
new_role = Role(name=name, description=description, is_system=False)
|
||||
db.session.add(new_role)
|
||||
db.session.flush() # to obtain new_role.id
|
||||
|
||||
# Save permissions from matrix
|
||||
for mod in SYSTEM_MODULES:
|
||||
mod_id = mod['id']
|
||||
level = request.form.get(f'perm_{mod_id}', 'none')
|
||||
if level not in ACCESS_LEVELS:
|
||||
level = 'none'
|
||||
new_role.set_permission(mod_id, level)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Rol "%(name)s" creado exitosamente con sus permisos.', name=new_role.name), 'success')
|
||||
return redirect(url_for('admin.roles_list'))
|
||||
|
||||
return render_template('admin/roles/edit.html', role=None, modules=SYSTEM_MODULES, current_perms={})
|
||||
|
||||
|
||||
@admin_bp.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def role_edit(id):
|
||||
"""Edit an existing role and its FortiGate permission matrix"""
|
||||
role = Role.query.get_or_404(id)
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
if not name:
|
||||
flash(_('El nombre del rol es requerido.'), 'error')
|
||||
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
|
||||
|
||||
existing = Role.query.filter_by(name=name).first()
|
||||
if existing and existing.id != role.id:
|
||||
flash(_('Ya existe otro rol con ese nombre.'), 'error')
|
||||
return render_template('admin/roles/edit.html', role=role, modules=SYSTEM_MODULES, current_perms={p.module: p.access_level for p in role.permissions})
|
||||
|
||||
# If it's a system role, keep name unchanged or protected
|
||||
if not role.is_system:
|
||||
role.name = name
|
||||
role.description = description
|
||||
|
||||
# Update permissions from the FortiGate matrix
|
||||
for mod in SYSTEM_MODULES:
|
||||
mod_id = mod['id']
|
||||
level = request.form.get(f'perm_{mod_id}', 'none')
|
||||
if level not in ACCESS_LEVELS:
|
||||
level = 'none'
|
||||
role.set_permission(mod_id, level)
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Rol "%(name)s" y permisos actualizados correctamente.', name=role.name), 'success')
|
||||
return redirect(url_for('admin.roles_list'))
|
||||
|
||||
current_perms = {p.module: p.access_level for p in role.permissions}
|
||||
return render_template(
|
||||
'admin/roles/edit.html',
|
||||
role=role,
|
||||
modules=SYSTEM_MODULES,
|
||||
current_perms=current_perms
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/roles/delete/<int:id>', methods=['POST'])
|
||||
@admin_required
|
||||
def role_delete(id):
|
||||
"""Delete a non-system role if it has no assigned users"""
|
||||
role = Role.query.get_or_404(id)
|
||||
if role.is_system:
|
||||
flash(_('No se pueden eliminar roles protegidos del sistema.'), 'error')
|
||||
return redirect(url_for('admin.roles_list'))
|
||||
|
||||
user_count = len(role.users) if role.users else 0
|
||||
if user_count > 0:
|
||||
flash(_('No se puede eliminar el rol "%(name)s" porque tiene %(count)d usuario(s) asignado(s). Reasígnalos primero.', name=role.name, count=user_count), 'error')
|
||||
return redirect(url_for('admin.roles_list'))
|
||||
|
||||
role_name = role.name
|
||||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
flash(_('El rol "%(name)s" ha sido eliminado.', name=role_name), 'success')
|
||||
return redirect(url_for('admin.roles_list'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# GOOGLE SHEETS IMPORT & SYNCHRONIZATION
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@admin_bp.route('/import', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def google_sheets_import():
|
||||
"""Manual sync and status page for Google Sheets academic schedule data"""
|
||||
from app.services.sheets_importer import GoogleSheetsImporter, SHEETS_CONFIG
|
||||
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
|
||||
|
||||
stats = None
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
importer = GoogleSheetsImporter()
|
||||
stats = importer.sync()
|
||||
if stats['errors']:
|
||||
flash(_('La sincronización se completó con algunas advertencias.'), 'warning')
|
||||
else:
|
||||
flash(_('Sincronización completada exitosamente. %(rows)d registros procesados.', rows=stats['total_rows_processed']), 'success')
|
||||
except Exception as e:
|
||||
flash(_('Error al sincronizar con Google Sheets: %(err)s', err=str(e)), 'error')
|
||||
|
||||
# Calculate current system metrics
|
||||
metrics = {
|
||||
'total_classrooms': Classroom.query.count(),
|
||||
'total_careers': Career.query.count(),
|
||||
'total_subjects': Subject.query.count(),
|
||||
'total_commissions': Commission.query.count(),
|
||||
'total_reservations': Reservation.query.count(),
|
||||
'sheets': SHEETS_CONFIG
|
||||
}
|
||||
|
||||
# Recent samples from DB if available
|
||||
recent_subjects = Subject.query.order_by(Subject.id.desc()).limit(10).all()
|
||||
|
||||
return render_template(
|
||||
'admin/import.html',
|
||||
metrics=metrics,
|
||||
stats=stats,
|
||||
recent_subjects=recent_subjects
|
||||
)
|
||||
|
||||
+38
-9
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.forms.auth import LoginForm
|
||||
@@ -19,7 +20,7 @@ def login():
|
||||
|
||||
if user and user.check_password(form.password.data):
|
||||
if not user.is_active:
|
||||
flash('Your account has been deactivated. Please contact administrator.', 'error')
|
||||
flash(_('Tu cuenta ha sido desactivada. Por favor contacta al administrador.'), 'error')
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
@@ -28,15 +29,20 @@ def login():
|
||||
# Si el usuario tiene idioma configurado en su perfil, sobrescribe el navegador en toda la sesión
|
||||
if user.preferred_language and user.preferred_language in ['es', 'en']:
|
||||
session['language'] = user.preferred_language
|
||||
else:
|
||||
session.pop('language', None)
|
||||
|
||||
session.pop('login_lang_explicit', None)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not next_page.startswith('/'):
|
||||
next_page = url_for('main.dashboard')
|
||||
|
||||
flash(f'Welcome back, {user.first_name} {user.last_name}!', 'success')
|
||||
user_full_name = f'{user.first_name} {user.last_name}'.strip() or user.name
|
||||
flash(_('¡Bienvenido de nuevo, %(name)s!', name=user_full_name), 'success')
|
||||
return redirect(next_page)
|
||||
else:
|
||||
flash('Invalid email or password.', 'error')
|
||||
flash(_('Correo electrónico o contraseña inválidos.'), 'error')
|
||||
|
||||
return render_template('auth/login.html', form=form)
|
||||
|
||||
@@ -45,25 +51,48 @@ def login():
|
||||
def logout():
|
||||
"""Handle user logout"""
|
||||
logout_user()
|
||||
# Al cerrar sesión, se limpia el idioma para que el login use el del navegador por defecto
|
||||
# Al cerrar sesión, se limpian las cookies de idioma para que el login detecte el navegador o use español
|
||||
session.pop('language', None)
|
||||
flash('You have been logged out successfully.', 'info')
|
||||
session.pop('login_lang_explicit', None)
|
||||
flash(_('Has cerrado sesión correctamente.'), 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page"""
|
||||
"""User profile page with language and theme settings"""
|
||||
if request.method == 'POST':
|
||||
lang = request.form.get('preferred_language')
|
||||
if lang in ['es', 'en']:
|
||||
current_user.preferred_language = lang
|
||||
session['language'] = lang
|
||||
db.session.commit()
|
||||
flash('Idioma de preferencia actualizado correctamente.', 'success')
|
||||
else:
|
||||
# Cadena vacía o None restablece la detección automática del navegador
|
||||
current_user.preferred_language = None
|
||||
session.pop('language', None)
|
||||
|
||||
theme = request.form.get('theme_preference')
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Preferencias de usuario actualizadas correctamente.'), 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
return render_template('auth/profile.html')
|
||||
|
||||
@auth_bp.route('/api/user/theme', methods=['POST'])
|
||||
@login_required
|
||||
def update_theme_api():
|
||||
"""Update user theme preference via fetch/AJAX from navbar toggle"""
|
||||
data = request.get_json(silent=True) or request.form
|
||||
theme = data.get('theme') if data else None
|
||||
if theme in ['auto', 'light', 'dark']:
|
||||
current_user.theme_preference = theme
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success', 'theme': theme})
|
||||
return jsonify({'status': 'error', 'message': 'Invalid theme'}), 400
|
||||
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.building import Building
|
||||
from app.models.classroom import Classroom
|
||||
from app.forms.building import BuildingForm
|
||||
from app.utils.decorators import permission_required
|
||||
from datetime import datetime
|
||||
|
||||
buildings_bp = Blueprint('buildings', __name__, url_prefix='/buildings')
|
||||
|
||||
@buildings_bp.route('/')
|
||||
@login_required
|
||||
def list_buildings():
|
||||
"""List all buildings with statistics"""
|
||||
search = request.args.get('search', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
|
||||
query = Building.query
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Building.name.ilike(f'%{search}%'),
|
||||
Building.code.ilike(f'%{search}%'),
|
||||
Building.address.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
if status_filter == 'active':
|
||||
query = query.filter(Building.is_active == True)
|
||||
elif status_filter == 'inactive':
|
||||
query = query.filter(Building.is_active == False)
|
||||
|
||||
buildings = query.order_by(Building.name.asc()).all()
|
||||
|
||||
# Calculate global building stats
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
total_capacity = sum(c.capacity for c in Classroom.query.filter_by(is_active=True).all())
|
||||
|
||||
return render_template(
|
||||
'buildings/list.html',
|
||||
buildings=buildings,
|
||||
total_classrooms=total_classrooms,
|
||||
total_capacity=total_capacity,
|
||||
search=search,
|
||||
status_filter=status_filter,
|
||||
title='Building Management'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def add_building():
|
||||
"""Add new building"""
|
||||
form = BuildingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower()
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe un edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
building = Building(
|
||||
name=form.name.data.strip(),
|
||||
code=form.code.data.strip().upper() if form.code.data else None,
|
||||
address=form.address.data.strip() if form.address.data else None,
|
||||
floors=form.floors.data,
|
||||
description=form.description.data.strip() if form.description.data else None,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" creado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al crear el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, title='Add Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_building(id):
|
||||
"""View building details and its classrooms by floor"""
|
||||
building = Building.query.get_or_404(id)
|
||||
|
||||
# Group active classrooms by floor
|
||||
classrooms = Classroom.query.filter_by(building_id=building.id).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
# Fallback to name matching if building_id was not set
|
||||
if not classrooms:
|
||||
classrooms = Classroom.query.filter_by(building=building.name).order_by(Classroom.floor.asc(), Classroom.code.asc()).all()
|
||||
|
||||
classrooms_by_floor = {}
|
||||
for c in classrooms:
|
||||
floor_key = c.floor
|
||||
if floor_key not in classrooms_by_floor:
|
||||
classrooms_by_floor[floor_key] = []
|
||||
classrooms_by_floor[floor_key].append(c)
|
||||
|
||||
return render_template(
|
||||
'buildings/view.html',
|
||||
building=building,
|
||||
classrooms=classrooms,
|
||||
classrooms_by_floor=classrooms_by_floor,
|
||||
title=f'Building - {building.name}'
|
||||
)
|
||||
|
||||
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def edit_building(id):
|
||||
"""Edit building"""
|
||||
building = Building.query.get_or_404(id)
|
||||
form = BuildingForm(obj=building)
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
# Check unique name excluding current
|
||||
existing = Building.query.filter(
|
||||
db.func.lower(Building.name) == form.name.data.lower(),
|
||||
Building.id != id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Ya existe otro edificio con este nombre.', 'danger')
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
old_name = building.name
|
||||
new_name = form.name.data.strip()
|
||||
|
||||
building.name = new_name
|
||||
building.code = form.code.data.strip().upper() if form.code.data else None
|
||||
building.address = form.address.data.strip() if form.address.data else None
|
||||
building.floors = form.floors.data
|
||||
building.description = form.description.data.strip() if form.description.data else None
|
||||
building.is_active = form.is_active.data
|
||||
building.updated_at = datetime.utcnow()
|
||||
|
||||
# If name changed, update string building on classrooms
|
||||
if old_name != new_name:
|
||||
Classroom.query.filter_by(building_id=building.id).update({'building': new_name})
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" actualizado con éxito.', 'success')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al actualizar el edificio: {str(e)}', 'danger')
|
||||
|
||||
return render_template('buildings/form.html', form=form, building=building, title='Edit Building')
|
||||
|
||||
@buildings_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def toggle_building(id):
|
||||
"""Toggle building active status"""
|
||||
building = Building.query.get_or_404(id)
|
||||
try:
|
||||
building.is_active = not building.is_active
|
||||
building.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
status_text = 'activado' if building.is_active else 'desactivado'
|
||||
flash(f'Edificio "{building.name}" {status_text} con éxito.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al cambiar estado del edificio: {str(e)}', 'danger')
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
|
||||
@buildings_bp.route('/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@permission_required('classrooms', 'read_write')
|
||||
def delete_building(id):
|
||||
"""Delete building if it has no classrooms assigned"""
|
||||
building = Building.query.get_or_404(id)
|
||||
has_classrooms = Classroom.query.filter(
|
||||
db.or_(Classroom.building_id == building.id, Classroom.building == building.name)
|
||||
).count()
|
||||
|
||||
if has_classrooms > 0:
|
||||
flash(f'No se puede eliminar el edificio "{building.name}" porque tiene {has_classrooms} aulas asociadas. Primero reasigna o elimina las aulas.', 'danger')
|
||||
return redirect(url_for('buildings.view_building', id=building.id))
|
||||
|
||||
try:
|
||||
db.session.delete(building)
|
||||
db.session.commit()
|
||||
flash(f'Edificio "{building.name}" eliminado correctamente.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error al eliminar edificio: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('buildings.list_buildings'))
|
||||
@@ -30,12 +30,18 @@ def add_classroom():
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
classroom = Classroom(
|
||||
# campus_id=form.campus_id.data,
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data,
|
||||
capacity=form.capacity.data,
|
||||
capacity=cap_val,
|
||||
description=form.description.data,
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
@@ -125,6 +131,12 @@ def edit_classroom(id):
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
if request.method == 'GET':
|
||||
if classroom.capacity == 0 or (classroom.code and 'VIRTUAL' in classroom.code.upper()):
|
||||
form.capacity.data = '∞'
|
||||
else:
|
||||
form.capacity.data = str(classroom.capacity)
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
|
||||
@@ -142,12 +154,17 @@ def edit_classroom(id):
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||
|
||||
cap_raw = str(form.capacity.data or '').strip()
|
||||
if cap_raw in ['∞', 'inf', 'INF', 'Infinity', 'infinity', '0', 'ilimitada', 'Ilimitada', 'sin limite', 'Sin límite']:
|
||||
cap_val = 0
|
||||
else:
|
||||
cap_val = int(cap_raw)
|
||||
|
||||
# Update classroom
|
||||
# classroom.campus_id = form.campus_id.data
|
||||
classroom.building = form.building.data
|
||||
classroom.code = form.room_number.data
|
||||
classroom.floor = form.floor.data
|
||||
classroom.capacity = form.capacity.data
|
||||
classroom.capacity = cap_val
|
||||
classroom.description = form.description.data
|
||||
classroom.is_active = form.is_active.data
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
|
||||
+137
-132
@@ -1,132 +1,137 @@
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_subjects = 0
|
||||
|
||||
# Get today's reservations
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
week_reservations = 0
|
||||
|
||||
# Get upcoming reservations (next 7 days)
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).limit(10).all()
|
||||
except:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'en'
|
||||
|
||||
session['language'] = language
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))
|
||||
from flask import Blueprint, render_template, jsonify, redirect, url_for, session, request, flash
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject import Commission
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from flask_babel import gettext as _
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
# Get statistics
|
||||
try:
|
||||
total_classrooms = Classroom.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_classrooms = 0
|
||||
|
||||
try:
|
||||
total_subjects = Subject.query.filter_by(is_active=True).count()
|
||||
except:
|
||||
total_subjects = 0
|
||||
|
||||
# Get today's reservations
|
||||
today = datetime.utcnow().date()
|
||||
try:
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
except:
|
||||
today_reservations = 0
|
||||
|
||||
# Get this week's reservations
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
try:
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
except:
|
||||
week_reservations = 0
|
||||
|
||||
# Get upcoming reservations (next 7 days)
|
||||
try:
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.start_time <= datetime.utcnow() + timedelta(days=7)
|
||||
).limit(10).all()
|
||||
except:
|
||||
upcoming_reservations = []
|
||||
|
||||
stats = {
|
||||
'total_classrooms': total_classrooms,
|
||||
'total_subjects': total_subjects,
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations
|
||||
}
|
||||
|
||||
return render_template('dashboard.html',
|
||||
stats=stats,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
|
||||
@main_bp.route('/api/dashboard-stats')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
# Get today's data
|
||||
today = datetime.utcnow().date()
|
||||
today_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) == today
|
||||
).count()
|
||||
|
||||
# Get this week's data
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= week_start,
|
||||
func.date(Reservation.start_time) <= week_end
|
||||
).count()
|
||||
|
||||
# Get monthly data for chart
|
||||
current_date = datetime.utcnow()
|
||||
monthly_data = []
|
||||
|
||||
for i in range(12):
|
||||
month_date = current_date - timedelta(days=30 * i)
|
||||
month_start = month_date.replace(day=1)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
monthly_reservations = Reservation.query.filter(
|
||||
func.date(Reservation.start_time) >= month_start.date(),
|
||||
func.date(Reservation.start_time) <= month_end.date()
|
||||
).count()
|
||||
|
||||
monthly_data.append({
|
||||
'month': month_start.strftime('%Y-%m'),
|
||||
'reservations': monthly_reservations
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'today_reservations': today_reservations,
|
||||
'week_reservations': week_reservations,
|
||||
'monthly_data': list(reversed(monthly_data))
|
||||
})
|
||||
|
||||
@main_bp.route('/genetic-optimizer')
|
||||
@login_required
|
||||
def genetic_optimizer():
|
||||
"""Serve the genetic algorithm optimization interface"""
|
||||
if current_user.role not in ['ADMIN']:
|
||||
flash('You do not have permission to access the optimization tool.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('genetic_optimizer.html')
|
||||
|
||||
@main_bp.route('/set_language/<language>')
|
||||
def set_language(language=None):
|
||||
if language not in ['en', 'es']:
|
||||
language = 'es'
|
||||
|
||||
session['language'] = language
|
||||
|
||||
# Si se selecciona el idioma desde la pantalla de login, registrar la selección explícita
|
||||
if request.referrer and 'login' in request.referrer:
|
||||
session['login_lang_explicit'] = language
|
||||
|
||||
if current_user.is_authenticated:
|
||||
current_user.preferred_language = language
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('main.dashboard'))␍
|
||||
+214
-39
@@ -14,21 +14,39 @@ schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||
@schedule_bp.route('/today')
|
||||
@login_required
|
||||
def today_schedule():
|
||||
"""Show today's schedule"""
|
||||
"""Show today's schedule with filters for virtual classrooms, floor, and shift"""
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
|
||||
# Get reservations with eager loading
|
||||
reservations = Reservation.query.options(
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
|
||||
# Base query for today's reservations
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).filter(
|
||||
).join(Reservation.classroom).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
)
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter and floor_filter.isdigit():
|
||||
query = query.filter(Classroom.floor == int(floor_filter))
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
# Create time blocks for timeline view
|
||||
time_blocks = {}
|
||||
@@ -49,11 +67,9 @@ def today_schedule():
|
||||
|
||||
# Calculate stats
|
||||
total_reservations = len(reservations)
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING])
|
||||
confirmed = len([r for r in reservations if r.status == ReservationStatus.CONFIRMED.value or str(r.status) == 'CONFIRMED'])
|
||||
pending = len([r for r in reservations if r.status == ReservationStatus.PENDING or str(r.status) == 'PENDING'])
|
||||
|
||||
# Get total classrooms and calculate occupied ones
|
||||
from app.models.classroom import Classroom
|
||||
total_classrooms = Classroom.query.count()
|
||||
occupied_classrooms = len(set(r.classroom_id for r in reservations if r.classroom_id))
|
||||
|
||||
@@ -64,12 +80,19 @@ def today_schedule():
|
||||
'occupied_classrooms': occupied_classrooms,
|
||||
'total_classrooms': total_classrooms
|
||||
}
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template('schedule/today.html',
|
||||
reservations=reservations,
|
||||
time_blocks=time_blocks,
|
||||
stats=stats,
|
||||
today=today)
|
||||
today=today,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
available_floors=available_floors)
|
||||
|
||||
@schedule_bp.route('/cancel/<int:id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -115,8 +138,24 @@ def view_reservation(id):
|
||||
@schedule_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
"""Show calendar view"""
|
||||
return render_template('schedule/calendar.html', title='Calendar View')
|
||||
"""Show calendar view with classroom, subject, career, shift, floor and virtual filtering"""
|
||||
from app.models.career import Career
|
||||
classrooms = Classroom.query.filter_by(is_active=True).order_by(Classroom.building.asc(), Classroom.code.asc()).all()
|
||||
subjects = Subject.query.filter_by(is_active=True).order_by(Subject.name.asc()).all()
|
||||
careers = Career.query.order_by(Career.name.asc()).all()
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template(
|
||||
'schedule/calendar.html',
|
||||
title='Calendar View',
|
||||
today_date=date.today().isoformat(),
|
||||
classrooms=classrooms,
|
||||
subjects=subjects,
|
||||
careers=careers,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
@schedule_bp.route('/commissions')
|
||||
def commissions_view():
|
||||
@@ -197,8 +236,80 @@ def add_reservation():
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def list_reservations():
|
||||
"""List all reservations"""
|
||||
return redirect(url_for('schedule.today_schedule'))
|
||||
"""List all reservations with pagination, date filters, floor, shift, and virtual toggle"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', 'all').strip()
|
||||
date_from = request.args.get('date_from', '').strip()
|
||||
date_to = request.args.get('date_to', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
floor_filter = request.args.get('floor', '').strip()
|
||||
shift_filter = request.args.get('shift', '').strip()
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
query = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject),
|
||||
joinedload(Reservation.user)
|
||||
).join(Reservation.classroom).outerjoin(Reservation.commission).outerjoin(Commission.subject)
|
||||
|
||||
if status_filter and status_filter != 'all':
|
||||
query = query.filter(Reservation.status == status_filter.upper())
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
d_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
query = query.filter(Reservation.start_time >= d_from)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
d_to = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||
query = query.filter(Reservation.start_time < d_to)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor_filter and floor_filter.isdigit():
|
||||
query = query.filter(Classroom.floor == int(floor_filter))
|
||||
|
||||
if shift_filter:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift_filter}%'))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
floors = db.session.query(Classroom.floor).filter(Classroom.is_active==True, Classroom.floor.isnot(None)).distinct().order_by(Classroom.floor.asc()).all()
|
||||
available_floors = [f[0] for f in floors]
|
||||
|
||||
return render_template(
|
||||
'schedule/list.html',
|
||||
reservations=reservations,
|
||||
status_filter=status_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
hide_virtual=hide_virtual,
|
||||
floor_filter=floor_filter,
|
||||
shift_filter=shift_filter,
|
||||
search=search,
|
||||
available_floors=available_floors
|
||||
)
|
||||
|
||||
|
||||
@schedule_bp.route('/reservation/<int:id>/confirm', methods=['GET', 'POST'])
|
||||
@@ -321,46 +432,101 @@ def classroom_availability_api():
|
||||
def calendar_data():
|
||||
"""Get calendar events data for FullCalendar"""
|
||||
try:
|
||||
# Get date range from query parameters
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
classroom_id = request.args.get('classroom_id')
|
||||
subject_id = request.args.get('subject_id')
|
||||
career_id = request.args.get('career_id')
|
||||
shift = request.args.get('shift', '').strip()
|
||||
floor = request.args.get('floor', '').strip()
|
||||
hide_virtual = request.args.get('hide_virtual', '0') in ['1', 'true', 'True']
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
if start and end:
|
||||
from datetime import datetime
|
||||
start_date = datetime.fromisoformat(start)
|
||||
end_date = datetime.fromisoformat(end)
|
||||
# Clean up ISO timezone strings for PostgreSQL naive timestamp comparison
|
||||
start_clean = start.replace('Z', '+00:00')
|
||||
end_clean = end.replace('Z', '+00:00')
|
||||
start_date = datetime.fromisoformat(start_clean).replace(tzinfo=None)
|
||||
end_date = datetime.fromisoformat(end_clean).replace(tzinfo=None)
|
||||
else:
|
||||
# Default to current month
|
||||
today = date.today()
|
||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
||||
next_month = today.replace(day=28) + timedelta(days=4)
|
||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||
|
||||
# Get reservations in the date range
|
||||
reservations = Reservation.query.options(
|
||||
query = Reservation.query.join(Reservation.commission).join(Commission.subject).outerjoin(Reservation.classroom).options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED.value, ReservationStatus.PENDING.value])
|
||||
).order_by(Reservation.start_time).all()
|
||||
Reservation.start_time <= end_date
|
||||
)
|
||||
|
||||
if classroom_id and classroom_id.isdigit():
|
||||
query = query.filter(Reservation.classroom_id == int(classroom_id))
|
||||
|
||||
if subject_id and subject_id.isdigit():
|
||||
query = query.filter(Commission.subject_id == int(subject_id))
|
||||
|
||||
if career_id and career_id.isdigit():
|
||||
query = query.filter(Subject.career_id == int(career_id))
|
||||
|
||||
if shift:
|
||||
query = query.filter(Reservation.shift.ilike(f'%{shift}%'))
|
||||
|
||||
if hide_virtual:
|
||||
query = query.filter(
|
||||
~Classroom.code.ilike('%VIRTUAL%'),
|
||||
~Classroom.building.ilike('%Virtual%')
|
||||
)
|
||||
|
||||
if floor and floor.isdigit():
|
||||
query = query.filter(Classroom.floor == int(floor))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Subject.name.ilike(f'%{search}%'),
|
||||
Subject.code.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Reservation.purpose.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
reservations = query.order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
classroom_floor = reservation.classroom.floor if reservation.classroom else None
|
||||
is_virtual = bool(reservation.classroom and ('VIRTUAL' in (reservation.classroom.code or '').upper() or 'VIRTUAL' in (reservation.classroom.building or '').upper()))
|
||||
career_name = subject.career_obj.name if (subject and subject.career_obj) else (subject.department if subject else '')
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': subject_name,
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'className': f'fc-event-{reservation.status.value.lower()}',
|
||||
'className': f'fc-event-{status_val.lower()}',
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value,
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'building': reservation.classroom.building if reservation.classroom else '',
|
||||
'floor': classroom_floor,
|
||||
'is_virtual': is_virtual,
|
||||
'career': career_name,
|
||||
'commission': reservation.commission.code if reservation.commission else '',
|
||||
'shift': reservation.shift or '',
|
||||
'status': status_val,
|
||||
'purpose': reservation.purpose or '',
|
||||
'attendees': reservation.expected_attendees or 0,
|
||||
'notes': reservation.notes or ''
|
||||
}
|
||||
})
|
||||
@@ -368,6 +534,8 @@ def calendar_data():
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/api/today-events')
|
||||
@@ -380,26 +548,33 @@ def api_today_events():
|
||||
|
||||
reservations = Reservation.query.options(
|
||||
joinedload(Reservation.classroom),
|
||||
joinedload(Reservation.commission)
|
||||
joinedload(Reservation.commission).joinedload(Commission.subject).joinedload(Subject.career_obj)
|
||||
).filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status == ReservationStatus.CONFIRMED.value
|
||||
Reservation.start_time <= today_end
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
subject = reservation.commission.subject if (reservation.commission and reservation.commission.subject) else None
|
||||
subject_code = subject.code if subject else (reservation.commission.code if reservation.commission else '')
|
||||
subject_name = subject.name if subject else (reservation.purpose or 'Clase')
|
||||
classroom_name = reservation.classroom.code if reservation.classroom else 'Sin Aula'
|
||||
status_val = str(reservation.status.value if hasattr(reservation.status, 'value') else reservation.status)
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'title': f"{subject_name} ({subject_code}) - {classroom_name}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', id=reservation.id) if reservation.id else None,
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'subject_code': subject_code,
|
||||
'subject_name': subject_name,
|
||||
'classroom': classroom_name,
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value
|
||||
'status': status_val
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user