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:
197
pos/services/public_api_engine.py
Normal file
197
pos/services/public_api_engine.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Public API Engine: API key management, rate limiting, request logging.
|
||||
|
||||
Provides:
|
||||
- API key generation and validation (SHA-256 hashed)
|
||||
- Per-key rate limiting (requests per minute / day)
|
||||
- Request logging for analytics and abuse detection
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def generate_api_key():
|
||||
"""Generate a secure API key. Returns (full_key, key_hash, key_prefix)."""
|
||||
full_key = 'nx_' + secrets.token_urlsafe(32)
|
||||
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
|
||||
key_prefix = full_key[:8]
|
||||
return full_key, key_hash, key_prefix
|
||||
|
||||
|
||||
def hash_api_key(full_key):
|
||||
return hashlib.sha256(full_key.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_api_key(conn, tenant_id, name, scopes=None, rate_limit_rpm=60,
|
||||
rate_limit_rpd=10000, created_by=None, expires_at=None):
|
||||
"""Create a new API key. Returns (key_id, full_key)."""
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO api_keys
|
||||
(tenant_id, name, key_hash, key_prefix, scopes, rate_limit_rpm,
|
||||
rate_limit_rpd, created_by, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (tenant_id, name, key_hash, key_prefix,
|
||||
json.dumps(scopes) if scopes else '["read"]', rate_limit_rpm, rate_limit_rpd,
|
||||
created_by, expires_at))
|
||||
key_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return key_id, full_key
|
||||
|
||||
|
||||
def validate_api_key(conn, full_key):
|
||||
"""Validate an API key. Returns dict with key info or None."""
|
||||
key_hash = hash_api_key(full_key)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, tenant_id, name, scopes, rate_limit_rpm, rate_limit_rpd,
|
||||
is_active, expires_at
|
||||
FROM api_keys
|
||||
WHERE key_hash = %s
|
||||
""", (key_hash,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
key_id, tenant_id, name, scopes, rpm, rpd, is_active, expires = row
|
||||
|
||||
if not is_active:
|
||||
return {'valid': False, 'reason': 'inactive'}
|
||||
|
||||
if expires and datetime.utcnow() > expires:
|
||||
return {'valid': False, 'reason': 'expired'}
|
||||
|
||||
return {
|
||||
'valid': True,
|
||||
'key_id': key_id,
|
||||
'tenant_id': tenant_id,
|
||||
'name': name,
|
||||
'scopes': scopes,
|
||||
'rate_limit_rpm': rpm,
|
||||
'rate_limit_rpd': rpd,
|
||||
}
|
||||
|
||||
|
||||
def check_rate_limit(conn, key_id, rpm, rpd):
|
||||
"""Check if API key is within rate limits. Returns (allowed, headers)."""
|
||||
cur = conn.cursor()
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Minute window
|
||||
minute_start = now.replace(second=0, microsecond=0)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(request_count), 0)
|
||||
FROM api_rate_limit_counters
|
||||
WHERE api_key_id = %s AND window_type = 'minute'
|
||||
AND window_start = %s
|
||||
""", (key_id, minute_start))
|
||||
minute_count = cur.fetchone()[0] or 0
|
||||
|
||||
# Day window
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(request_count), 0)
|
||||
FROM api_rate_limit_counters
|
||||
WHERE api_key_id = %s AND window_type = 'day'
|
||||
AND window_start = %s
|
||||
""", (key_id, day_start))
|
||||
day_count = cur.fetchone()[0] or 0
|
||||
|
||||
allowed = minute_count < rpm and day_count < rpd
|
||||
|
||||
headers = {
|
||||
'X-RateLimit-Limit-Minute': str(rpm),
|
||||
'X-RateLimit-Remaining-Minute': str(max(0, rpm - minute_count - 1)),
|
||||
'X-RateLimit-Limit-Day': str(rpd),
|
||||
'X-RateLimit-Remaining-Day': str(max(0, rpd - day_count - 1)),
|
||||
}
|
||||
|
||||
cur.close()
|
||||
return allowed, headers
|
||||
|
||||
|
||||
def increment_rate_limit(conn, key_id):
|
||||
"""Increment request counters for an API key."""
|
||||
cur = conn.cursor()
|
||||
now = datetime.utcnow()
|
||||
minute_start = now.replace(second=0, microsecond=0)
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for window_type, window_start in [('minute', minute_start), ('day', day_start)]:
|
||||
cur.execute("""
|
||||
INSERT INTO api_rate_limit_counters (api_key_id, window_start, window_type, request_count)
|
||||
VALUES (%s, %s, %s, 1)
|
||||
ON CONFLICT (api_key_id, window_start, window_type)
|
||||
DO UPDATE SET request_count = api_rate_limit_counters.request_count + 1
|
||||
""", (key_id, window_start, window_type))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def log_api_request(conn, key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO api_request_logs
|
||||
(api_key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (key_id, tenant_id, method, path, status_code,
|
||||
response_time_ms, ip_address, user_agent))
|
||||
|
||||
# Update last_used_at
|
||||
if key_id:
|
||||
cur.execute("""
|
||||
UPDATE api_keys SET last_used_at = NOW() WHERE id = %s
|
||||
""", (key_id,))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def list_api_keys(conn, tenant_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, key_prefix, scopes, rate_limit_rpm, rate_limit_rpd,
|
||||
is_active, last_used_at, expires_at, created_at
|
||||
FROM api_keys
|
||||
WHERE tenant_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""", (tenant_id,))
|
||||
keys = []
|
||||
for r in cur.fetchall():
|
||||
keys.append({
|
||||
'id': r[0], 'name': r[1], 'key_prefix': r[2],
|
||||
'scopes': r[3], 'rate_limit_rpm': r[4], 'rate_limit_rpd': r[5],
|
||||
'is_active': r[6], 'last_used_at': str(r[7]) if r[7] else None,
|
||||
'expires_at': str(r[8]) if r[8] else None,
|
||||
'created_at': str(r[9]),
|
||||
})
|
||||
cur.close()
|
||||
return keys
|
||||
|
||||
|
||||
def revoke_api_key(conn, key_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE api_keys SET is_active = false WHERE id = %s", (key_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def delete_api_key(conn, key_id):
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM api_keys WHERE id = %s", (key_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
Reference in New Issue
Block a user