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
17 changes: 10 additions & 7 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,15 @@ def configure_logging(app):
)
app.register_blueprint(google_bp, url_prefix="/login")

# Límite global
# 5 por segundo para proteger contra ataques de fuerza bruta
# 30 por minuto debería ser suficiente para la mayoría de los usuarios
# 3000 por día como límite máximo global, suficiente incluso si se deja la app abierta la 24 horas del día
# Límite global (doblado: la app es de uso interno y los valores anteriores
# saltaban demasiado)
# 10/s para proteger contra ataques de fuerza bruta
# 60/min holgado para uso normal
# 10000/día como límite máximo global
limiter = Limiter(
key_func=get_remote_address,
app=app,
default_limits=["5 per second", "30 per minute", "5000 per day"],
default_limits=["10 per second", "60 per minute", "10000 per day"],
storage_uri="memory://"
)

Expand Down Expand Up @@ -233,7 +234,9 @@ def init_db():
app.register_blueprint(ausencias_bp)
app.register_blueprint(admin_bp)

from src.cli import cerrar_anio_command, import_users_command, init_admin_command
from src.cli import cerrar_anio_command, import_users_command, init_admin_command, recalcular_command, cambiar_saldo_command
app.cli.add_command(cerrar_anio_command)
app.cli.add_command(import_users_command)
app.cli.add_command(init_admin_command)
app.cli.add_command(init_admin_command)
app.cli.add_command(recalcular_command)
app.cli.add_command(cambiar_saldo_command)
209 changes: 204 additions & 5 deletions src/cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import click
from flask.cli import with_appcontext
from src import db
from src.models import Usuario, SaldoVacaciones
from src.models import Usuario, SaldoVacaciones, SolicitudVacaciones, CambioSaldo

MSG_OPERACION_CANCELADA = "❌ Operación cancelada"

@click.command('cerrar-anio')
@click.argument('anio_origen', type=int)
Expand Down Expand Up @@ -45,7 +47,7 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an
print(f"\n⚠️ ADVERTENCIA: Ya existen {saldos_nuevos} saldos para {anio_nuevo}")
print(" Si quieres rehacer el cierre, usa --force")
if not click.confirm('\n¿Continuar de todas formas?', default=False):
print("❌ Operación cancelada")
print(MSG_OPERACION_CANCELADA)
return

# ========================================
Expand Down Expand Up @@ -81,7 +83,7 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an
if not force:
print("\n" + "=" * 70)
if not click.confirm('¿Proceder con el cierre de año?', default=False):
print("❌ Operación cancelada")
print(MSG_OPERACION_CANCELADA)
return

print("\n" + "=" * 70)
Expand Down Expand Up @@ -163,6 +165,8 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an
anio=anio_nuevo
).first()

saldo_aplicado = False

