FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
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
This commit is contained in:
199
pos/blueprints/public_api_bp.py
Normal file
199
pos/blueprints/public_api_bp.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user