fudo.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import itertools
  2. import math
  3. from time import time
  4. import requests
  5. from config.settings import DEVELOPMENT
  6. import os
  7. import redis
  8. from logging import getLogger
  9. from models.items import Product
  10. from concurrent.futures import ThreadPoolExecutor
  11. import aiohttp
  12. import asyncio
  13. logger = getLogger(__name__)
  14. api_token = os.getenv('FUDO_API_KEY')
  15. api_secret = os.getenv('FUDO_API_SECRET')
  16. token = None
  17. token_exp = None
  18. # Configuración de Redis
  19. redis_client = redis.Redis(
  20. host=os.getenv('REDIS_HOST', 'localhost'),
  21. port=int(os.getenv('REDIS_PORT', 6379)),
  22. db=1 if DEVELOPMENT else 0,
  23. decode_responses=True
  24. )
  25. REDIS_TOKEN_KEY = 'fudo_api_token'
  26. def get_token():
  27. """
  28. Obtiene el token de autenticación de Fudo API.
  29. Primero verifica si existe un token válido en Redis.
  30. Si no existe o ha expirado, solicita uno nuevo y lo guarda en Redis con expiración automática.
  31. """
  32. global token, token_exp
  33. # Intento de obtener el token desde la RAM
  34. if token and token_exp and time() < token_exp:
  35. return token
  36. try:
  37. # Intentar obtener el token desde Redis
  38. cached_token = redis_client.get(REDIS_TOKEN_KEY)
  39. if cached_token:
  40. token = cached_token
  41. ttl = redis_client.ttl(REDIS_TOKEN_KEY)
  42. if ttl is None or int(str(ttl)) < 0:
  43. token_exp = None
  44. else:
  45. token_exp = int(str(ttl)) + int(time())
  46. return str(cached_token)
  47. except Exception as e:
  48. logger.error(f"Error al conectar con Redis: {e}")
  49. logger.info("Fallback: obteniendo token sin cache")
  50. # Si no hay token en cache, solicitar uno nuevo
  51. url = 'https://auth.fu.do/api'
  52. data = {
  53. "apiKey": api_token,
  54. "apiSecret": api_secret
  55. }
  56. r = requests.post(url, data=data)
  57. response_data = r.json()
  58. token = response_data['token']
  59. expiration_timestamp = response_data['exp']
  60. # Calcular TTL en segundos para Redis
  61. current_time = int(time())
  62. ttl_seconds = expiration_timestamp - current_time
  63. try:
  64. # Guardar el token en Redis con expiración automática
  65. if ttl_seconds > 0:
  66. redis_client.setex(REDIS_TOKEN_KEY, ttl_seconds, token)
  67. else:
  68. logger.warning("Warning: El token ya está expirado")
  69. except Exception as e:
  70. logger.error(f"Error al guardar en Redis: {e}")
  71. return token
  72. def get_modifiers():
  73. token = get_token()
  74. url = 'https://api.fu.do/v1alpha1/product-modifiers?include=product'
  75. headers = {
  76. 'Authorization': 'Bearer ' + token
  77. }
  78. r = requests.get(url, headers=headers)
  79. return r.json()
  80. def get_categories():
  81. token = get_token()
  82. url = 'https://api.fu.do/v1alpha1/product-categories'
  83. headers = {
  84. 'Authorization': 'Bearer ' + token
  85. }
  86. r = requests.get(url, headers=headers)
  87. return r.json().get("data")
  88. def get_category_dict():
  89. categories = get_categories()
  90. category_dict = {}
  91. for category in categories:
  92. category_dict[category["id"]] = category["attributes"]["name"]
  93. return category_dict
  94. def get_category(id_category:int):
  95. token = get_token()
  96. url = 'https://api.fu.do/v1alpha1/product-categories/{}'.format(id_category)
  97. headers = {
  98. 'Authorization': 'Bearer ' + token
  99. }
  100. r = requests.get(url, headers=headers)
  101. if r.status_code != 200:
  102. logger.error(f"Error al obtener producto: {r.json()['errors']}")
  103. data = r.json()["data"]
  104. return {
  105. "id": data["id"],
  106. "name": data["attributes"]["name"],
  107. "enableOnlineMenu": data["attributes"].get("enableOnlineMenu", False)
  108. }
  109. def get_product(id_category:int):
  110. url = 'https://api.fu.do/v1alpha1/products/{}'.format(id_category)
  111. token = get_token()
  112. headers = {
  113. 'Authorization': 'Bearer ' + token
  114. }
  115. r = requests.get(url, headers=headers)
  116. if r.status_code != 200:
  117. logger.error(f"Error al obtener producto: {r.json()['errors']}")
  118. data = r.json().get("data")
  119. if not data:
  120. return None
  121. return Product(
  122. id=int(data["id"]),
  123. name=data["attributes"]["name"],
  124. type=get_category(data["relationships"]["productCategory"]["data"]["id"])["name"] or "Producto",
  125. price=data["attributes"]["price"],
  126. image=data["attributes"]["imageUrl"],
  127. description=data["attributes"]["description"],
  128. status=1 if data["attributes"]["active"] and data else 0,
  129. kitchen_id=data["relationships"]["kitchen"]["data"]["id"],
  130. promo_day=None,
  131. promo_price=None,
  132. promo_id=None
  133. )
  134. def get_products(page: int = 1):
  135. url = 'https://api.fu.do/v1alpha1/products?page[number]={}'.format(page)
  136. token = get_token()
  137. headers = {
  138. 'Authorization': 'Bearer ' + token
  139. }
  140. r = requests.get(url, headers=headers)
  141. return r.json()['data']
  142. async def fetch_page(session, url_template, token, page_num):
  143. """
  144. Realiza la petición de una sola página de forma asíncrona.
  145. Retorna una tupla (numero_pagina, lista_datos).
  146. """
  147. url = url_template.format(page_num)
  148. headers = {'Authorization': 'Bearer ' + token}
  149. try:
  150. async with session.get(url, headers=headers) as response:
  151. if response.status == 200:
  152. payload = await response.json()
  153. data = payload.get('data', [])
  154. return page_num, data
  155. else:
  156. # Si falla (ej. 404 o 500), asumimos fin de datos o error recuperable
  157. return page_num, []
  158. except Exception:
  159. return page_num, []
  160. async def get_all_indexed_products():
  161. url_template = 'https://api.fu.do/v1alpha1/products?page[number]={}'
  162. token = get_token() # Asumiendo que esta función existe y es síncrona
  163. products = {}
  164. # Configuración de fuerza bruta
  165. BATCH_SIZE = 8 # Cantidad de peticiones simultáneas
  166. current_page = 1
  167. keep_fetching = True
  168. # Configuración de conexión (límite de conexiones simultáneas)
  169. connector = aiohttp.TCPConnector(limit=100)
  170. async with aiohttp.ClientSession(connector=connector) as session:
  171. while keep_fetching:
  172. # Crear tareas para el bloque actual (ej: páginas 1 a 50)
  173. tasks = [
  174. fetch_page(session, url_template, token, page)
  175. for page in range(current_page, current_page + BATCH_SIZE)
  176. ]
  177. # Ejecutar bloque simultáneamente
  178. results = await asyncio.gather(*tasks)
  179. # Procesar resultados
  180. empty_page_found = False
  181. for page_num, data in results:
  182. if not data:
  183. empty_page_found = True
  184. # No rompemos el loop inmediato para procesar datos previos en el batch si existen
  185. continue
  186. for product in data:
  187. if product["attributes"]["active"]:
  188. if product["attributes"]["enableQrMenu"]:
  189. products[product['id']] = product
  190. else:
  191. logger.warning(f"Product {product['id']}:{product['attributes']['name']} is not QR-enabled. enableQrMenu={product['attributes']['enableQrMenu']}")
  192. # Lógica de parada
  193. if empty_page_found:
  194. # Si algún request en el bloque volvió vacío, asumimos que llegamos al final
  195. keep_fetching = False
  196. else:
  197. # Si todo el bloque trajo datos, preparamos el siguiente bloque
  198. current_page += BATCH_SIZE
  199. return products
  200. async def get_all_products():
  201. """Método para obtener todos los productos de la base de datos."""
  202. return list((await get_all_indexed_products()).values())
  203. N_PER_PAGE = 100
  204. def _get_page_bounds(page: int, token: str):
  205. """
  206. Función helper: Obtiene una página y devuelve el primer y último
  207. número de mesa en ella, y los datos.
  208. """
  209. url = (
  210. 'https://api.fu.do/v1alpha1/tables'
  211. f'?page[number]={page}&page[size]={N_PER_PAGE}'
  212. '&include=activeSales&sort=number'
  213. )
  214. headers = {'Authorization': 'Bearer ' + token}
  215. try:
  216. r = requests.get(url, headers=headers, timeout=10)
  217. if r.status_code != 200:
  218. return None, None, None # Error de API
  219. data = r.json().get('data', [])
  220. if not data:
  221. return 0, 0, [] # Página vacía
  222. first_number = data[0]['attributes']['number']
  223. last_number = data[-1]['attributes']['number']
  224. return first_number, last_number, data
  225. except requests.RequestException as e:
  226. print(f"Error de request en página {page}: {e}")
  227. return None, None, None
  228. def get_table(number: int):
  229. token = get_token()
  230. # --- FASE 1: BÚSQUEDA EXPONENCIAL (Encontrar rango) ---
  231. # Encontrar un 'high_bound' (página) donde el último N° sea >= 'number'
  232. page = 1
  233. low_bound_page = 1
  234. high_bound_page = 1
  235. # Primero, revisamos la página 1
  236. first_num, last_num, page_data = _get_page_bounds(page, token)
  237. if first_num is None:
  238. return None # Error en la primera petición
  239. if not page_data:
  240. return None # No hay mesas en total
  241. # Si está en la página 1
  242. if number >= first_num and number <= last_num:
  243. low_bound_page = 1
  244. high_bound_page = 1
  245. # Si es mayor, empezamos a saltar exponencialmente
  246. elif number > last_num:
  247. low_bound_page = 2
  248. page_jump = 2
  249. while True:
  250. current_page = low_bound_page + page_jump - 1
  251. first, last, data = _get_page_bounds(current_page, token)
  252. if not data:
  253. # Nos pasamos, el rango es entre low y la página actual
  254. high_bound_page = current_page - 1
  255. break
  256. if number <= last:
  257. # Encontramos el techo. El rango es [low_bound_page, current_page]
  258. high_bound_page = current_page
  259. break
  260. # Si no, actualizamos el 'piso' y duplicamos el salto
  261. low_bound_page = current_page + 1
  262. page_jump *= 2
  263. # --- FASE 2: BÚSQUEDA BINARIA (En el rango) ---
  264. target_page_data = []
  265. while low_bound_page <= high_bound_page:
  266. mid_page = (low_bound_page + high_bound_page) // 2
  267. first, last, data = _get_page_bounds(mid_page, token)
  268. if not data:
  269. # Página vacía, buscar en la mitad inferior
  270. high_bound_page = mid_page - 1
  271. continue
  272. if number >= first and number <= last:
  273. # ¡Encontramos la página correcta!
  274. target_page_data = data
  275. break
  276. elif number < first:
  277. # Está en una página anterior
  278. high_bound_page = mid_page - 1
  279. else: # number > last
  280. # Está en una página posterior
  281. low_bound_page = mid_page + 1
  282. # Filtramos la página que encontramos
  283. try:
  284. return list(filter(lambda x: x['attributes']['number'] == number, target_page_data))[0]
  285. except IndexError:
  286. # Esto no debería pasar si la lógica es correcta,
  287. # pero es una salvaguarda
  288. return None
  289. def get_table_items(table_number: int):
  290. token = get_token()
  291. table = get_table(table_number)
  292. if not table:
  293. return None
  294. active_sale = get_active_sale(table)
  295. if not active_sale:
  296. return None
  297. sale = get_sale(active_sale['id'])
  298. if not sale:
  299. return None
  300. items = sale["data"]['relationships']['items']['data']
  301. # 1. Función ajustada para retornar datos
  302. def peticion(url, headers):
  303. try:
  304. r = requests.get(url, headers=headers)
  305. if r.status_code == 200:
  306. # Aquí procesas el resultado como necesites
  307. return r.json()
  308. return None
  309. except Exception:
  310. return None
  311. id_list = list(map(lambda x: f"https://api.fu.do/v1alpha1/items/{x['id']}?include=product", items))
  312. print(id_list)
  313. headers = {
  314. 'Authorization': 'Bearer ' + token
  315. }
  316. # 2. Corrección en la ejecución del ThreadPoolExecutor
  317. resultados = []
  318. with ThreadPoolExecutor(max_workers=10) as executor:
  319. # itertools.repeat repite el header para cada url en id_list
  320. resultados = list(executor.map(peticion, id_list, itertools.repeat(headers)))
  321. return [
  322. {
  323. "id": int(data["data"]["relationships"]["product"]["data"]["id"]),
  324. "quantity": data["data"]["attributes"]["quantity"],
  325. } for data in resultados
  326. ]
  327. def get_sale(sale_id:int):
  328. url = 'https://api.fu.do/v1alpha1/sales/{}'.format(sale_id)
  329. token = get_token()
  330. headers = {
  331. 'Authorization': 'Bearer ' + token
  332. }
  333. r = requests.get(url, headers=headers)
  334. if r.status_code != 200:
  335. logger.error('Error al obtener tablas:' + str(r.json()['errors']))
  336. return None
  337. return r.json()
  338. def create_sale(table_id:int):
  339. url = 'https://api.fu.do/v1alpha1/sales'
  340. token = get_token()
  341. headers = {
  342. 'Authorization': 'Bearer ' + token
  343. }
  344. data = {
  345. "data": {
  346. "type": "Sale",
  347. "attributes": {
  348. "people": 1,
  349. "saleType": "EAT-IN",
  350. "comment": "Pedido desde la app pedidos express"
  351. },
  352. "relationships": {
  353. "table": {
  354. "data": {
  355. "id": str(table_id),
  356. "type": "Table"
  357. }
  358. },
  359. "waiter": {
  360. "data": {
  361. "type": "User",
  362. "id": "76"
  363. }
  364. }
  365. }
  366. }
  367. }
  368. r = requests.post(url, headers=headers, json=data)
  369. if r.status_code != 201:
  370. logger.error('Error al crear la venta:', r.json())
  371. return None
  372. return r.json()["data"]
  373. def create_item(product_id:int, quantity:int, sale_id:int, comment = None):
  374. url = 'https://api.fu.do/v1alpha1/items'
  375. token = get_token()
  376. headers = {
  377. 'Authorization': 'Bearer ' + token
  378. }
  379. data = {
  380. "quantity": quantity,
  381. "origin": "MOBILE",
  382. "comment": "Pedido desde pedidos express" + (f" - {comment}" if comment else ""),
  383. }
  384. data = {
  385. "data":{
  386. "type": "Item",
  387. "attributes": data,
  388. "relationships": {
  389. "product": {
  390. "data": {
  391. "type": "Product",
  392. "id": str(product_id)
  393. }
  394. },
  395. "sale": {
  396. "data": {
  397. "type": "Sale",
  398. "id": str(sale_id)
  399. }
  400. }
  401. },
  402. }
  403. }
  404. r = requests.post(url, headers=headers, json=data)
  405. if r.status_code != 201:
  406. logger.error(r.json())
  407. return None
  408. return r.json()["data"]
  409. def get_active_sale(table):
  410. data = table['relationships']['activeSales']['data']
  411. if len(data) == 0:
  412. return None
  413. return data[0]
  414. def clear_token():
  415. """
  416. Elimina el token cached de Redis.
  417. Útil cuando el token es inválido o se necesita forzar una renovación.
  418. """
  419. try:
  420. redis_client.delete(REDIS_TOKEN_KEY)
  421. logger.info("Token eliminado del cache")
  422. except Exception as e:
  423. logger.error(f"Error al eliminar token de Redis: {e}")
  424. """
  425. Instrucciones para hacer un pedido:
  426. 1. Obtener el token de autenticación con `get_token()` (ahora usa Redis cache).
  427. 2. Obtener la mesa con `get_table(numero_de_mesa)`.
  428. 3. Ver si tiene una activeSale, en caso contrario crear una con `create_sale(id_mesa)`.
  429. 4. Agregar los items a la venta con `create_item(id_producto, cantidad, id_venta, comentario)`.
  430. Configuración de Redis:
  431. - Host: REDIS_HOST (default: localhost)
  432. - Puerto: REDIS_PORT (default: 6379)
  433. - Base de datos: REDIS_DB (default: 0)
  434. """
  435. if __name__ == "__main__":
  436. from rich import print
  437. print(get_table_items(106))