if not saldo_nuevo:
saldo_nuevo = SaldoVacaciones(
usuario_id=u.id,
Expand All @@ -173,20 +177,36 @@ def cerrar_anio_command(anio_origen, max_carryover, gestionar_festivos, anios_an
)
db.session.add(saldo_nuevo)
count_creados += 1
saldo_aplicado = True
print(f" {simbolo} {u.nombre:30} | Base: {dias_base_nuevo_anio:2} + Carryover: {dias_a_traspasar:3} = {total_nuevo:3} días")

elif force:
# Si force, actualizar saldo existente
saldo_nuevo.dias_totales = total_nuevo
saldo_nuevo.dias_carryover = dias_a_traspasar
saldo_nuevo.dias_disfrutados = 0 # Reset
count_actualizados += 1
saldo_aplicado = True
print(f" 🔄 {u.nombre:30} | ACTUALIZADO (force) = {total_nuevo} días")

else:
count_saltados += 1
print(f" ⏭️ {u.nombre:30} | Ya existe saldo {anio_nuevo} (saltado)")

# 5. Auditoría: registrar el ajuste de carryover si lo hubo
if saldo_aplicado and dias_a_traspasar != 0:
db.session.add(CambioSaldo(
usuario_id=u.id,
actor_id=None,
actor_label='system:cli',
anio=anio_nuevo,
dias_anteriores=dias_base_nuevo_anio,
dias_nuevos=total_nuevo,
delta=dias_a_traspasar,
motivo=f"Ajuste cierre {anio_origen}",
origen='cli',
))

except Exception as e:
errores.append(f"{u.nombre}: {str(e)}")
print(f" ❌ {u.nombre}: ERROR - {str(e)}")
Expand Down Expand Up @@ -303,6 +323,185 @@ def import_users_command(csv_file):
print(f"\nResumen: {count_new} creados, {count_skip} saltados.")


@click.command('recalcular')
@click.option('--usuario', '-u', required=True, help='Email del usuario')
@click.option('--anio', type=int, default=None, help='Año a recalcular (default: año actual)')
@click.option('--dry-run', is_flag=True, help='Solo mostrar el resultado sin aplicar cambios')
@with_appcontext
def recalcular_command(usuario, anio, dry_run):
"""
Recalcula 'dias_disfrutados' y 'dias_restantes' de un usuario sumando
sus solicitudes de vacaciones aprobadas (es_actual=True, tipo_accion!=cancelacion)
cuya fecha_solicitud cae dentro del año indicado.

Con --dry-run no se modifica nada, solo se muestra cómo quedaría el saldo.

Ejemplos:
flask recalcular -u john.doe@adhara.io --dry-run
flask recalcular -u john.doe@adhara.io --anio 2025
"""
from datetime import datetime
from sqlalchemy import func

if anio is None:
anio = datetime.now().year

user = Usuario.query.filter_by(email=usuario).first()
if not user:
print(f"❌ Usuario no encontrado: {usuario}")
return

saldo = SaldoVacaciones.query.filter_by(usuario_id=user.id, anio=anio).first()
if not saldo:
print(f"❌ No hay saldo registrado para {user.nombre} ({user.email}) en {anio}")
return

inicio_anio = datetime(anio, 1, 1)
inicio_anio_siguiente = datetime(anio + 1, 1, 1)

base_query = SolicitudVacaciones.query.filter(
SolicitudVacaciones.usuario_id == user.id,
SolicitudVacaciones.estado == 'aprobada',
SolicitudVacaciones.es_actual == True,
SolicitudVacaciones.tipo_accion != 'cancelacion',
SolicitudVacaciones.fecha_solicitud >= inicio_anio,
SolicitudVacaciones.fecha_solicitud < inicio_anio_siguiente,
)

total = base_query.with_entities(
func.coalesce(func.sum(SolicitudVacaciones.dias_solicitados), 0)
).scalar()
dias_disfrutados_calc = int(total or 0)
dias_restantes_calc = saldo.dias_totales - dias_disfrutados_calc

dias_disfrutados_actual = saldo.dias_disfrutados
dias_restantes_actual = saldo.dias_totales - saldo.dias_disfrutados

print("=" * 70)
print(" RECÁLCULO DE SALDO DE VACACIONES")
print("=" * 70)
print(f"\n👤 Usuario: {user.nombre} ({user.email})")
print(f"📅 Año: {anio}")

print("\n📊 Saldo actual:")
print(f" • Días totales: {saldo.dias_totales}")
print(f" • Días disfrutados: {dias_disfrutados_actual}")
print(f" • Días restantes: {dias_restantes_actual}")

print("\n🔢 Saldo recalculado:")
print(f" • Días totales: {saldo.dias_totales} (sin cambios)")
print(f" • Días disfrutados: {dias_disfrutados_calc}")
print(f" • Días restantes: {dias_restantes_calc}")

diff = dias_disfrutados_calc - dias_disfrutados_actual

solicitudes = base_query.order_by(SolicitudVacaciones.fecha_inicio).all()
if solicitudes:
print(f"\n📋 Solicitudes consideradas ({len(solicitudes)}):")
for s in solicitudes:
print(f" • {s.fecha_inicio} → {s.fecha_fin}: {s.dias_solicitados} días "
f"(solicitada {s.fecha_solicitud.strftime('%Y-%m-%d')}, {s.tipo_accion})")
else:
print(f"\n📋 No hay solicitudes aprobadas activas con fecha_solicitud en {anio}.")

if diff == 0:
print("\n✅ No hay diferencias. El saldo ya está correcto.")
return

simbolo = '+' if diff > 0 else ''
print(f"\n⚠️ Diferencia en disfrutados: {simbolo}{diff} días")

if dry_run:
print("\n💡 Modo --dry-run: no se han aplicado cambios.")
return

if not click.confirm('\n¿Aplicar el recálculo y actualizar el saldo?', default=False):
print(MSG_OPERACION_CANCELADA)
return

saldo.dias_disfrutados = dias_disfrutados_calc
db.session.commit()
print("\n✅ Saldo actualizado.")


@click.command('cambiar-saldo')
@click.option('--usuario', '-u', required=True, help='Email del usuario')
@click.option('--delta', type=int, required=True,
help='Días a sumar (positivo) o restar (negativo). Ej: -2, +5')
@click.option('--motivo', '-m', required=True, help='Justificación obligatoria')
@click.option('--anio', type=int, default=None, help='Año del saldo (default: año actual)')
@click.option('--force', is_flag=True, help='Aplicar sin pedir confirmación')
@with_appcontext
def cambiar_saldo_command(usuario, delta, motivo, anio, force):
"""
Suma o resta días al SaldoVacaciones (dias_totales) de un usuario para
un año concreto, dejando un registro en la tabla de auditoría
'cambios_saldo'. Actor = system:cli.

No toca la base contractual (Usuario.dias_vacaciones); esa se ajusta
en el cierre anual.

Ejemplos:
flask cambiar-saldo -u john.doe@adhara.io --delta 2 --motivo "Bonus proyecto X"
flask cambiar-saldo -u john.doe@adhara.io --delta -1 --motivo "Ajuste error de cómputo" --anio 2025
"""
from datetime import datetime
from src.utils import aplicar_cambio_saldo

if anio is None:
anio = datetime.now().year

if delta == 0:
print("❌ --delta no puede ser 0.")
return
if not motivo.strip():
print("❌ --motivo no puede estar vacío.")
return

user = Usuario.query.filter_by(email=usuario).first()
if not user:
print(f"❌ Usuario no encontrado: {usuario}")
return

saldo = SaldoVacaciones.query.filter_by(usuario_id=user.id, anio=anio).first()
dias_anteriores = saldo.dias_totales if saldo else user.dias_vacaciones
dias_proyectados = dias_anteriores + delta

print("=" * 70)
print(" CAMBIO DE SALDO DE VACACIONES")
print("=" * 70)
print(f"\n👤 Usuario: {user.nombre} ({user.email})")
print(f"📅 Año: {anio}")
print(f"📝 Motivo: {motivo.strip()}")
print(f"\n📊 dias_totales:")
print(f" • Antes: {dias_anteriores}{' (saldo nuevo, base contractual)' if not saldo else ''}")
print(f" • Delta: {delta:+d}")
print(f" • Después: {dias_proyectados}")

if dias_proyectados < 0:
print(f"\n❌ El nuevo total quedaría negativo ({dias_proyectados}). Operación abortada.")
return

if not force and not click.confirm('\n¿Aplicar el cambio?', default=False):
print(MSG_OPERACION_CANCELADA)
return

try:
cambio = aplicar_cambio_saldo(
usuario=user,
delta=delta,
motivo=motivo,
anio=anio,
actor=None,
origen='cli',
)
except ValueError as e:
print(f"\n❌ {e}")
return

print(f"\n✅ Saldo actualizado. Auditoría id={cambio.id}, actor={cambio.actor_label}.")


@click.command('init-admin')
@with_appcontext
def init_admin_command():
Expand Down
27 changes: 27 additions & 0 deletions src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,33 @@ def __repr__(self):
return f'<SaldoVacaciones {self.usuario.nombre} - {self.anio}>'


class CambioSaldo(db.Model):
__tablename__ = 'cambios_saldo'

__table_args__ = (
db.Index('idx_cambio_saldo_usuario', 'usuario_id'),
db.Index('idx_cambio_saldo_fecha', 'fecha'),
)

id = db.Column(db.Integer, primary_key=True)
usuario_id = db.Column(db.Integer, db.ForeignKey('usuarios.id'), nullable=False)
actor_id = db.Column(db.Integer, db.ForeignKey('usuarios.id'), nullable=True)
actor_label = db.Column(db.String(100), nullable=False)
anio = db.Column(db.Integer, nullable=False)
dias_anteriores = db.Column(db.Integer, nullable=False)
dias_nuevos = db.Column(db.Integer, nullable=False)
delta = db.Column(db.Integer, nullable=False)
motivo = db.Column(db.Text, nullable=False)
origen = db.Column(db.String(20), nullable=False)
fecha = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

usuario = db.relationship('Usuario', foreign_keys=[usuario_id])
actor = db.relationship('Usuario', foreign_keys=[actor_id])

def __repr__(self):
return f'<CambioSaldo u={self.usuario_id} {self.anio} {self.delta:+d}>'


class Fichaje(db.Model):
__tablename__ = 'fichajes'

Expand Down
Loading
Loading