feat: Implementar registro de usuarios en base de datos dual

Este commit introduce las siguientes mejoras en el proceso de onboarding:

1.  **Registro en Dos Fases**: El flujo de onboarding ahora registra a las nuevas usuarias en dos bases de datos distintas para mejorar la seguridad y la integridad de los datos:
    *   ****: Se crea un registro básico para la autenticación y el control de acceso del bot.
    *   ****: Se guarda un perfil completo y detallado de la empleada en la tabla .

2.  **Modelos SQLAlchemy**: Se han actualizado los modelos de SQLAlchemy ( y ) para reflejar la estructura de las tablas de la base de datos.

3.  **Lógica de Base de Datos Centralizada**: El módulo  ahora contiene la lógica para gestionar el registro dual, asegurando que ambas operaciones se realicen de forma atómica.

4.  **Flujo de Onboarding Actualizado**: El script de  ha sido modificado para recopilar la información necesaria y pasarla al nuevo sistema de registro.

5.  **Configuración de Docker**: Se ha ajustado el  y los scripts de inicialización de la base de datos ( y ) para soportar el nuevo esquema de base de datos dual.

Estos cambios aseguran un proceso de registro más robusto, seguro y escalable, sentando las bases para futuras funcionalidades de RRHH.
This commit is contained in:
Marco Gallegos
2025-12-20 09:28:13 -06:00
parent 9cb9513c41
commit 72204d54cf
5 changed files with 242 additions and 31 deletions

28
db/init/00-bootstrap.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
: "${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}"
APP_USER="${MYSQL_USER:-user}"
APP_PASSWORD="${MYSQL_PASSWORD:-password}"
escape_sql() {
printf "%s" "$1" | sed "s/'/''/g"
}
DB_USER_ESCAPED=$(escape_sql "$APP_USER")
DB_PASS_ESCAPED=$(escape_sql "$APP_PASSWORD")
mysql -uroot -p"${MYSQL_ROOT_PASSWORD}" <<-EOSQL
CREATE DATABASE IF NOT EXISTS USERS_ALMA;
CREATE DATABASE IF NOT EXISTS vanity_hr;
CREATE DATABASE IF NOT EXISTS vanity_attendance;
CREATE USER IF NOT EXISTS '${DB_USER_ESCAPED}'@'%';
ALTER USER '${DB_USER_ESCAPED}'@'%' IDENTIFIED WITH mysql_native_password BY '${DB_PASS_ESCAPED}';
GRANT ALL PRIVILEGES ON USERS_ALMA.* TO '${DB_USER_ESCAPED}'@'%';
GRANT ALL PRIVILEGES ON vanity_hr.* TO '${DB_USER_ESCAPED}'@'%';
GRANT ALL PRIVILEGES ON vanity_attendance.* TO '${DB_USER_ESCAPED}'@'%';
FLUSH PRIVILEGES;
EOSQL

View File

