feat(pos): sistema de imagenes — upload, thumbnail, display en inventario y catalogo
- Add POST/DELETE /items/{id}/image endpoints with Pillow processing (resize 800px, thumbnail 300px, JPEG 85%)
- Validate file type (jpg/png/webp) and size (max 5MB)
- Show image in product detail modal with upload/delete buttons
- Enrich catalog parts list with local inventory image when available
- Image directory created automatically on first upload (pos/static/images/parts/)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
# /home/Autopartes/pos/blueprints/inventory_bp.py
|
||||
"""Inventory blueprint: CRUD for inventory items + stock operations + reports."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
@@ -312,6 +314,132 @@ def update_item(item_id):
|
||||
return jsonify({'message': 'Item updated'})
|
||||
|
||||
|
||||
# ─── Image Upload / Delete ─────────────────────
|
||||
|
||||
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'images', 'parts')
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
def _process_image(file_data, max_size=800):
|
||||
"""Resize image to max_size and convert to JPEG."""
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(file_data))
|
||||
img.thumbnail((max_size, max_size), Image.LANCZOS)
|
||||
if img.mode not in ('RGB', 'L'):
|
||||
img = img.convert('RGB')
|
||||
output = io.BytesIO()
|
||||
img.save(output, format='JPEG', quality=85)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _process_thumbnail(file_data, size=300):
|
||||
"""Generate a smaller thumbnail."""
|
||||
return _process_image(file_data, max_size=size)
|
||||
|
||||
|
||||
def _delete_image_files(tenant_id, item_id):
|
||||
"""Remove image and thumbnail for the given item from disk."""
|
||||
for suffix in ('', '_thumb'):
|
||||
path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}{suffix}.jpg')
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def upload_image(item_id):
|
||||
"""Upload an image for an inventory item. Accepts multipart file upload.
|
||||
Validates file type (jpg, png, webp) and size (max 5 MB).
|
||||
Saves resized image + thumbnail, updates inventory.image_url.
|
||||
"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
f = request.files['file']
|
||||
if not f.filename:
|
||||
return jsonify({'error': 'Empty filename'}), 400
|
||||
|
||||
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return jsonify({'error': f'File type not allowed. Use: {", ".join(ALLOWED_EXTENSIONS)}'}), 400
|
||||
|
||||
raw = f.read()
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
return jsonify({'error': 'File too large (max 5 MB)'}), 400
|
||||
|
||||
# Verify item exists
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM inventory WHERE id = %s", (item_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
try:
|
||||
# Process and save main image
|
||||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||
main_data = _process_image(raw)
|
||||
main_filename = f'{g.tenant_id}_{item_id}.jpg'
|
||||
main_path = os.path.join(IMAGES_DIR, main_filename)
|
||||
with open(main_path, 'wb') as out:
|
||||
out.write(main_data)
|
||||
|
||||
# Process and save thumbnail
|
||||
thumb_data = _process_thumbnail(raw)
|
||||
thumb_filename = f'{g.tenant_id}_{item_id}_thumb.jpg'
|
||||
thumb_path = os.path.join(IMAGES_DIR, thumb_filename)
|
||||
with open(thumb_path, 'wb') as out:
|
||||
out.write(thumb_data)
|
||||
|
||||
# Update DB
|
||||
image_url = f'/pos/static/images/parts/{main_filename}'
|
||||
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (image_url, item_id))
|
||||
conn.commit()
|
||||
|
||||
log_action(conn, 'IMAGE_UPLOAD', 'inventory', item_id,
|
||||
new_value={'image_url': image_url})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'image_url': image_url,
|
||||
'thumbnail_url': f'/pos/static/images/parts/{thumb_filename}',
|
||||
'message': 'Image uploaded'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_image(item_id):
|
||||
"""Delete the image for an inventory item. Removes files from disk and sets image_url = NULL."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT image_url FROM inventory WHERE id = %s", (item_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
# Remove files from disk
|
||||
_delete_image_files(g.tenant_id, item_id)
|
||||
|
||||
# Clear DB
|
||||
cur.execute("UPDATE inventory SET image_url = NULL WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
|
||||
log_action(conn, 'IMAGE_DELETE', 'inventory', item_id,
|
||||
old_value={'image_url': row[0]})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Image deleted'})
|
||||
|
||||
|
||||
# ─── Stock Operations ──────────────────────────
|
||||
|
||||
@inventory_bp.route('/purchase', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user