Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ ENABLE_MANUAL_ENTRY=True

# --- Timeline Visual ---
# False: Muestra el timeline visual en fichajes. True: Lo oculta.
HIDE_TIMELINE=False
HIDE_TIMELINE=False

# --- Zona Horaria ---
TIMEZONE=Europe/Madrid
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ pytest
psycopg2-binary
gevent
ecs-logging
pytz
1 change: 1 addition & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions src/email_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,28 +44,39 @@ 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]

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
)

msg.body = f'''
Hola,

{solicitante.nombre} ha solicitado vacaciones:
{solicitante.nombre} ha solicitado {tipo_texto}:

- Desde: {solicitud.fecha_inicio}
- Hasta: {solicitud.fecha_fin}
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/routes/ausencias.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'))
49 changes: 27 additions & 22 deletions src/routes/fichajes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion src/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
4 changes: 2 additions & 2 deletions src/static/js/modal_confirmations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand All @@ -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'
);
});
});
Expand Down
8 changes: 8 additions & 0 deletions templates/cronograma.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ <h5><i class="bi bi-info-circle"></i> Leyenda</h5>
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',
Expand Down
2 changes: 1 addition & 1 deletion templates/fichajes.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ <h1><i class="bi bi-clock"></i> Mis Fichajes</h1>
<div class="col-auto">
<label class="form-label">Año</label>
<select name="anio" class="form-select" onchange="this.form.submit()">
{% for a in range(2023, 2027) %}
{% for a in anios_disponibles %}
<option value="{{ a }}" {% if a==anio_actual %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
Expand Down