mirror of
https://github.com/marcogll/telegram_new_socias.git
synced 2026-01-13 21:25:16 +00:00
feat: refactor schedule storage to vanity_hr schema, update onboarding command to /registro, and enhance horario flow with short name collection
This commit is contained in:
@@ -4,8 +4,8 @@ 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
|
||||
from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos
|
||||
from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros, HorarioEmpleadas
|
||||
from models.vanity_hr_models import Base as BaseVanityHr, DataEmpleadas, Vacaciones, Permisos, HorarioEmpleadas
|
||||
from models.vanity_attendance_models import Base as BaseVanityAttendance, AsistenciaRegistros
|
||||
|
||||
|
||||
# --- DATABASE (MySQL) SETUP ---
|
||||
|
||||
@@ -2,10 +2,10 @@ import os
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from modules.database import get_engine
|
||||
from models.vanity_attendance_models import HorariosConfigurados
|
||||
from datetime import datetime, time as time_cls
|
||||
|
||||
from modules.database import SessionVanityHr
|
||||
from models.vanity_hr_models import HorarioEmpleadas, DataEmpleadas
|
||||
|
||||
def _send_webhook(url: str, payload: dict):
|
||||
"""Sends a POST request to a webhook."""
|
||||
@@ -39,51 +39,88 @@ def _finalize_horario(telegram_id: int, data: dict):
|
||||
"""Finalizes the 'horario' flow."""
|
||||
logging.info(f"Finalizing 'horario' flow for telegram_id: {telegram_id}")
|
||||
|
||||
# 1. Prepare data
|
||||
# 1. Prepare data for webhook and DB
|
||||
day_pairs = [
|
||||
("monday", "MONDAY_IN", "MONDAY_OUT"),
|
||||
("tuesday", "TUESDAY_IN", "TUESDAY_OUT"),
|
||||
("wednesday", "WEDNESDAY_IN", "WEDNESDAY_OUT"),
|
||||
("thursday", "THURSDAY_IN", "THURSDAY_OUT"),
|
||||
("friday", "FRIDAY_IN", "FRIDAY_OUT"),
|
||||
("saturday", "SATURDAY_IN", None),
|
||||
]
|
||||
|
||||
schedule_data = {
|
||||
"telegram_id": telegram_id,
|
||||
"short_name": data.get("SHORT_NAME"),
|
||||
"monday_in": _convert_to_time(data.get("MONDAY_IN")),
|
||||
"monday_out": _convert_to_time(data.get("MONDAY_OUT")),
|
||||
"tuesday_in": _convert_to_time(data.get("TUESDAY_IN")),
|
||||
"tuesday_out": _convert_to_time(data.get("TUESDAY_OUT")),
|
||||
"wednesday_in": _convert_to_time(data.get("WEDNESDAY_IN")),
|
||||
"wednesday_out": _convert_to_time(data.get("WEDNESDAY_OUT")),
|
||||
"thursday_in": _convert_to_time(data.get("THURSDAY_IN")),
|
||||
"thursday_out": _convert_to_time(data.get("THURSDAY_OUT")),
|
||||
"friday_in": _convert_to_time(data.get("FRIDAY_IN")),
|
||||
"friday_out": _convert_to_time(data.get("FRIDAY_OUT")),
|
||||
"saturday_in": _convert_to_time(data.get("SATURDAY_IN")),
|
||||
"saturday_out": _convert_to_time("6:00 PM"), # Hardcoded as per flow
|
||||
}
|
||||
|
||||
rows_for_db = []
|
||||
for day_key, in_key, out_key in day_pairs:
|
||||
entrada = _convert_to_time(data.get(in_key))
|
||||
salida_raw = data.get(out_key) if out_key else "6:00 PM"
|
||||
salida = _convert_to_time(salida_raw)
|
||||
|
||||
schedule_data[f"{day_key}_in"] = entrada
|
||||
schedule_data[f"{day_key}_out"] = salida
|
||||
|
||||
if not entrada or not salida:
|
||||
logging.warning(f"Missing schedule data for {day_key}. Entrada: {entrada}, Salida: {salida}")
|
||||
continue
|
||||
|
||||
rows_for_db.append(
|
||||
{
|
||||
"dia_semana": day_key,
|
||||
"hora_entrada": entrada,
|
||||
"hora_salida": salida,
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Send to webhook
|
||||
webhook_url = os.getenv("WEBHOOK_SCHEDULE")
|
||||
if webhook_url:
|
||||
# Create a JSON-serializable payload
|
||||
json_payload = {k: (v.isoformat() if isinstance(v, datetime.time) else v) for k, v in schedule_data.items()}
|
||||
json_payload = {
|
||||
k: (v.isoformat() if isinstance(v, time_cls) else v) for k, v in schedule_data.items()
|
||||
}
|
||||
json_payload["timestamp"] = datetime.now().isoformat()
|
||||
_send_webhook(webhook_url, json_payload)
|
||||
|
||||
# 3. Save to database
|
||||
engine = get_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
# 3. Save to database (vanity_hr.horario_empleadas)
|
||||
if not SessionVanityHr:
|
||||
logging.error("SessionVanityHr is not initialized. Cannot persist horarios.")
|
||||
return False
|
||||
|
||||
session = SessionVanityHr()
|
||||
try:
|
||||
# Upsert logic: Check if a record for this telegram_id already exists
|
||||
existing_schedule = session.query(HorariosConfigurados).filter_by(telegram_id=telegram_id).first()
|
||||
if existing_schedule:
|
||||
# Update existing record
|
||||
for key, value in schedule_data.items():
|
||||
setattr(existing_schedule, key, value)
|
||||
existing_schedule.timestamp = datetime.now()
|
||||
logging.info(f"Updating existing schedule for telegram_id: {telegram_id}")
|
||||
else:
|
||||
# Create new record
|
||||
new_schedule = HorariosConfigurados(**schedule_data)
|
||||
session.add(new_schedule)
|
||||
logging.info(f"Creating new schedule for telegram_id: {telegram_id}")
|
||||
|
||||
empleada = session.query(DataEmpleadas).filter(DataEmpleadas.telegram_chat_id == telegram_id).first()
|
||||
numero_empleado = empleada.numero_empleado if empleada else None
|
||||
if not numero_empleado:
|
||||
logging.warning(f"No se encontró numero_empleado para telegram_id={telegram_id}. Se guardará NULL.")
|
||||
|
||||
existing_rows = {
|
||||
row.dia_semana: row
|
||||
for row in session.query(HorarioEmpleadas).filter_by(telegram_id=telegram_id).all()
|
||||
}
|
||||
|
||||
for row in rows_for_db:
|
||||
dia = row["dia_semana"]
|
||||
entrada = row["hora_entrada"]
|
||||
salida = row["hora_salida"]
|
||||
existing = existing_rows.get(dia)
|
||||
if existing:
|
||||
existing.numero_empleado = numero_empleado or existing.numero_empleado
|
||||
existing.hora_entrada_teorica = entrada
|
||||
existing.hora_salida_teorica = salida
|
||||
else:
|
||||
session.add(
|
||||
HorarioEmpleadas(
|
||||
numero_empleado=numero_empleado,
|
||||
telegram_id=telegram_id,
|
||||
dia_semana=dia,
|
||||
hora_entrada_teorica=entrada,
|
||||
hora_salida_teorica=salida,
|
||||
)
|
||||
)
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,105 +1,153 @@
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
from functools import partial
|
||||
from telegram import Update, ReplyKeyboardRemove, ReplyKeyboardMarkup
|
||||
|
||||
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
|
||||
from telegram.ext import (
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
ConversationHandler,
|
||||
MessageHandler,
|
||||
CommandHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
from .finalizer import finalize_flow
|
||||
|
||||
async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
await update.message.reply_text("Flujo cancelado.")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
|
||||
current_state_key = context.user_data.get("current_state", 0)
|
||||
|
||||
current_step = next((step for step in flow["steps"] if step["state"] == current_state_key), None)
|
||||
|
||||
if not current_step:
|
||||
await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.")
|
||||
return ConversationHandler.END
|
||||
|
||||
user_answer = update.message.text
|
||||
variable_name = current_step.get("variable")
|
||||
if variable_name:
|
||||
context.user_data[variable_name] = user_answer
|
||||
|
||||
next_state_key = None
|
||||
if "next_steps" in current_step:
|
||||
for condition in current_step["next_steps"]:
|
||||
if condition.get("value") == user_answer:
|
||||
next_state_key = condition["go_to"]
|
||||
break
|
||||
elif condition.get("value") == "default":
|
||||
next_state_key = condition["go_to"]
|
||||
elif "next_step" in current_step:
|
||||
next_state_key = current_step["next_step"]
|
||||
|
||||
if next_state_key is None:
|
||||
return await end_cancel(update, context)
|
||||
|
||||
if next_state_key == -1:
|
||||
await finalize_flow(update, context)
|
||||
return ConversationHandler.END
|
||||
|
||||
next_step = next((step for step in flow["steps"] if step["state"] == next_state_key), None)
|
||||
|
||||
if not next_step:
|
||||
await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.")
|
||||
return ConversationHandler.END
|
||||
|
||||
reply_markup = ReplyKeyboardRemove()
|
||||
if next_step.get("type") == "keyboard" and "options" in next_step:
|
||||
# Create a 2D array for the keyboard
|
||||
options = next_step["options"]
|
||||
keyboard = [options[i:i+2] for i in range(0, len(options), 2)]
|
||||
reply_markup = ReplyKeyboardMarkup(
|
||||
keyboard,
|
||||
one_time_keyboard=True, resize_keyboard=True
|
||||
)
|
||||
|
||||
await update.message.reply_text(next_step["question"], reply_markup=reply_markup)
|
||||
|
||||
context.user_data["current_state"] = next_state_key
|
||||
return next_state_key
|
||||
def _build_keyboard(options):
|
||||
keyboard = [options[i : i + 2] for i in range(0, len(options), 2)]
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard,
|
||||
one_time_keyboard=True,
|
||||
resize_keyboard=True,
|
||||
)
|
||||
|
||||
|
||||
async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
|
||||
context.user_data.clear()
|
||||
context.user_data["flow_name"] = flow["flow_name"]
|
||||
|
||||
first_step = flow["steps"][0]
|
||||
context.user_data["current_state"] = first_step["state"]
|
||||
def _preprocess_flow(flow: dict):
|
||||
"""Populate missing next_step values assuming a linear order."""
|
||||
steps = flow.get("steps", [])
|
||||
for idx, step in enumerate(steps):
|
||||
if "next_step" in step or "next_steps" in step:
|
||||
continue
|
||||
if idx + 1 < len(steps):
|
||||
step["next_step"] = steps[idx + 1]["state"]
|
||||
else:
|
||||
step["next_step"] = -1
|
||||
|
||||
reply_markup = ReplyKeyboardRemove()
|
||||
if first_step.get("type") == "keyboard" and "options" in first_step:
|
||||
options = first_step["options"]
|
||||
keyboard = [options[i:i+2] for i in range(0, len(options), 2)]
|
||||
reply_markup = ReplyKeyboardMarkup(
|
||||
keyboard,
|
||||
one_time_keyboard=True, resize_keyboard=True
|
||||
)
|
||||
|
||||
await update.message.reply_text(first_step["question"], reply_markup=reply_markup)
|
||||
return first_step["state"]
|
||||
|
||||
def _find_step(flow: dict, state_key):
|
||||
return next((step for step in flow["steps"] if step["state"] == state_key), None)
|
||||
|
||||
|
||||
ALLOWED_AST_NODES = (
|
||||
ast.Expression,
|
||||
ast.BoolOp,
|
||||
ast.Compare,
|
||||
ast.Name,
|
||||
ast.Load,
|
||||
ast.Constant,
|
||||
ast.List,
|
||||
ast.Tuple,
|
||||
ast.And,
|
||||
ast.Or,
|
||||
ast.Eq,
|
||||
ast.NotEq,
|
||||
ast.In,
|
||||
ast.NotIn,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_condition(condition: str, response: str) -> bool:
|
||||
"""Safely evaluate expressions like `response in ['Hoy', 'Mañana']`."""
|
||||
if not condition:
|
||||
return False
|
||||
try:
|
||||
tree = ast.parse(condition, mode="eval")
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ALLOWED_AST_NODES):
|
||||
raise ValueError(f"Unsupported expression: {condition}")
|
||||
compiled = compile(tree, "<condition>", "eval")
|
||||
return bool(eval(compiled, {"__builtins__": {}}, {"response": response}))
|
||||
except Exception as exc:
|
||||
logging.warning("Failed to evaluate condition '%s': %s", condition, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _determine_next_state(step: dict, user_answer: str):
|
||||
"""Resolve the next state declared in the JSON step."""
|
||||
if "next_steps" in step:
|
||||
default_target = None
|
||||
for option in step["next_steps"]:
|
||||
value = option.get("value")
|
||||
if value == "default":
|
||||
default_target = option.get("go_to")
|
||||
elif user_answer == value:
|
||||
return option.get("go_to")
|
||||
return default_target
|
||||
|
||||
next_step = step.get("next_step")
|
||||
|
||||
if isinstance(next_step, list):
|
||||
default_target = None
|
||||
for option in next_step:
|
||||
condition = option.get("condition")
|
||||
target = option.get("state")
|
||||
if condition:
|
||||
if _evaluate_condition(condition, user_answer):
|
||||
return target
|
||||
elif option.get("value") and user_answer == option["value"]:
|
||||
return target
|
||||
elif option.get("default"):
|
||||
default_target = target
|
||||
return default_target
|
||||
|
||||
return next_step
|
||||
|
||||
|
||||
async def _go_to_state(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict, state_key):
|
||||
"""Send the question for the requested state, skipping info-only steps."""
|
||||
safety_counter = 0
|
||||
while True:
|
||||
safety_counter += 1
|
||||
if safety_counter > len(flow["steps"]) + 2:
|
||||
logging.error("Detected potential loop while traversing flow '%s'", flow.get("flow_name"))
|
||||
await update.message.reply_text("Ocurrió un error al continuar con el flujo. Intenta iniciar de nuevo.")
|
||||
return ConversationHandler.END
|
||||
|
||||
if state_key == -1:
|
||||
await finalize_flow(update, context)
|
||||
return ConversationHandler.END
|
||||
|
||||
next_step = _find_step(flow, state_key)
|
||||
if not next_step:
|
||||
await update.message.reply_text("Error: No se encontró el siguiente paso del flujo.")
|
||||
return ConversationHandler.END
|
||||
|
||||
reply_markup = ReplyKeyboardRemove()
|
||||
if next_step.get("type") == "keyboard" and "options" in next_step:
|
||||
reply_markup = _build_keyboard(next_step["options"])
|
||||
|
||||
await update.message.reply_text(next_step["question"], reply_markup=reply_markup)
|
||||
context.user_data["current_state"] = state_key
|
||||
|
||||
if next_step.get("type") == "info":
|
||||
state_key = _determine_next_state(next_step, None)
|
||||
if state_key is None:
|
||||
await update.message.reply_text("No se pudo continuar con el flujo actual. Intenta iniciar de nuevo.")
|
||||
return ConversationHandler.END
|
||||
continue
|
||||
|
||||
return state_key
|
||||
|
||||
|
||||
def create_handler(flow: dict):
|
||||
states = {}
|
||||
all_states = sorted(list(set([step["state"] for step in flow["steps"]])))
|
||||
|
||||
for state_key in all_states:
|
||||
# Skip the end state
|
||||
if state_key == -1:
|
||||
continue
|
||||
|
||||
callback = partial(generic_callback, flow=flow)
|
||||
states[state_key] = [MessageHandler(filters.TEXT & ~filters.COMMAND, callback)]
|
||||
|
||||
@@ -112,19 +160,54 @@ def create_handler(flow: dict):
|
||||
allow_reentry=True,
|
||||
)
|
||||
|
||||
|
||||
async def end_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
await update.message.reply_text("Flujo cancelado.")
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
async def generic_callback(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
|
||||
current_state_key = context.user_data.get("current_state", 0)
|
||||
current_step = _find_step(flow, current_state_key)
|
||||
|
||||
if not current_step:
|
||||
await update.message.reply_text("Hubo un error en el flujo. Por favor, inicia de nuevo.")
|
||||
return ConversationHandler.END
|
||||
|
||||
user_answer = update.message.text
|
||||
variable_name = current_step.get("variable")
|
||||
if variable_name:
|
||||
context.user_data[variable_name] = user_answer
|
||||
|
||||
next_state_key = _determine_next_state(current_step, user_answer)
|
||||
if next_state_key is None:
|
||||
return await end_cancel(update, context)
|
||||
|
||||
return await _go_to_state(update, context, flow, next_state_key)
|
||||
|
||||
|
||||
async def start_flow(update: Update, context: ContextTypes.DEFAULT_TYPE, flow: dict):
|
||||
context.user_data.clear()
|
||||
context.user_data["flow_name"] = flow["flow_name"]
|
||||
|
||||
first_state = flow["steps"][0]["state"]
|
||||
return await _go_to_state(update, context, flow, first_state)
|
||||
|
||||
|
||||
def load_flows():
|
||||
flow_handlers = []
|
||||
flow_dir = "conv-flows"
|
||||
if not os.path.isdir(flow_dir):
|
||||
logging.warning(f"Directory not found: {flow_dir}")
|
||||
return flow_handlers
|
||||
|
||||
|
||||
for filename in os.listdir(flow_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(flow_dir, filename)
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
flow_definition = json.load(f)
|
||||
_preprocess_flow(flow_definition)
|
||||
handler = create_handler(flow_definition)
|
||||
flow_handlers.append(handler)
|
||||
logging.info(f"Flow '{flow_definition['flow_name']}' loaded successfully.")
|
||||
|
||||
@@ -434,7 +434,7 @@ async def finalizar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
|
||||
async def cancelar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
await update.message.reply_text(
|
||||
"Proceso cancelado. ⏸️\nPuedes retomarlo con /welcome o ir al menú con /start.",
|
||||
"Proceso cancelado. ⏸️\nPuedes retomarlo con /registro (alias /welcome) o ir al menú con /start.",
|
||||
reply_markup=main_actions_keyboard()
|
||||
)
|
||||
context.user_data.clear()
|
||||
@@ -450,8 +450,11 @@ states[34] = [MessageHandler(filters.TEXT & ~filters.COMMAND, finalizar)]
|
||||
|
||||
# Handler listo para importar en main.py
|
||||
onboarding_handler = ConversationHandler(
|
||||
entry_points=[CommandHandler("welcome", start)], # Cambiado a /welcome
|
||||
states=states, # Tu diccionario de estados
|
||||
entry_points=[
|
||||
CommandHandler("welcome", start),
|
||||
CommandHandler("registro", start),
|
||||
],
|
||||
states=states,
|
||||
fallbacks=[CommandHandler("cancelar", cancelar)],
|
||||
allow_reentry=True
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ def main_actions_keyboard(is_registered: bool = False) -> ReplyKeyboardMarkup:
|
||||
"""Teclado inferior con comandos directos (un toque lanza el flujo)."""
|
||||
keyboard = []
|
||||
if not is_registered:
|
||||
keyboard.append(["/welcome"])
|
||||
keyboard.append(["/registro"])
|
||||
|
||||
keyboard.extend([
|
||||
["/vacaciones", "/permiso"],
|
||||
|
||||
Reference in New Issue
Block a user