From 72204d54cf749240b50dd0fa4d5f775e960774e1 Mon Sep 17 00:00:00 2001 From: Marco Gallegos Date: Sat, 20 Dec 2025 09:28:13 -0600 Subject: [PATCH] feat: Implementar registro de usuarios en base de datos dual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- db/init/00-bootstrap.sh | 28 +++++ db/init/init.sql | 4 - docker-compose.yml | 1 + modules/database.py | 229 ++++++++++++++++++++++++++++++++++++---- modules/onboarding.py | 11 +- 5 files changed, 242 insertions(+), 31 deletions(-) create mode 100755 db/init/00-bootstrap.sh diff --git a/db/init/00-bootstrap.sh b/db/init/00-bootstrap.sh new file mode 100755 index 0000000..efda717 --- /dev/null +++ b/db/init/00-bootstrap.sh @@ -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 diff --git a/db/init/init.sql b/db/init/init.sql index 2e823ea..444c571 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -2,10 +2,6 @@ CREATE DATABASE IF NOT EXISTS USERS_ALMA; CREATE DATABASE IF NOT EXISTS vanity_hr; 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; CREATE TABLE IF NOT EXISTS users ( diff --git a/docker-compose.yml b/docker-compose.yml index 71b435d..bfc0b07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ version: "3.8" services: bot: build: . + image: marcogll/vanessa-bot:1.8 container_name: vanessa_bot restart: always env_file: diff --git a/modules/database.py b/modules/database.py index 25fd837..ee78535 100644 --- a/modules/database.py +++ b/modules/database.py @@ -1,5 +1,6 @@ import logging import os +from datetime import datetime, date from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker 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) --- # 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: """Checks if a Telegram chat_id already exists in the USERS_ALMA.users table.""" if not SessionUsersAlma: @@ -55,30 +123,149 @@ def chat_id_exists(chat_id: int) -> bool: session.close() def register_user(user_data: dict) -> bool: - """Registers a new user in the USERS_ALMA.users table.""" - if not SessionUsersAlma: - logging.warning("SessionUsersAlma not initialized. Cannot register user.") + """ + Persists a new colaboradora across the USERS_ALMA.users and vanity_hr.data_empleadas tables. + + 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 - 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: - new_user = User( - telegram_id=str(user_data.get("chat_id")), - username=user_data.get("telegram_user"), - first_name=user_data.get("first_name"), - last_name=f"{user_data.get('apellido_paterno', '')} {user_data.get('apellido_materno', '')}".strip(), - email=user_data.get("email"), - cell_phone=user_data.get("celular"), - role='user' # Default role - ) - session.add(new_user) - session.commit() - logging.info(f"User {user_data.get('chat_id')} registered successfully in DB.") - return True - except Exception as e: - session.rollback() - logging.error(f"Error registering user in DB: {e}") + user_record = session_users.query(User).filter(User.telegram_id == str(telegram_id)).first() + if user_record: + user_record.username = metadata.get("telegram_user") or meta.get("username") + user_record.first_name = candidato.get("nombre_preferido") or meta.get("first_name") + apellidos = f"{candidato.get('apellido_paterno', '')} {candidato.get('apellido_materno', '')}".strip() + user_record.last_name = apellidos or user_record.last_name + user_record.email = contacto.get("email") or user_record.email + user_record.cell_phone = contacto.get("celular") or user_record.cell_phone + else: + user_record = User( + telegram_id=str(telegram_id), + username=metadata.get("telegram_user") or meta.get("username"), + first_name=candidato.get("nombre_preferido") or meta.get("first_name"), + last_name=f"{candidato.get('apellido_paterno', '')} {candidato.get('apellido_materno', '')}".strip(), + email=contacto.get("email"), + 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 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() diff --git a/modules/onboarding.py b/modules/onboarding.py index d5401a9..8753af3 100644 --- a/modules/onboarding.py +++ b/modules/onboarding.py @@ -406,16 +406,15 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: # --- REGISTRO EN BASE DE DATOS --- db_ok = register_user({ - **meta, - **payload["metadata"], - **payload["candidato"], - **payload["contacto"] + "meta": meta, + **payload }) + chat_id_log = payload.get("metadata", {}).get("chat_id", meta.get("telegram_id")) 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: - 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: await update.message.reply_text(