diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index ddf4167..61252f9 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -451,3 +451,129 @@ def update_vehicle_compat_source(): cur.close() conn.close() return jsonify({'message': 'Vehicle compatibility source updated', 'source': source}) + + +# ─── Allowed Part Brands ───────────────────────────────────────────────────── + +# Whitelist of part manufacturers shown in the allowed-brands selector +_ALLOWED_PART_BRANDS = [ + 'Luk', 'Motocraft', 'Euzcadi', 'Gates', 'Injetech', 'Bilstein', + 'Monroe', 'Yokomitzu', 'Ecom', 'Lth', 'Dynamik', 'Wagner', + 'Bosch', 'Brembo', 'Champion', 'Dorman', 'Kyb', 'Handkook', + 'Tomco', 'Mann Filter', 'Total Parts', 'Kanadian', 'Pirelli', + 'NGK', 'Moresa', 'Fritec', 'Acdelco', 'Dash4', 'Moog', 'SYD', + 'FRAM', 'AUTOLITE' +] + + +@config_bp.route('/available-brands', methods=['GET']) +@require_auth() +def get_available_brands(): + """Return whitelisted aftermarket manufacturer names from master DB.""" + from tenant_db import get_master_conn + conn = get_master_conn() + cur = conn.cursor() + cur.execute(""" + SELECT DISTINCT m.name_manufacture + FROM manufacturers m + JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture + WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != '' + AND LOWER(m.name_manufacture) = ANY(%s) + ORDER BY m.name_manufacture ASC + """, ([b.lower() for b in _ALLOWED_PART_BRANDS],)) + brands = [r[0] for r in cur.fetchall()] + cur.close() + conn.close() + return jsonify({'brands': brands}) + + +@config_bp.route('/allowed-brands', methods=['GET']) +@require_auth() +def get_allowed_brands(): + """Return the tenant's allowed part brands from tenant_config.""" + import json + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'") + row = cur.fetchone() + cur.close() + conn.close() + if row and row[0]: + try: + brands = json.loads(row[0]) + if isinstance(brands, list): + return jsonify({'brands': brands}) + except (json.JSONDecodeError, ValueError): + pass + return jsonify({'brands': []}) + + +@config_bp.route('/allowed-brands', methods=['PUT']) +@require_auth('config.edit') +def update_allowed_brands(): + """Save the tenant's allowed part brands to tenant_config.""" + import json + data = request.get_json() or {} + brands = data.get('brands', []) + if not isinstance(brands, list): + return jsonify({'error': 'brands must be an array'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES ('allowed_part_brands', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (json.dumps(brands),)) + conn.commit() + cur.close() + conn.close() + return jsonify({'message': 'Allowed brands updated', 'brands': brands}) + + +# ─── WhatsApp Configuration ──────────────────────────────────────────────── + +@config_bp.route('/whatsapp', methods=['GET']) +@require_auth('config.view') +def get_whatsapp_config(): + """Get WhatsApp bridge configuration for this tenant.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'") + rows = {row[0]: row[1] for row in cur.fetchall()} + cur.close() + conn.close() + + return jsonify({ + 'bridge_url': rows.get('whatsapp_bridge_url', ''), + 'bridge_key': rows.get('whatsapp_bridge_key', ''), + 'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true', + 'phone_number': rows.get('whatsapp_phone_number', ''), + }) + + +@config_bp.route('/whatsapp', methods=['PUT']) +@require_auth('config.edit') +def update_whatsapp_config(): + """Update WhatsApp bridge configuration for this tenant.""" + data = request.get_json() or {} + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + settings = { + 'whatsapp_bridge_url': data.get('bridge_url', ''), + 'whatsapp_bridge_key': data.get('bridge_key', ''), + 'whatsapp_enabled': 'true' if data.get('enabled') else 'false', + 'whatsapp_phone_number': data.get('phone_number', ''), + } + + for key, value in settings.items(): + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (key, value)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({'message': 'WhatsApp configuration updated'}) diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index 3f8ad9c..9983a68 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -19,6 +19,21 @@ from services import whatsapp_service whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') +def _get_whatsapp_config(conn): + """Read WhatsApp bridge configuration from tenant_config. + Returns dict with bridge_url, enabled, etc.""" + cur = conn.cursor() + cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'") + config = {row[0]: row[1] for row in cur.fetchall()} + cur.close() + return { + 'bridge_url': config.get('whatsapp_bridge_url', ''), + 'bridge_key': config.get('whatsapp_bridge_key', ''), + 'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true', + 'phone_number': config.get('whatsapp_phone_number', ''), + } + + def _resolve_mye_ids(vehicle, master_conn): """Return list of MYE ids matching vehicle brand/model/year text.""" if not master_conn or not vehicle: @@ -271,25 +286,45 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N @whatsapp_bp.route('/status', methods=['GET']) @require_auth() def status(): - return jsonify(whatsapp_service.get_status()) + conn = get_tenant_conn(g.tenant_id) + cfg = _get_whatsapp_config(conn) + conn.close() + if not cfg['enabled'] or not cfg['bridge_url']: + return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'}) + return jsonify(whatsapp_service.get_status(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/qr', methods=['GET']) @require_auth() def qr(): - return jsonify(whatsapp_service.get_qr()) + conn = get_tenant_conn(g.tenant_id) + cfg = _get_whatsapp_config(conn) + conn.close() + if not cfg['enabled'] or not cfg['bridge_url']: + return jsonify({'state': 'disabled', 'message': 'WhatsApp not configured for this tenant'}) + return jsonify(whatsapp_service.get_qr(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/connect', methods=['POST']) @require_auth() def connect(): - return jsonify(whatsapp_service.connect()) + conn = get_tenant_conn(g.tenant_id) + cfg = _get_whatsapp_config(conn) + conn.close() + if not cfg['enabled'] or not cfg['bridge_url']: + return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400 + return jsonify(whatsapp_service.connect(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/logout', methods=['POST']) @require_auth() def logout(): - return jsonify(whatsapp_service.logout()) + conn = get_tenant_conn(g.tenant_id) + cfg = _get_whatsapp_config(conn) + conn.close() + if not cfg['enabled'] or not cfg['bridge_url']: + return jsonify({'state': 'error', 'error': 'WhatsApp not configured for this tenant'}), 400 + return jsonify(whatsapp_service.logout(bridge_url=cfg['bridge_url'])) @whatsapp_bp.route('/webhook', methods=['POST']) @@ -311,16 +346,34 @@ def webhook(): if not msg.get('phone') or msg.get('from_me'): return jsonify({'ok': True}) - # Reuse one tenant connection for the whole webhook path — we need it - # for persistence AND for the inventory-context lookup. - # TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives. - tenant_id = 11 + # Resolve tenant: try query param first, then fallback to first enabled tenant + tenant_id = request.args.get('tenant_id', type=int) + if not tenant_id: + # Fallback: find first tenant with whatsapp enabled + try: + mconn = get_master_conn() + mcur = mconn.cursor() + mcur.execute(""" + SELECT t.id FROM tenants t + JOIN tenant_config c ON c.key = 'whatsapp_enabled' AND c.value = 'true' + WHERE t.is_active = true + ORDER BY t.id LIMIT 1 + """) + row = mcur.fetchone() + mcur.close() + mconn.close() + tenant_id = row[0] if row else None + except Exception: + tenant_id = None + tenant_conn = None master_conn = None inventory_context = None + wa_config = {} try: tenant_conn = get_tenant_conn(tenant_id) master_conn = get_master_conn() + wa_config = _get_whatsapp_config(tenant_conn) # 1. Log the incoming message (with contact display name) cur = tenant_conn.cursor() @@ -434,7 +487,7 @@ def webhook(): except Exception as del_err: print(f"[WA-AI] Failed to clear conversation history: {del_err}") reply = '🗑️ *Conversación reiniciada.*\n\n¡Hola de nuevo! ¿En qué puedo ayudarte?' - result = whatsapp_service.send_message(reply_to, reply) + result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url')) if tenant_conn: try: cur_save = tenant_conn.cursor() @@ -451,7 +504,7 @@ def webhook(): if intent is not None: # It was a quote command — send reply and skip the AI if reply: - result = whatsapp_service.send_message(reply_to, reply) + result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url')) if tenant_conn: try: cur_save = tenant_conn.cursor() @@ -549,7 +602,7 @@ def webhook(): # Send reply if we produced one if reply: - result = whatsapp_service.send_message(reply_to, reply) + result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url')) print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}") # Save the bot's reply to DB so it shows in the WhatsApp UI @@ -592,11 +645,17 @@ def send(): if not phone or not message: return jsonify({'error': 'phone and message required'}), 400 - result = whatsapp_service.send_message(phone, message) + # Load tenant WhatsApp config + conn = get_tenant_conn(g.tenant_id) + cfg = _get_whatsapp_config(conn) + if not cfg['enabled'] or not cfg['bridge_url']: + conn.close() + return jsonify({'error': 'WhatsApp not configured for this tenant'}), 400 + + result = whatsapp_service.send_message(phone, message, bridge_url=cfg['bridge_url']) # Save outgoing message try: - conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO whatsapp_messages (phone, direction, message_text) @@ -604,9 +663,10 @@ def send(): """, (phone, message)) conn.commit() cur.close() - conn.close() except Exception: pass + finally: + conn.close() return jsonify(result) diff --git a/pos/services/whatsapp_service.py b/pos/services/whatsapp_service.py index e2ab29c..bb9e151 100644 --- a/pos/services/whatsapp_service.py +++ b/pos/services/whatsapp_service.py @@ -1,6 +1,7 @@ """WhatsApp service via Baileys Bridge (self-hosted, free). -Simple REST bridge at localhost:21465 that wraps WhatsApp Web via Baileys. +Simple REST bridge that wraps WhatsApp Web via Baileys. +Supports per-tenant configuration via bridge_url parameter. """ import requests @@ -9,47 +10,56 @@ from config import WHATSAPP_BRIDGE_URL HEADERS = {'Content-Type': 'application/json'} -def get_status(): +def _get_url(bridge_url=None): + return bridge_url or WHATSAPP_BRIDGE_URL + + +def get_status(bridge_url=None): + url = _get_url(bridge_url) try: - return requests.get(f'{WHATSAPP_BRIDGE_URL}/status', timeout=5).json() + return requests.get(f'{url}/status', timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} -def get_qr(): +def get_qr(bridge_url=None): + url = _get_url(bridge_url) try: - return requests.get(f'{WHATSAPP_BRIDGE_URL}/qr', timeout=5).json() + return requests.get(f'{url}/qr', timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} -def connect(): +def connect(bridge_url=None): + url = _get_url(bridge_url) try: - return requests.post(f'{WHATSAPP_BRIDGE_URL}/connect', headers=HEADERS, timeout=5).json() + return requests.post(f'{url}/connect', headers=HEADERS, timeout=5).json() except Exception as e: return {'state': 'error', 'error': str(e)} -def send_message(phone, text): +def send_message(phone, text, bridge_url=None): + url = _get_url(bridge_url) try: - return requests.post(f'{WHATSAPP_BRIDGE_URL}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json() + return requests.post(f'{url}/send', headers=HEADERS, json={'phone': phone, 'message': text}, timeout=15).json() except Exception as e: return {'error': str(e)} -def send_quote(phone, quote_data): +def send_quote(phone, quote_data, bridge_url=None): lines = [f"*Cotizacion #{quote_data.get('id', '')}*", ""] for item in quote_data.get('items', []): lines.append(f"- {item.get('quantity', 1)}x {item.get('name', '')} ${item.get('subtotal', 0):,.2f}") lines.append(f"\nSubtotal: ${quote_data.get('subtotal', 0):,.2f}") lines.append(f"IVA: ${quote_data.get('tax_total', 0):,.2f}") lines.append(f"*Total: ${quote_data.get('total', 0):,.2f}*") - return send_message(phone, "\n".join(lines)) + return send_message(phone, "\n".join(lines), bridge_url=bridge_url) -def logout(): +def logout(bridge_url=None): + url = _get_url(bridge_url) try: - return requests.post(f'{WHATSAPP_BRIDGE_URL}/logout', headers=HEADERS, timeout=5).json() + return requests.post(f'{url}/logout', headers=HEADERS, timeout=5).json() except Exception as e: return {'error': str(e)} @@ -72,6 +82,7 @@ def process_incoming(webhook_data): media_base64 — base64 string if media, else None media_mimetype — e.g. 'image/jpeg', 'audio/ogg' is_voice_note — True for WhatsApp voice notes (audioMessage ptt) + push_name — display name from WhatsApp """ data = webhook_data.get('data', {}) key = data.get('key', {})