@@ -2,10 +2,6 @@ CREATE DATABASE IF NOT EXISTS USERS_ALMA;
CREATE DATABASE IF NOT EXISTS vanity_hr; CREATE DATABASE IF NOT EXISTS vanity_hr;
CREATE DATABASE IF NOT EXISTS vanity_attendance; CREATE DATABASE IF NOT EXISTS vanity_attendance;
GRANT ALL PRIVILEGES ON USERS_ALMA.* TO 'user'@'%';
GRANT ALL PRIVILEGES ON vanity_hr.* TO 'user'@'%';
GRANT ALL PRIVILEGES ON vanity_attendance.* TO 'user'@'%';
USE USERS_ALMA; USE USERS_ALMA;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (

View File

@@ -3,6 +3,7 @@ version: "3.8"
services: services:
bot: bot:
build: . build: .
image: marcogll/vanessa-bot:1.8
container_name: vanessa_bot container_name: vanessa_bot
restart: always restart: always
env_file: env_file:

View File

@@ -1,5 +1,6 @@
import logging import logging
import os import os
from datetime import datetime, date
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from models.users_alma_models import Base as BaseUsersAlma, User from models.users_alma_models import Base as BaseUsersAlma, User
@@ -38,6 +39,73 @@ SessionVanityAttendance = sessionmaker(autocommit=False, autoflush=False, bind=e
# --- GOOGLE SHEETS SETUP (REMOVED) --- # --- GOOGLE SHEETS SETUP (REMOVED) ---
# Duplicate checking is now done via database. # Duplicate checking is now done via database.
def _parse_date(value):
if not value:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
for parser in (
lambda v: datetime.fromisoformat(v),
lambda v: datetime.strptime(v, "%Y-%m-%d"),
lambda v: datetime.strptime(v, "%d/%m/%Y"),
):
try:
return parser(str(value)).date()
except Exception:
continue
return None
def _parse_datetime(value):
if not value:
return None
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
try:
return datetime.fromisoformat(str(value))
except Exception:
pass
try:
return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S")
except Exception:
return None
def _build_full_address(domicilio: dict) -> str:
if not domicilio:
return ""
partes = []
calle = domicilio.get("calle")
num_ext = domicilio.get("num_ext")
num_int = domicilio.get("num_int")
colonia = domicilio.get("colonia")
cp = domicilio.get("cp")
ciudad = domicilio.get("ciudad")
estado = domicilio.get("estado")
if calle:
linea = f"{calle}"
if num_ext:
linea += f" {num_ext}"
if num_int and num_int not in ("0", "N/A"):
linea += f" Int {num_int}"
partes.append(linea)
if colonia:
partes.append(colonia)
if ciudad or estado:
partes.append(", ".join(filter(None, [ciudad, estado])))
if cp:
partes.append(f"CP {cp}")
return " - ".join(partes)
def _references_with_padding(referencias: list) -> list:
refs = referencias or []
if len(refs) < 3:
refs = refs + [{}] * (3 - len(refs))
return refs[:3]
def chat_id_exists(chat_id: int) -> bool: def chat_id_exists(chat_id: int) -> bool:
"""Checks if a Telegram chat_id already exists in the USERS_ALMA.users table.""" """Checks if a Telegram chat_id already exists in the USERS_ALMA.users table."""
if not SessionUsersAlma: if not SessionUsersAlma:
@@ -55,30 +123,149 @@ def chat_id_exists(chat_id: int) -> bool:
session.close() session.close()
def register_user(user_data: dict) -> bool: def register_user(user_data: dict) -> bool:
"""Registers a new user in the USERS_ALMA.users table.""" """
if not SessionUsersAlma: Persists a new colaboradora across the USERS_ALMA.users and vanity_hr.data_empleadas tables.
logging.warning("SessionUsersAlma not initialized. Cannot register user.")
Expected structure (all keys optional but recommended):
{
"meta": {...}, "metadata": {...}, "candidato": {...}, "contacto": {...},
"domicilio": {...}, "laboral": {...}, "referencias": [...], "emergencia": {...}
}
"""
if not SessionUsersAlma or not SessionVanityHr:
logging.warning("Database sessions not initialized. Cannot register user.")
return False return False
session = SessionUsersAlma() meta = user_data.get("meta") or {}
metadata = user_data.get("metadata") or {}
candidato = user_data.get("candidato") or {}
contacto = user_data.get("contacto") or {}
domicilio = user_data.get("domicilio") or {}
laboral = user_data.get("laboral") or {}
referencias = _references_with_padding(user_data.get("referencias") or [])
emergencia = user_data.get("emergencia") or {}
telegram_id = metadata.get("chat_id") or meta.get("telegram_id")
if not telegram_id:
logging.error("register_user: missing telegram_id; aborting persist.")
return False
# --- USERS_ALMA.users ---
session_users = SessionUsersAlma()
try: try:
new_user = User( user_record = session_users.query(User).filter(User.telegram_id == str(telegram_id)).first()
telegram_id=str(user_data.get("chat_id")), if user_record:
username=user_data.get("telegram_user"), user_record.username = metadata.get("telegram_user") or meta.get("username")
first_name=user_data.get("first_name"), user_record.first_name = candidato.get("nombre_preferido") or meta.get("first_name")
last_name=f"{user_data.get('apellido_paterno', '')} {user_data.get('apellido_materno', '')}".strip(), apellidos = f"{candidato.get('apellido_paterno', '')} {candidato.get('apellido_materno', '')}".strip()
email=user_data.get("email"), user_record.last_name = apellidos or user_record.last_name
cell_phone=user_data.get("celular"), user_record.email = contacto.get("email") or user_record.email
role='user' # Default role user_record.cell_phone = contacto.get("celular") or user_record.cell_phone
) else:
session.add(new_user) user_record = User(
session.commit() telegram_id=str(telegram_id),
logging.info(f"User {user_data.get('chat_id')} registered successfully in DB.") username=metadata.get("telegram_user") or meta.get("username"),
return True first_name=candidato.get("nombre_preferido") or meta.get("first_name"),
except Exception as e: last_name=f"{candidato.get('apellido_paterno', '')} {candidato.get('apellido_materno', '')}".strip(),
session.rollback() email=contacto.get("email"),
logging.error(f"Error registering user in DB: {e}") cell_phone=contacto.get("celular"),
role='user'
)
session_users.add(user_record)
session_users.commit()
except Exception as exc:
session_users.rollback()
logging.error(f"Error persisting user in USERS_ALMA: {exc}")
return False return False
finally: finally:
session.close() session_users.close()
# --- vanity_hr.data_empleadas ---
numero_empleado = laboral.get("numero_empleado") or f"T{telegram_id}"
fecha_registro = _parse_datetime(metadata.get("fecha_registro")) or datetime.utcnow()
fecha_procesamiento = datetime.utcnow()
fecha_ingreso = _parse_date(laboral.get("fecha_inicio"))
fecha_nacimiento = _parse_date(candidato.get("fecha_nacimiento"))
tiempo_registro_minutos = None
try:
duracion_segundos = float(metadata.get("duracion_segundos", 0))
tiempo_registro_minutos = int(round(duracion_segundos / 60))
except Exception:
tiempo_registro_minutos = None
nombre = candidato.get("nombre_oficial") or ""
apellido_paterno = candidato.get("apellido_paterno") or ""
apellido_materno = candidato.get("apellido_materno") or ""
nombre_completo = " ".join(filter(None, [nombre, apellido_paterno, apellido_materno])).strip()
domicilio_completo = _build_full_address(domicilio)
telegram_username = metadata.get("telegram_user") or meta.get("username")
try:
telegram_chat_id = int(telegram_id)
except Exception:
telegram_chat_id = None
empleada_payload = {
"numero_empleado": numero_empleado,
"puesto": laboral.get("rol_id"),
"sucursal": laboral.get("sucursal_id"),
"fecha_ingreso": fecha_ingreso,
"estatus": "activo",
"nombre_completo": nombre_completo or nombre,
"nombre": nombre or meta.get("first_name"),
"nombre_preferido": candidato.get("nombre_preferido"),
"apellido_paterno": apellido_paterno,
"apellido_materno": apellido_materno,
"fecha_nacimiento": fecha_nacimiento,
"lugar_nacimiento": candidato.get("lugar_nacimiento"),
"rfc": candidato.get("rfc"),
"curp": candidato.get("curp"),
"email": contacto.get("email"),
"telefono_celular": contacto.get("celular"),
"domicilio_calle": domicilio.get("calle"),
"domicilio_numero_exterior": domicilio.get("num_ext"),
"domicilio_numero_interior": domicilio.get("num_int"),
"domicilio_numero_texto": domicilio.get("num_ext_texto"),
"domicilio_colonia": domicilio.get("colonia"),
"domicilio_codigo_postal": domicilio.get("cp"),
"domicilio_ciudad": domicilio.get("ciudad"),
"domicilio_estado": domicilio.get("estado"),
"domicilio_completo": domicilio_completo,
"emergencia_nombre": emergencia.get("nombre"),
"emergencia_telefono": emergencia.get("telefono"),
"emergencia_parentesco": emergencia.get("relacion"),
"referencia_1_nombre": referencias[0].get("nombre"),
"referencia_1_telefono": referencias[0].get("telefono"),
"referencia_1_tipo": referencias[0].get("relacion"),
"referencia_2_nombre": referencias[1].get("nombre"),
"referencia_2_telefono": referencias[1].get("telefono"),
"referencia_2_tipo": referencias[1].get("relacion"),
"referencia_3_nombre": referencias[2].get("nombre"),
"referencia_3_telefono": referencias[2].get("telefono"),
"referencia_3_tipo": referencias[2].get("relacion"),
"origen_registro": "telegram_bot",
"telegram_usuario": telegram_username,
"telegram_chat_id": telegram_chat_id,
"bot_version": metadata.get("bot_version"),
"fecha_registro": fecha_registro,
"tiempo_registro_minutos": tiempo_registro_minutos,
"fecha_procesamiento": fecha_procesamiento
}
session_hr = SessionVanityHr()
try:
existing = session_hr.get(DataEmpleadas, numero_empleado)
if existing:
for field, value in empleada_payload.items():
setattr(existing, field, value)
else:
session_hr.add(DataEmpleadas(**empleada_payload))
session_hr.commit()
logging.info(f"User {telegram_id} registered in vanity_hr.data_empleadas as {numero_empleado}.")
return True
except Exception as exc:
session_hr.rollback()
logging.error(f"Error persisting colaboradora in vanity_hr: {exc}")
return False
finally:
session_hr.close()

View File

@@ -406,16 +406,15 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# --- REGISTRO EN BASE DE DATOS --- # --- REGISTRO EN BASE DE DATOS ---
db_ok = register_user({ db_ok = register_user({
**meta, "meta": meta,
**payload["metadata"], **payload
**payload["candidato"],
**payload["contacto"]
}) })
chat_id_log = payload.get("metadata", {}).get("chat_id", meta.get("telegram_id"))
if db_ok: if db_ok:
logging.info(f"Usuario {meta['chat_id']} registrado en la base de datos.") logging.info(f"Usuario {chat_id_log} registrado en la base de datos.")
else: else:
logging.error(f"Fallo al registrar usuario {meta['chat_id']} en la base de datos.") logging.error(f"Fallo al registrar usuario {chat_id_log} en la base de datos.")
if enviado: if enviado:
await update.message.reply_text( await update.message.reply_text(