From 86f00275c3f3b01aab238e86976b4f852a578f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 16:09:26 +0100 Subject: [PATCH 1/8] fix: cambiar el texto de los popups de confirmacion "Cancelar o Cancelar" no es muy intuitivo. Mejor "Cancelar o Confirmar". --- src/static/js/modal_confirmations.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/static/js/modal_confirmations.js b/src/static/js/modal_confirmations.js index db7d239..fd44fe5 100644 --- a/src/static/js/modal_confirmations.js +++ b/src/static/js/modal_confirmations.js @@ -16,7 +16,7 @@ document.addEventListener('DOMContentLoaded', function () { ? `¿Estás seguro de que deseas cancelar tu solicitud de vacaciones (${fechas})?` : `¿Estás seguro de que deseas solicitar la cancelación de tus vacaciones aprobadas (${fechas})?\n\nEsta acción requerirá aprobación.`; - showConfirmModal(title, message, () => submitFormWithConfirm(form), 'danger', 'Cancelar'); + showConfirmModal(title, message, () => submitFormWithConfirm(form), 'danger', 'Confirmar'); }); }); @@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () { `¿Estás seguro de que deseas cancelar tu solicitud de baja/ausencia (${fechas})?`, () => submitFormWithConfirm(form), 'danger', - 'Cancelar' + 'Confirmar' ); }); }); From 5b86c8ce8047dec40088e3dcd1f19fc4cf8e7142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 16:09:56 +0100 Subject: [PATCH 2/8] fix: ajustes calendario - Traducidos textos de botones - La semana ahora empieza en Lunes --- templates/cronograma.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/templates/cronograma.html b/templates/cronograma.html index f36e548..4710408 100644 --- a/templates/cronograma.html +++ b/templates/cronograma.html @@ -33,6 +33,14 @@
Leyenda
var calendar = new FullCalendar.Calendar(calendarEl, { initialView: 'dayGridMonth', locale: 'es', + firstDay: 1, + buttonText: { + today: 'Hoy', + month: 'Mes', + week: 'Semana', + day: 'Día', + list: 'Agenda' + }, headerToolbar: { left: 'prev,next today', center: 'title', From 1da9edce414b557222c517db4c88c8d0a4549abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 16:21:29 +0100 Subject: [PATCH 3/8] fix: filtrado de vacaciones en cronograma El cronograma estaba mostrando vacaciones canceladas - ahora las filtra para que no aparezcan --- src/routes/main.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/routes/main.py b/src/routes/main.py index c4ca2f6..13ad8c4 100644 --- a/src/routes/main.py +++ b/src/routes/main.py @@ -100,7 +100,15 @@ def perfil(): @main_bp.route('/cronograma') @login_required def cronograma(): - solicitudes_vac = SolicitudVacaciones.query.filter_by(estado='aprobada', es_actual=True).all() + + # Excluimos cancelaciones en vacaciones + solicitudes_vac = SolicitudVacaciones.query.filter( + SolicitudVacaciones.estado == 'aprobada', + SolicitudVacaciones.es_actual == True, + SolicitudVacaciones.tipo_accion != 'cancelacion' + ).all() + + # Filtramos bajas por estado "aprobada" solicitudes_bajas = SolicitudBaja.query.filter_by(estado='aprobada', es_actual=True).all() eventos = [] From 1011ab906cafff4253538743cc567856460e0bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 16:23:06 +0100 Subject: [PATCH 4/8] chore: emails en bajas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los correos no estaban implementados en la solicitud de bajas, se han añadido. Para ello se ha modificado la función "enviar_email_solicitud" para que compruebe el tipo de solicitud y adapte el texto. Lo veo mejor que hacer una función para cada cosa duplicando el código. --- src/email_service.py | 46 ++++++++++++++++++++++++++++++++--------- src/routes/ausencias.py | 18 ++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/email_service.py b/src/email_service.py index 193adbb..5d668e9 100644 --- a/src/email_service.py +++ b/src/email_service.py @@ -44,8 +44,8 @@ def _send_async(app, msg): def enviar_email_solicitud(aprobadores, solicitante, solicitud): """ - MODIFICADO: Ahora acepta una lista de objetos de usuario (aprobadores). Envía email de notificación de nueva solicitud a todos los responsables. + Adaptado para manejar tanto Vacaciones como Bajas. """ # Extraer los correos de la lista de objetos recibida emails_destinatarios = [a.email for a in aprobadores] @@ -53,11 +53,22 @@ def enviar_email_solicitud(aprobadores, solicitante, solicitud): if not emails_destinatarios: return + # Determinar tipo de solicitud para el texto + # Si tiene atributo 'tipo_ausencia', es una Baja/Ausencia + es_baja = hasattr(solicitud, 'tipo_ausencia') + if es_baja: + nombre_tipo = solicitud.tipo_ausencia.nombre if solicitud.tipo_ausencia else "Baja/Ausencia" + tipo_texto = f"ausencia ({nombre_tipo})" + asunto_tipo = "Ausencia/Baja" + else: + tipo_texto = "vacaciones" + asunto_tipo = "Vacaciones" + # Construir lista de nombres para el cuerpo del mensaje nombres_aprobadores = ', '.join([a.nombre for a in aprobadores]) msg = Message( - subject=f'Nueva solicitud de vacaciones de {solicitante.nombre}', + subject=f'Nueva solicitud de {asunto_tipo} de {solicitante.nombre}', sender=current_app.config['MAIL_DEFAULT_SENDER'], recipients=emails_destinatarios ) @@ -65,7 +76,7 @@ def enviar_email_solicitud(aprobadores, solicitante, solicitud): msg.body = f''' Hola, -{solicitante.nombre} ha solicitado vacaciones: +{solicitante.nombre} ha solicitado {tipo_texto}: - Desde: {solicitud.fecha_inicio} - Hasta: {solicitud.fecha_fin} @@ -95,29 +106,43 @@ def handle_email_result(fut): def enviar_email_respuesta(usuario, solicitud): """ - Envía email de notificación de respuesta a solicitud. - Usa ThreadPoolExecutor para envío asíncrono seguro. + Envía email de notificación de respuesta (aprobación/rechazo). + Funciona para Vacaciones y Bajas. """ - estado_texto = "APROBADA" if solicitud.state == 'aprobada' else "RECHAZADA" + # Corregido: Usar .estado en lugar de .state + estado_texto = "APROBADA" if solicitud.estado == 'aprobada' else "RECHAZADA" + + # Determinar tipo de texto + es_baja = hasattr(solicitud, 'tipo_ausencia') + if es_baja: + nombre_tipo = solicitud.tipo_ausencia.nombre if solicitud.tipo_ausencia else "Ausencia" + tipo_texto = f"ausencia ({nombre_tipo})" + else: + tipo_texto = "vacaciones" msg = Message( - subject=f'Tu solicitud de vacaciones ha sido {estado_texto}', + subject=f'Tu solicitud de {tipo_texto} ha sido {estado_texto}', sender=current_app.config['MAIL_DEFAULT_SENDER'], recipients=[usuario.email] ) + # Obtener nombre del aprobador de forma segura + nombre_aprobador = 'Sistema' + if solicitud.aprobador: + nombre_aprobador = solicitud.aprobador.nombre + msg.body = f''' Hola {usuario.nombre}, -Tu solicitud de vacaciones ha sido {estado_texto}. +Tu solicitud de {tipo_texto} ha sido {estado_texto}. Detalles de la solicitud: - Desde: {solicitud.fecha_inicio} - Hasta: {solicitud.fecha_fin} - Días solicitados: {solicitud.dias_solicitados} - Estado: {estado_texto} -- Respondida por: {solicitud.aprobador.nombre if solicitud.aprobador else 'Sistema'} -- Fecha de respuesta: {solicitud.fecha_respuesta} +- Respondida por: {nombre_aprobador} +- Fecha de respuesta: {solicitud.fecha_respuesta or 'N/A'} {f"- Comentarios: {solicitud.comentarios}" if solicitud.comentarios else ""} Saludos, @@ -136,6 +161,7 @@ def handle_email_result(fut): future.add_done_callback(handle_email_result) + def enviar_email_otp(usuario, codigo): """ Envía email con el código OTP para verificación de MFA. diff --git a/src/routes/ausencias.py b/src/routes/ausencias.py index 3d4a64c..7b082ee 100644 --- a/src/routes/ausencias.py +++ b/src/routes/ausencias.py @@ -421,6 +421,16 @@ def solicitar_baja(): db.session.add(solicitud) db.session.commit() + + # Enviar Email a los aprobadores + aprobadores = [rel.aprobador for rel in target_user.aprobadores] + if not aprobadores: + admin = Usuario.query.filter_by(rol='admin').first() + if admin: aprobadores = [admin] + + if aprobadores: + from src.email_service import enviar_email_solicitud + enviar_email_solicitud(aprobadores, target_user, solicitud) msg_exito = f'Baja registrada y aprobada para {target_user.nombre}.' if es_admin_gestion else 'Baja/Permiso registrado correctamente.' flash(msg_exito, 'success') @@ -665,4 +675,12 @@ def responder_baja(id, accion): solicitud.fecha_respuesta = datetime.utcnow() db.session.commit() + + # Enviar email al usuario con el resultado + try: + from src.email_service import enviar_email_respuesta + enviar_email_respuesta(solicitud.usuario, solicitud) + except Exception as e: + print(f"Error enviando email notificación: {e}") + return redirect(url_for('ausencias.aprobar_solicitudes')) \ No newline at end of file From 519737c0947c3d3bf703f77c93eb72e33b24b3a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 17:01:51 +0100 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20a=C3=B1os=20dinamicos=20en=20pantall?= =?UTF-8?q?a=20fichajes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los años estaban hardcodeados desde 2023 a 2027, lo que es una bomba de tiempo. Ya usaba logica dinámica en el panel de administración, así que la he adaptado para usarla aquí también. --- src/routes/fichajes.py | 39 ++++++++++++++++++++------------------- templates/fichajes.html | 2 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/routes/fichajes.py b/src/routes/fichajes.py index a89d4d2..d9070f6 100644 --- a/src/routes/fichajes.py +++ b/src/routes/fichajes.py @@ -6,36 +6,33 @@ from sqlalchemy.sql import extract from src.utils import es_festivo, verificar_solapamiento, verificar_solapamiento_fichaje, decimal_to_human import uuid +import pytz from src import db from src.models import Fichaje from src.utils import es_festivo, verificar_solapamiento from . import fichajes_bp -# --- HELPER DE ZONA HORARIA (Sencillo y nativo) --- +# --- HELPER DE ZONA HORARIA (usa pytz para obtener la hora real) --- def get_user_now(): """ - Devuelve la fecha y hora actual ajustada a la zona horaria del negocio. - Por defecto intenta simular Europe/Madrid si el servidor está en UTC. - - IMPORTANTE: Esta función actualmente NO calcula DST (horario de verano). - Siempre aplica UTC+1. Para soporte completo de DST, instalar 'pytz'. + Devuelve la fecha y hora actual ajustada a la zona horaria configurada. """ - now = datetime.utcnow() - # Ajuste manual simple para Madrid (UTC+1 en invierno, UTC+2 en verano) - # Una solución más robusta requeriría instalar 'pytz' y configurar la zona en el Usuario. - # TODO: En el futuro, instalar 'pytz' y usar: datetime.now(pytz.timezone('Europe/Madrid')) + # Obtener la zona horaria desde la configuración + tz_name = current_app.config.get('TIMEZONE', 'Europe/Madrid') - # NOTA: DST NO IMPLEMENTADO - Siempre usa UTC+1 - # Para implementar DST correctamente, necesitarías: - # 1. Añadir 'pytz' a requirements.txt - # 2. Usar: datetime.now(pytz.timezone('Europe/Madrid')) - is_dst = False # Hardcoded - DST not calculated - offset = 1 # Always UTC+1 (winter time for Spain) + try: + target_tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + # Fallback de seguridad si el nombre en el .env está mal escrito que soy un manospies + current_app.logger.error(f"Zona horaria desconocida: {tz_name}. Usando UTC.") + target_tz = pytz.utc + + # Obtener la hora actual en esa zona + now_aware = datetime.now(target_tz) - # Si el servidor ya está en hora local (no UTC), no ajustar. - # Para Docker/Nube suele ser UTC. - return now + timedelta(hours=offset) + # Devolver como 'naive' (sin info de zona) para compatibilidad con BBDD + return now_aware.replace(tzinfo=None) # --- RUTAS DE FICHAJES --- @@ -51,6 +48,9 @@ def listar(): hoy = datetime.now() mes = request.args.get('mes', type=int, default=hoy.month) anio = request.args.get('anio', type=int, default=hoy.year) + anio_actual_real = hoy.year + anios_disponibles = list(range(anio_actual_real - 4, anio_actual_real + 2)) + anios_disponibles.reverse() page = request.args.get('page', type=int, default=1) per_page = 50 # Mostrar 50 fichajes por página @@ -121,6 +121,7 @@ def listar(): pagination=pagination, mes_actual=mes, anio_actual=anio, + anios_disponibles=anios_disponibles, total_horas_mes=total_horas_mes, total_fichajes_mes=total_fichajes_mes) diff --git a/templates/fichajes.html b/templates/fichajes.html index f53d8a9..a45c621 100644 --- a/templates/fichajes.html +++ b/templates/fichajes.html @@ -38,7 +38,7 @@

Mis Fichajes

From f962c8562b730f5393559d5f10901b1549bf185a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 17:03:48 +0100 Subject: [PATCH 6/8] feat: uso de pytz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un problenma que había que que siempre se usaba UTC+1 lo cual funciona... la mitad del tiempo XD. He implementado pytz, que es una librería que comprueba la hora REAL de la zona horaria indicada. He puesto que se lea la zona horaria del env, para evitar hardcodear más cosas. Así la hora de los fichajes en verano será la real. --- .env.example | 5 ++++- requirements.txt | 1 + src/__init__.py | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 6173d9e..fbf32fe 100644 --- a/.env.example +++ b/.env.example @@ -51,4 +51,7 @@ ENABLE_MANUAL_ENTRY=True # --- Timeline Visual --- # False: Muestra el timeline visual en fichajes. True: Lo oculta. -HIDE_TIMELINE=False \ No newline at end of file +HIDE_TIMELINE=False + +# --- Zona Horaria --- +TIMEZONE=Europe/Madrid \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0682e70..63dc556 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ pytest psycopg2-binary gevent ecs-logging +pytz \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 2dabf71..5102783 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -93,6 +93,7 @@ def configure_logging(app): app.config['HIDE_TIMELINE'] = os.environ.get('HIDE_TIMELINE', 'False').lower() == 'true' app.config['DEFAULT_ADMIN_EMAIL'] = os.environ.get('DEFAULT_ADMIN_EMAIL', 'admin@example.com') app.config['DEFAULT_ADMIN_INITIAL_PASSWORD'] = os.environ.get('DEFAULT_ADMIN_INITIAL_PASSWORD', 'admin123') +app.config['TIMEZONE'] = os.environ.get('TIMEZONE', 'Europe/Madrid') # Configuración Scheduler app.config['SCHEDULER_API_ENABLED'] = True From 0977ab47cefabbb44cb98f4fb75469bceda1dcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 17:21:29 +0100 Subject: [PATCH 7/8] fix: usar pytz en ediciones y eliminaciones --- src/routes/fichajes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/routes/fichajes.py b/src/routes/fichajes.py index d9070f6..e7a3d63 100644 --- a/src/routes/fichajes.py +++ b/src/routes/fichajes.py @@ -184,7 +184,8 @@ def crear(): fecha=fecha, hora_entrada=hora_entrada, hora_salida=hora_salida, - pausa=pausa + pausa=pausa, + fecha_creacion=get_user_now() ) db.session.add(fichaje) @@ -256,7 +257,8 @@ def editar(id): fecha=datetime.strptime(request.form.get('fecha'), '%Y-%m-%d').date(), hora_entrada=datetime.strptime(request.form.get('hora_entrada'), '%H:%M').time(), hora_salida=datetime.strptime(request.form.get('hora_salida'), '%H:%M').time(), - pausa=pausa + pausa=pausa, + fecha_creacion=get_user_now() ) db.session.add(nuevo_fichaje) @@ -299,6 +301,7 @@ def eliminar(id): tipo_accion='eliminacion', motivo_rectificacion="Eliminado por el usuario/admin", fecha=fichaje_actual.fecha, + fecha_creacion=get_user_now() # Mantenemos datos originales para saber qué se borró hora_entrada=fichaje_actual.hora_entrada, hora_salida=fichaje_actual.hora_salida, @@ -446,7 +449,8 @@ def toggle_fichaje(): fecha=fecha_actual, hora_entrada=hora_actual, hora_salida=None, # IMPORTANTE: Se queda abierto - pausa=0 + pausa=0, + fecha_creacion=ahora_local ) db.session.add(nuevo_fichaje) From 23290100dc550e5a37c795c6d83d92cfd6b9f6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Pastor?= Date: Tue, 3 Feb 2026 17:24:48 +0100 Subject: [PATCH 8/8] fix: missing comma --- src/routes/fichajes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/fichajes.py b/src/routes/fichajes.py index e7a3d63..9f5d5c9 100644 --- a/src/routes/fichajes.py +++ b/src/routes/fichajes.py @@ -301,7 +301,7 @@ def eliminar(id): tipo_accion='eliminacion', motivo_rectificacion="Eliminado por el usuario/admin", fecha=fichaje_actual.fecha, - fecha_creacion=get_user_now() + fecha_creacion=get_user_now(), # Mantenemos datos originales para saber qué se borró hora_entrada=fichaje_actual.hora_entrada, hora_salida=fichaje_actual.hora_salida,