feat: Implement multi-database architecture

This commit introduces a three-database architecture to the application,
as specified in the `db_logic.md` file.

The changes include:
- A SQL initialization script (`db/init/init.sql`) to create the
  `USERS_ALMA`, `vanity_hr`, and `vanity_attendance` databases and their
  respective tables.
- SQLAlchemy models for all tables, organized into separate files
  within the `models` directory.
- Refactoring of the database connection logic in `modules/database.py`
  to support connections to all three databases.
- Creation of a `modules/logger.py` to handle request logging to the
  `USERS_ALMA` database.
- Updates to `docker-compose.yml` to mount the initialization script and
  build the bot image locally.
- Updates to `.env.example` to include the new database environment
  variables.
- Restoration of the data dictionary to `db_tasks.md`.
This commit is contained in:
google-labs-jules[bot]
2025-12-18 19:36:40 +00:00
parent fbb8649748
commit 4e2b6335a6
12 changed files with 575 additions and 209 deletions

View File

@@ -1,14 +1,40 @@
import logging
import os
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, MetaData, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.users_alma_models import Base as BaseUsersAlma, User
from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos
from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros, HorarioEmpleadas
import gspread
from google.oauth2.service_account import Credentials
# Configuración de logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
# --- DATABASE (MySQL) SETUP ---
def _build_engine(db_name_env_var):
user = os.getenv("MYSQL_USER")
password = os.getenv("MYSQL_PASSWORD")
db_name = os.getenv(db_name_env_var)
host = os.getenv("MYSQL_HOST", "db")
if not all([user, password, db_name]):
logging.warning(f"Database connection disabled: missing environment variables for {db_name_env_var}.")
return None
try:
db_url = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{db_name}"
return create_engine(db_url, pool_pre_ping=True)
except Exception as exc:
logging.error(f"Could not create database engine for {db_name}: {exc}")
return None
# Create engines for each database
engine_users_alma = _build_engine("MYSQL_DATABASE_USERS_ALMA")
engine_vanity_hr = _build_engine("MYSQL_DATABASE_VANITY_HR")
engine_vanity_attendance = _build_engine("MYSQL_DATABASE_VANITY_ATTENDANCE")
# Create sessions for each database
SessionUsersAlma = sessionmaker(autocommit=False, autoflush=False, bind=engine_users_alma) if engine_users_alma else None
SessionVanityHr = sessionmaker(autocommit=False, autoflush=False, bind=engine_vanity_hr) if engine_vanity_hr else None
SessionVanityAttendance = sessionmaker(autocommit=False, autoflush=False, bind=engine_vanity_attendance) if engine_vanity_attendance else None
# --- GOOGLE SHEETS SETUP ---
GSHEET_URL = os.getenv("GOOGLE_SHEET_URL")
@@ -16,15 +42,14 @@ GOOGLE_CREDENTIALS_FILE = os.getenv("GOOGLE_CREDENTIALS_FILE", "google_credentia
SHEET_COLUMN_INDEX = 40 # AN is the 40th column
def get_gsheet_client():
"""Retorna un cliente de gspread autenticado o None si falla."""
"""Returns an authenticated gspread client or None if it fails."""
if not GSHEET_URL:
logging.warning("GOOGLE_SHEET_URL no está configurada. La verificación de duplicados está deshabilitada.")
logging.warning("GOOGLE_SHEET_URL is not configured. Duplicate checking is disabled.")
return None
creds = None
scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
# Prioridad 1: Cargar desde variables de entorno
gsa_creds_dict = {
"type": os.getenv("GSA_TYPE"),
"project_id": os.getenv("GSA_PROJECT_ID"),
@@ -41,139 +66,39 @@ def get_gsheet_client():
if all(gsa_creds_dict.values()):
try:
creds = Credentials.from_service_account_info(gsa_creds_dict, scopes=scopes)
logging.info("Autenticando con Google Sheets usando variables de entorno.")
except Exception as e:
logging.error(f"Error al procesar credenciales de entorno de Google: {e}")
logging.error(f"Error processing Google credentials from environment: {e}")
return None
# Prioridad 2: Cargar desde archivo JSON
elif os.path.exists(GOOGLE_CREDENTIALS_FILE):
try:
creds = Credentials.from_service_account_file(GOOGLE_CREDENTIALS_FILE, scopes=scopes)
logging.info(f"Autenticando con Google Sheets usando el archivo '{GOOGLE_CREDENTIALS_FILE}'.")
except Exception as e:
logging.error(f"Error al procesar el archivo de credenciales '{GOOGLE_CREDENTIALS_FILE}': {e}")
logging.error(f"Error processing credentials file '{GOOGLE_CREDENTIALS_FILE}': {e}")
return None
else:
logging.warning("No se encontraron credenciales de Google (ni por variables de entorno ni por archivo). La verificación de duplicados está deshabilitada.")
logging.warning("Google credentials not found (neither environment variables nor file). Duplicate checking is disabled.")
return None
try:
client = gspread.authorize(creds)
return client
return gspread.authorize(creds)
except Exception as e:
logging.error(f"Error al autorizar cliente de gspread: {e}")
logging.error(f"Error authorizing gspread client: {e}")
return None
def chat_id_exists(chat_id: int) -> bool:
"""Verifica si un chat_id de Telegram ya existe en la columna AN de la hoja de cálculo."""
"""Checks if a Telegram chat_id already exists in the Google Sheet."""
client = get_gsheet_client()
if not client:
return False # Si no hay cliente, no podemos verificar, así que asumimos que no existe.
return False
try:
spreadsheet = client.open_by_url(GSHEET_URL)
worksheet = spreadsheet.get_worksheet(0) # Primera hoja
# Obtener todos los valores de la columna AN (índice 40)
worksheet = spreadsheet.get_worksheet(0)
chat_ids_in_sheet = worksheet.col_values(SHEET_COLUMN_INDEX)
# El ID de chat puede venir como número o texto, así que comparamos como string
return str(chat_id) in chat_ids_in_sheet
except gspread.exceptions.SpreadsheetNotFound:
logging.error(f"No se pudo encontrar la hoja de cálculo en la URL proporcionada.")
logging.error("Could not find the spreadsheet at the provided URL.")
return False
except Exception as e:
logging.error(f"Error al leer la hoja de cálculo: {e}")
logging.error(f"Error reading the spreadsheet: {e}")
return False
# --- DATABASE (MySQL) SETUP ---
# Base para los modelos declarativos
Base = declarative_base()
# Clase que mapea a la tabla de logs
class RequestLog(Base):
__tablename__ = 'request_logs'
id = Column(Integer, primary_key=True)
telegram_id = Column(String(50))
username = Column(String(100))
command = Column(String(100))
message = Column(String(500))
created_at = Column(DateTime, default=datetime.utcnow)
def _build_engine():
"""Crea un engine de SQLAlchemy si hay variables de entorno suficientes."""
user = os.getenv("MYSQL_USER")
password = os.getenv("MYSQL_PASSWORD")
database = os.getenv("MYSQL_DATABASE")
host = os.getenv("MYSQL_HOST") or "db" # Permitimos override para uso local
if not all([user, password, database]):
logging.warning("DB logging deshabilitado: faltan MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE.")
return None
try:
db_url = f"mysql+mysqlconnector://{user}:{password}@{host}:3306/{database}"
return create_engine(db_url, pool_pre_ping=True)
except Exception as exc:
logging.error(f"No se pudo crear el engine de base de datos: {exc}")
return None
def _disable_db_logging(reason: str):
"""Deshabilita el logging a DB después de un error para evitar spam."""
global engine, SessionLocal
engine = None
SessionLocal = None
logging.warning(f"DB logging deshabilitado: {reason}")
# Crear el engine y sesión si es posible
engine = _build_engine()
metadata = MetaData() if engine else None
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) if engine else None
# Función para inicializar la base de datos
def init_db():
global engine, SessionLocal
if not engine:
return
try:
logging.info("Inicializando la base de datos y creando tablas si no existen...")
Base.metadata.create_all(bind=engine)
logging.info("Tablas verificadas/creadas correctamente.")
except Exception as e:
logging.error(f"Error al inicializar la base de datos: {e}")
_disable_db_logging("no se pudo inicializar la base de datos (se omitirán logs).")
# No propagamos para que el bot pueda seguir levantando aunque no haya DB
# Función para registrar una solicitud en la base de datos
def log_request(telegram_id, username, command, message):
if not SessionLocal:
logging.debug("Log de DB omitido (DB no configurada).")
return
try:
db_session = SessionLocal()
except Exception as exc:
logging.error(f"No se pudo crear sesión DB, se deshabilita el log: {exc}")
_disable_db_logging("no se pudo abrir sesión")
return
try:
log_entry = RequestLog(
telegram_id=str(telegram_id),
username=username,
command=command,
message=message
)
db_session.add(log_entry)
db_session.commit()
logging.info(f"Log guardado: {command} de {username}")
except Exception as e:
logging.error(f"Error al guardar el log: {e}")
db_session.rollback()
finally:
db_session.close()
# Inicializar la base de datos al arrancar el módulo
init_db()