Files
Autoparts-DB/pos/blueprints/config_bp.py
consultoria-as 36dd6634e3 feat(whatsapp): per-tenant WhatsApp configuration
- Refactor whatsapp_service.py to accept bridge_url parameter
- whatsapp_bp.py: remove hardcoded tenant_id=11, use g.tenant_id
- whatsapp_bp.py: webhook now accepts ?tenant_id param with fallback
- config_bp.py: add GET/PUT /config/whatsapp endpoints
- Each tenant can now have its own Baileys bridge URL and settings
2026-05-18 04:38:47 +00:00

580 lines
20 KiB
Python

# /home/Autopartes/pos/blueprints/config_bp.py
"""Config blueprint: tenant configuration, branches, theming."""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
@config_bp.route('/branches', methods=['GET'])
@require_auth()
def list_branches():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id")
branches = []
for r in cur.fetchall():
branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]})
cur.close()
conn.close()
return jsonify({'data': branches})
@config_bp.route('/branches', methods=['POST'])
@require_auth('config.edit')
def create_branch():
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
conn_chk = get_tenant_conn(g.tenant_id)
cur_chk = conn_chk.cursor()
cur_chk.execute("SELECT count(*) FROM branches WHERE is_active = true")
current_branches = cur_chk.fetchone()[0]
cur_chk.close()
conn_chk.close()
allowed, limit, current = check_limit(g.tenant_id, 'max_branches', current_branches)
if not allowed:
plan_key = get_plan(g.tenant_id)
nxt = next_plan(plan_key)
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} branches). Upgrade to {nxt_name}.'}), 403
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO branches (name, address, phone)
VALUES (%s, %s, %s) RETURNING id
""", (data['name'], data.get('address'), data.get('phone')))
branch_id = cur.fetchone()[0]
conn.commit()
cur.close()
conn.close()
return jsonify({'id': branch_id, 'message': 'Branch created'}), 201
@config_bp.route('/employees', methods=['GET'])
@require_auth('config.view')
def list_employees():
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT e.id, e.name, e.email, e.phone, e.role, e.branch_id,
b.name as branch_name, e.max_discount_pct, e.is_active
FROM employees e
LEFT JOIN branches b ON e.branch_id = b.id
ORDER BY e.id
""")
employees = []
for r in cur.fetchall():
employees.append({
'id': r[0], 'name': r[1], 'email': r[2], 'phone': r[3],
'role': r[4], 'branch_id': r[5], 'branch_name': r[6],
'max_discount_pct': float(r[7]) if r[7] else 0, 'is_active': r[8]
})
cur.close()
conn.close()
return jsonify({'data': employees})
@config_bp.route('/employees', methods=['POST'])
@require_auth('config.edit')
def create_employee():
import bcrypt
data = request.get_json() or {}
required = ['name', 'role', 'pin']
for f in required:
if not data.get(f):
return jsonify({'error': f'{f} required'}), 400
# Plan limit check
from services.billing import check_limit, next_plan, PLANS, get_plan
conn_chk = get_tenant_conn(g.tenant_id)
cur_chk = conn_chk.cursor()
cur_chk.execute("SELECT count(*) FROM employees WHERE is_active = true")
current_employees = cur_chk.fetchone()[0]
cur_chk.close()
conn_chk.close()
allowed, limit, current = check_limit(g.tenant_id, 'max_employees', current_employees)
if not allowed:
plan_key = get_plan(g.tenant_id)
nxt = next_plan(plan_key)
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
if data['role'] not in valid_roles:
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
pin_hash = bcrypt.hashpw(data['pin'].encode(), bcrypt.gensalt()).decode()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO employees (name, email, phone, pin, role, branch_id, max_discount_pct, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, true) RETURNING id
""", (data['name'], data.get('email'), data.get('phone'), pin_hash,
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
emp_id = cur.fetchone()[0]
# Set default permissions by role
role_permissions = {
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
'catalog.view', 'catalog.edit',
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
'invoicing.view', 'invoicing.create',
'reports.view', 'reports.financial',
'config.view', 'config.edit', 'config.edit_prices'],
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
'catalog.view', 'customers.view', 'customers.create'],
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
'accountant': ['accounting.view', 'accounting.create',
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
'reports.view', 'reports.financial',
'customers.view'],
}
for perm in role_permissions.get(data['role'], []):
cur.execute(
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(emp_id, perm)
)
from services.audit import log_action
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
new_value={'name': data['name'], 'role': data['role']})
conn.commit()
cur.close()
conn.close()
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit')
def update_employee(emp_id):
"""Update an existing employee's name, email, role, branch, discount, active status.
If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged."""
import bcrypt
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Check employee exists
cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
# Build SET clause dynamically — only update provided fields
updates = []
params = []
field_map = {
'name': 'name', 'email': 'email', 'phone': 'phone',
'role': 'role', 'branch_id': 'branch_id',
'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active',
}
for json_key, col in field_map.items():
if json_key in data:
updates.append(f"{col} = %s")
params.append(data[json_key])
# PIN update (only if provided and non-empty)
if data.get('pin') and len(str(data['pin'])) >= 4:
pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode()
updates.append("pin = %s")
params.append(pin_hash)
updates.append("password_hash = %s")
params.append(pin_hash)
if not updates:
cur.close(); conn.close()
return jsonify({'error': 'Nothing to update'}), 400
params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'})
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/currency', methods=['GET'])
@require_auth()
def get_currency():
"""Get currency config for this tenant."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('currency', 'exchange_rate_usd_mxn')")
cfg = {}
for row in cur.fetchall():
cfg[row[0]] = row[1]
cur.close()
conn.close()
from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
return jsonify({
'currency': cfg.get('currency', DEFAULT_CURRENCY),
'exchange_rate': float(cfg.get('exchange_rate_usd_mxn', str(EXCHANGE_RATE_USD_MXN))),
'currencies': {
'MXN': {'symbol': '$', 'name': 'Peso Mexicano'},
'USD': {'symbol': 'US$', 'name': 'US Dollar'},
}
})
@config_bp.route('/currency', methods=['PUT'])
@require_auth('config.edit')
def update_currency():
"""Update currency config for this tenant."""
data = request.get_json() or {}
currency = data.get('currency', 'MXN')
rate = data.get('exchange_rate')
if currency not in ('MXN', 'USD'):
return jsonify({'error': 'currency must be MXN or USD'}), 400
if rate is not None:
try:
rate = float(rate)
if rate <= 0:
raise ValueError
except (TypeError, ValueError):
return jsonify({'error': 'exchange_rate must be a positive number'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('currency', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (currency,))
if rate is not None:
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('exchange_rate_usd_mxn', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (str(rate),))
conn.commit()
cur.close()
conn.close()
# Invalidate cached exchange rate so next sale picks up the new value
from services.currency import invalidate_rate_cache
invalidate_rate_cache()
return jsonify({'message': 'Currency config updated', 'currency': currency})
@config_bp.route('/business', methods=['GET'])
@require_auth()
def get_business():
"""Read-only tenant business info from tenant_config."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'")
cfg = {}
for row in cur.fetchall():
cfg[row[0]] = row[1]
cur.close()
conn.close()
return jsonify({
'razon_social': cfg.get('tenant_razon_social', ''),
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
'rfc': cfg.get('tenant_rfc', ''),
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
'direccion': cfg.get('tenant_direccion', ''),
'telefono': cfg.get('tenant_telefono', ''),
'email': cfg.get('tenant_email', ''),
})
@config_bp.route('/business', methods=['PUT'])
@require_auth('config.edit')
def update_business():
"""Save tenant business info to tenant_config."""
data = request.get_json() or {}
field_map = {
'razon_social': 'tenant_razon_social',
'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal',
'direccion': 'tenant_direccion',
'telefono': 'tenant_telefono',
'email': 'tenant_email',
# Tax params
'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio',
'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method',
}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
for field, key in field_map.items():
val = data.get(field)
if val is not None:
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, str(val).strip()))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
@config_bp.route('/theme', methods=['GET'])
@require_auth()
def get_theme():
"""Get current theme for this tenant. Returns CSS variables."""
# For v1, return a default theme. The design team will add more.
return jsonify({
'theme': 'default',
'variables': {
'--color-primary': '#1a73e8',
'--color-secondary': '#5f6368',
'--color-accent': '#ff6b35',
'--color-bg': '#ffffff',
'--color-surface': '#f8f9fa',
'--color-text': '#202124',
'--color-border': '#dadce0',
'--font-display': "'Sora', sans-serif",
'--font-body': "'Plus Jakarta Sans', sans-serif",
'--font-mono': "'JetBrains Mono', monospace",
'--radius': '8px',
}
})
# ─── Billing / Subscription ──────────────────────────
@config_bp.route('/billing', methods=['GET'])
@require_auth()
def get_billing():
"""Get current plan, usage stats, and available plans."""
from services.billing import get_plan_details, PLANS, PLAN_ORDER
plan = get_plan_details(g.tenant_id)
# Get current usage counts
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT count(*) FROM inventory WHERE is_active = true")
products = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM employees WHERE is_active = true")
employees = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM branches WHERE is_active = true")
branches = cur.fetchone()[0]
cur.close()
conn.close()
return jsonify({
'current_plan': plan,
'usage': {
'products': products,
'employees': employees,
'branches': branches,
},
'plans': {k: {**v, 'key': k} for k, v in PLANS.items()},
'plan_order': PLAN_ORDER,
})
@config_bp.route('/billing/upgrade', methods=['POST'])
@require_auth('config.edit')
def upgrade_billing():
"""Upgrade tenant plan."""
from services.billing import upgrade_plan
data = request.get_json() or {}
new_plan = data.get('plan')
if not new_plan:
return jsonify({'error': 'plan required'}), 400
result = upgrade_plan(g.tenant_id, new_plan)
if 'error' in result:
return jsonify(result), 400
return jsonify(result)
# ─── Vehicle Compatibility Source ────────────────────
@config_bp.route('/vehicle-compat-source', methods=['GET'])
@require_auth()
def get_vehicle_compat_source():
"""Get the configured vehicle compatibility source.
Returns: {'source': 'tecdoc' | 'qwen' | 'both'}
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'vehicle_compat_source'")
row = cur.fetchone()
cur.close()
conn.close()
source = row[0] if row else 'both'
if source not in ('tecdoc', 'qwen', 'both'):
source = 'both'
return jsonify({'source': source})
@config_bp.route('/vehicle-compat-source', methods=['PUT'])
@require_auth('config.edit')
def update_vehicle_compat_source():
"""Set the vehicle compatibility source."""
data = request.get_json() or {}
source = data.get('source', 'both')
if source not in ('tecdoc', 'qwen', 'both'):
return jsonify({'error': 'source must be tecdoc, qwen, or both'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('vehicle_compat_source', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (source,))
conn.commit()
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'})