FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
200 lines
6.6 KiB
Python
200 lines
6.6 KiB
Python
"""Public API Blueprint: API key management and public endpoints.
|
|
|
|
Prefix: /api/v1
|
|
"""
|
|
|
|
from flask import Blueprint, request, jsonify, g
|
|
from middleware import require_auth
|
|
from tenant_db import get_tenant_conn, get_master_conn
|
|
from services.public_api_engine import (
|
|
create_api_key, validate_api_key, check_rate_limit, increment_rate_limit,
|
|
log_api_request, list_api_keys, revoke_api_key, delete_api_key,
|
|
)
|
|
|
|
public_api_bp = Blueprint('public_api', __name__, url_prefix='/api/v1')
|
|
|
|
|
|
# ─── Admin endpoints (require auth) ─────────────────────────────
|
|
|
|
@public_api_bp.route('/keys', methods=['GET'])
|
|
@require_auth()
|
|
def get_keys():
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
keys = list_api_keys(conn, g.tenant_id)
|
|
return jsonify({'keys': keys})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@public_api_bp.route('/keys', methods=['POST'])
|
|
@require_auth()
|
|
def create_key():
|
|
data = request.get_json() or {}
|
|
if not data.get('name'):
|
|
return jsonify({'error': 'name is required'}), 400
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
key_id, full_key = create_api_key(
|
|
conn, g.tenant_id, data['name'],
|
|
scopes=data.get('scopes', ['read']),
|
|
rate_limit_rpm=data.get('rate_limit_rpm', 60),
|
|
rate_limit_rpd=data.get('rate_limit_rpd', 10000),
|
|
created_by=getattr(g, 'employee_id', None),
|
|
expires_at=data.get('expires_at'),
|
|
)
|
|
return jsonify({
|
|
'id': key_id,
|
|
'api_key': full_key, # Only shown once!
|
|
'message': 'Store this key safely — it will not be shown again',
|
|
}), 201
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@public_api_bp.route('/keys/<int:key_id>/revoke', methods=['PUT'])
|
|
@require_auth()
|
|
def revoke_key(key_id):
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
revoke_api_key(conn, key_id)
|
|
return jsonify({'message': 'API key revoked'})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@public_api_bp.route('/keys/<int:key_id>', methods=['DELETE'])
|
|
@require_auth()
|
|
def delete_key(key_id):
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
try:
|
|
delete_api_key(conn, key_id)
|
|
return jsonify({'message': 'API key deleted'})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ─── Public endpoints (API key auth) ─────────────────────────────
|
|
|
|
def _require_api_key():
|
|
"""Decorator-like helper to validate API key from header."""
|
|
api_key = request.headers.get('X-API-Key') or request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if not api_key:
|
|
return None, {'error': 'API key required. Provide via X-API-Key header or Authorization: Bearer <key>'}, 401
|
|
|
|
conn = get_master_conn()
|
|
try:
|
|
key_info = validate_api_key(conn, api_key)
|
|
if not key_info or not key_info.get('valid'):
|
|
reason = key_info.get('reason', 'invalid') if key_info else 'invalid'
|
|
return None, {'error': f'API key {reason}'}, 401
|
|
|
|
# Check rate limit
|
|
allowed, headers = check_rate_limit(conn, key_info['key_id'], key_info['rate_limit_rpm'], key_info['rate_limit_rpd'])
|
|
if not allowed:
|
|
return None, {'error': 'Rate limit exceeded'}, 429, headers
|
|
|
|
increment_rate_limit(conn, key_info['key_id'])
|
|
return key_info, None, None, headers
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@public_api_bp.route('/health', methods=['GET'])
|
|
def public_health():
|
|
return jsonify({'status': 'ok', 'service': 'Nexus Public API'})
|
|
|
|
|
|
@public_api_bp.route('/catalog/search', methods=['GET'])
|
|
def public_catalog_search():
|
|
key_info, error, status, *extra = _require_api_key()
|
|
if error:
|
|
headers = extra[0] if extra else {}
|
|
return jsonify(error), status, headers
|
|
|
|
q = request.args.get('q', '').strip()
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
if not q or len(q) < 2:
|
|
return jsonify({'error': 'Query must be at least 2 characters'}), 400
|
|
|
|
start_time = __import__('time').time()
|
|
conn = get_tenant_conn(key_info['tenant_id'])
|
|
try:
|
|
from services.catalog_service import smart_search
|
|
master = get_master_conn()
|
|
try:
|
|
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
|
finally:
|
|
master.close()
|
|
|
|
response_time_ms = int((__import__('time').time() - start_time) * 1000)
|
|
|
|
# Log the request
|
|
master2 = get_master_conn()
|
|
try:
|
|
log_api_request(
|
|
master2, key_info['key_id'], key_info['tenant_id'],
|
|
request.method, request.path, 200,
|
|
response_time_ms, request.remote_addr,
|
|
request.headers.get('User-Agent'),
|
|
)
|
|
finally:
|
|
master2.close()
|
|
|
|
headers = extra[0] if extra else {}
|
|
return jsonify({
|
|
'query': q,
|
|
'results': results,
|
|
'count': len(results),
|
|
}), 200, headers
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@public_api_bp.route('/catalog/parts/<int:part_id>', methods=['GET'])
|
|
def public_part_detail(part_id):
|
|
key_info, error, status, *extra = _require_api_key()
|
|
if error:
|
|
headers = extra[0] if extra else {}
|
|
return jsonify(error), status, headers
|
|
|
|
start_time = __import__('time').time()
|
|
conn = get_tenant_conn(key_info['tenant_id'])
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT id, part_number, name, brand, price_1, price_2, price_3,
|
|
image_url, description, is_active
|
|
FROM inventory WHERE id = %s AND is_active = true
|
|
""", (part_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
|
|
if not row:
|
|
return jsonify({'error': 'Part not found'}), 404
|
|
|
|
part = {
|
|
'id': row[0], 'part_number': row[1], 'name': row[2],
|
|
'brand': row[3], 'price_1': float(row[4]) if row[4] else None,
|
|
'price_2': float(row[5]) if row[5] else None,
|
|
'price_3': float(row[6]) if row[6] else None,
|
|
'image_url': row[7], 'description': row[8], 'is_active': row[9],
|
|
}
|
|
|
|
response_time_ms = int((__import__('time').time() - start_time) * 1000)
|
|
master = get_master_conn()
|
|
try:
|
|
log_api_request(
|
|
master, key_info['key_id'], key_info['tenant_id'],
|
|
request.method, request.path, 200,
|
|
response_time_ms, request.remote_addr,
|
|
request.headers.get('User-Agent'),
|
|
)
|
|
finally:
|
|
master.close()
|
|
|
|
headers = extra[0] if extra else {}
|
|
return jsonify(part), 200, headers
|
|
finally:
|
|
conn.close()
|