fudo.py 16 KB

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