Files
Autoparts-DB/pos/services/image_service.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

166 lines
5.1 KiB
Python

"""Image Service: part image upload, processing, and storage.
Stores images at:
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_full.webp
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_thumb.webp
Serves statically via Flask at /pos/static/images/parts/<tenant_id>/<filename>
"""
import os
import uuid
import requests
from io import BytesIO
from PIL import Image
# Base directories
DATA_DIR = '/home/Autopartes/data/images/parts'
STATIC_DIR = '/home/Autopartes/pos/static/images/parts'
# Image processing settings
MAX_WIDTH = 1200
THUMB_SIZE = (300, 300)
FORMAT = 'WEBP'
QUALITY = 85
THUMB_QUALITY = 80
def _ensure_dir(path):
os.makedirs(path, exist_ok=True)
def _build_paths(tenant_id, item_id):
"""Return (data_dir, static_dir, basename) for an item."""
ddir = os.path.join(DATA_DIR, str(tenant_id))
sdir = os.path.join(STATIC_DIR, str(tenant_id))
basename = str(item_id)
return ddir, sdir, basename
def _process_image(img):
"""Resize and convert image to WebP. Returns (full_bytes, thumb_bytes)."""
# Convert to RGB if necessary (e.g. RGBA -> RGB for WEBP compatibility)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Resize original if too wide
w, h = img.size
if w > MAX_WIDTH:
ratio = MAX_WIDTH / w
new_h = int(h * ratio)
img = img.resize((MAX_WIDTH, new_h), Image.LANCZOS)
# Full image
full_buf = BytesIO()
img.save(full_buf, format=FORMAT, quality=QUALITY, optimize=True)
full_buf.seek(0)
# Thumbnail (crop to square from center)
thumb = img.copy()
tw, th = thumb.size
min_dim = min(tw, th)
left = (tw - min_dim) // 2
top = (th - min_dim) // 2
thumb = thumb.crop((left, top, left + min_dim, top + min_dim))
thumb = thumb.resize(THUMB_SIZE, Image.LANCZOS)
thumb_buf = BytesIO()
thumb.save(thumb_buf, format=FORMAT, quality=THUMB_QUALITY, optimize=True)
thumb_buf.seek(0)
return full_buf, thumb_buf
def save_image(tenant_id, item_id, file_obj=None, image_url=None,
filename_hint=None):
"""Save an image for an inventory item.
Args:
tenant_id: tenant ID for path isolation
item_id: inventory item ID
file_obj: file-like object (from Flask request.files)
image_url: URL to download image from
filename_hint: original filename for extension detection
Returns:
dict with 'image_url', 'thumb_url', 'size_full', 'size_thumb'
"""
if not file_obj and not image_url:
raise ValueError("Either file_obj or image_url is required")
# Load image
if file_obj:
img = Image.open(file_obj)
else:
resp = requests.get(image_url, timeout=30)
resp.raise_for_status()
img = Image.open(BytesIO(resp.content))
# Process
full_buf, thumb_buf = _process_image(img)
# Save to filesystem
ddir, sdir, basename = _build_paths(tenant_id, item_id)
_ensure_dir(ddir)
_ensure_dir(sdir)
full_path = os.path.join(ddir, f"{basename}_full.webp")
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
# Also symlink/copy to static dir for Flask serving
static_full = os.path.join(sdir, f"{basename}_full.webp")
static_thumb = os.path.join(sdir, f"{basename}_thumb.webp")
with open(full_path, 'wb') as f:
f.write(full_buf.read())
with open(thumb_path, 'wb') as f:
f.write(thumb_buf.read())
# Copy to static dir
import shutil
shutil.copy2(full_path, static_full)
shutil.copy2(thumb_path, static_thumb)
# Build URLs
image_url = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
thumb_url = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
return {
'image_url': image_url,
'thumb_url': thumb_url,
'size_full': os.path.getsize(full_path),
'size_thumb': os.path.getsize(thumb_path),
}
def delete_image(tenant_id, item_id):
"""Delete all images for an inventory item."""
ddir, sdir, basename = _build_paths(tenant_id, item_id)
deleted = []
for directory in [ddir, sdir]:
for suffix in ['_full.webp', '_thumb.webp']:
path = os.path.join(directory, f"{basename}{suffix}")
if os.path.exists(path):
os.remove(path)
deleted.append(path)
return {'deleted': deleted}
def get_image_info(tenant_id, item_id):
"""Get image info for an inventory item."""
ddir, sdir, basename = _build_paths(tenant_id, item_id)
full_path = os.path.join(ddir, f"{basename}_full.webp")
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
info = {'has_image': False, 'image_url': None, 'thumb_url': None}
if os.path.exists(full_path):
info['has_image'] = True
info['image_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
info['thumb_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
info['size_full'] = os.path.getsize(full_path)
info['size_thumb'] = os.path.getsize(thumb_path)
info['updated_at'] = os.path.getmtime(full_path)
return info