Files
Autoparts-DB/pos/blueprints/public_api_bp.py
Nexus Dev 9ff3dc4c8b 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
2026-04-27 05:23:30 +00:00

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()