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 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 diff --git a/src/routes/fichajes.py b/src/routes/fichajes.py index a89d4d2..9f5d5c9 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) @@ -183,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) @@ -255,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) @@ -298,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, @@ -445,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) 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 = [] 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' ); }); }); 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 @@