Files
tfm_ainventory/backend/routers/items.py

471 lines
16 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
import json
from slowapi import Limiter
from slowapi.util import get_remote_address
from pathlib import Path
from .. import models, schemas, auth
from ..database import get_db
from ..image_processing import save_image, get_unique_filename
# [H-02] Rate limiter for extract-label endpoint
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(
prefix="/items",
tags=["Items"]
)
@router.get("/stats")
def read_item_stats(
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Item statistics — only for authenticated users."""
total_categories = db.query(models.Category).count()
total_items = db.query(models.Item).count()
# Count items per category string
items_per_category = db.query(models.Item.category, func.count(models.Item.id))\
.group_by(models.Item.category).all()
return {
"total_categories": total_categories,
"total_items": total_items,
"items_distribution": {cat: count for cat, count in items_per_category if cat}
}
@router.get("/", response_model=List[schemas.Item])
def read_items(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] List of items — only for authenticated users."""
items = db.query(models.Item).offset(skip).limit(limit).all()
return items
@router.get("/{item_id}", response_model=schemas.Item)
def read_item(
item_id: int,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Get item — only for authenticated users."""
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return item
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
_MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
@limiter.limit("10/minute")
@router.post("/extract-label")
async def extract_label(
request: Request,
file: UploadFile = File(...),
mode: str = "item", # 'item' or 'box'
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Extract label from image — only for authenticated users. [H-02] Rate limit: 10 req/min per IP."""
from ..ai_vision import extract_label_info
# [SECURITY FIX H-03] Validate MIME type and maximum size
if file.content_type not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"File type not allowed: {file.content_type}. Accepted: {', '.join(_ALLOWED_IMAGE_TYPES)}"
)
contents = await file.read()
if len(contents) > _MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File exceeds 10MB limit."
)
result = extract_label_info(contents, mode=mode)
return result
@router.post("/", response_model=schemas.Item, status_code=status.HTTP_201_CREATED)
def create_item(
item: schemas.ItemCreate,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Create item — only for authenticated users. [M-02] user_id from token."""
# [DUPLICATE CHECK] Prevent duplicate part numbers
if item.barcode:
existing = db.query(models.Item).filter(models.Item.barcode == item.barcode).first()
if existing:
# Return existing item data for user comparison
raise HTTPException(
status_code=409,
detail={
"message": f"Item with Part Number '{item.barcode}' already exists in inventory.",
"existing_id": existing.id,
"existing_item": schemas.Item.model_validate(existing).model_dump(mode='json')
}
)
# [AUTO-PERSIST] Create Category/Color if not exists
if item.category:
cat = db.query(models.Category).filter(models.Category.name == item.category).first()
if not cat:
db.add(models.Category(name=item.category))
db.commit()
if item.color:
col = db.query(models.Color).filter(models.Color.name == item.color).first()
if not col:
db.add(models.Color(name=item.color))
db.commit()
db_item = models.Item(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
# Audit log the creation — [M-02] user_id from token, not from body
# Capture full snapshot
item_snapshot = {
"barcode": db_item.barcode,
"name": db_item.name,
"category": db_item.category,
"type": db_item.type,
"part_number": db_item.part_number,
"color": db_item.color,
"specs": db_item.specs,
"box_label": db_item.box_label,
"image_url": db_item.image_url
}
audit = models.AuditLog(
user_id=current_user.sub,
action="CREATE_ITEM",
target_item_id=db_item.id,
target_item_name=db_item.name,
target_item_pn=db_item.part_number,
target_item_barcode=db_item.barcode,
target_snapshot=json.dumps(item_snapshot),
quantity_change=item.quantity
)
db.add(audit)
db.commit()
return db_item
@router.put("/{item_id}", response_model=schemas.Item)
def update_item(
item_id: int,
item: schemas.ItemCreate,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Update item — only for authenticated users."""
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [AUTO-PERSIST] Create Category/Color if not exists
if item.category:
cat = db.query(models.Category).filter(models.Category.name == item.category).first()
if not cat:
db.add(models.Category(name=item.category))
db.commit()
if item.color:
col = db.query(models.Color).filter(models.Color.name == item.color).first()
if not col:
db.add(models.Color(name=item.color))
db.commit()
update_data = item.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@router.delete("/{item_id}")
def delete_item(
item_id: int,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_admin)
):
"""[C-01] Delete item — only for authenticated users. InterventionItems are cleared; AuditLogs are KEPT for history."""
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [AUDIT] Log the deletion to database and disk
from ..logger import log
log.warning(f"USER[{current_user.sub}] DELETING ITEM: ID={item_id}, Name={db_item.name}, PN={db_item.part_number}")
item_snapshot = {
"barcode": db_item.barcode,
"name": db_item.name,
"category": db_item.category,
"type": db_item.type,
"part_number": db_item.part_number,
"color": db_item.color,
"specs": db_item.specs,
"box_label": db_item.box_label,
"image_url": db_item.image_url,
"final_quantity": db_item.quantity
}
audit = models.AuditLog(
user_id=current_user.sub,
action="DELETE_ITEM",
target_item_id=item_id,
target_item_name=db_item.name,
target_item_pn=db_item.part_number,
target_item_barcode=db_item.barcode,
target_snapshot=json.dumps(item_snapshot),
details=f"Final Quantity: {db_item.quantity}"
)
db.add(audit)
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
# Audit Logs in database are NOT deleted here to preserve history of actions
db.delete(db_item)
db.commit()
return {"message": "Item deleted successfully. History logs preserved."}
@router.post("/{item_id}/photo")
async def upload_photo(
item_id: int,
file: UploadFile = File(...),
crop_bounds: str = None,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Upload and optionally crop photo for item. [FIX-4] Validates crop_bounds before processing."""
# Get item
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [FIX-4] Validate and parse crop_bounds early
crop_bounds_dict = None
if crop_bounds:
try:
crop_bounds_dict = json.loads(crop_bounds)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="crop_bounds must be valid JSON")
# [FIX-4] Validate required keys
required_keys = {'x', 'y', 'width', 'height'}
if not required_keys.issubset(crop_bounds_dict.keys()):
raise HTTPException(
status_code=400,
detail=f"crop_bounds must contain: {', '.join(required_keys)}"
)
# [FIX-4] Validate all values are numbers and non-negative
for key in required_keys:
try:
val = float(crop_bounds_dict[key])
if val < 0:
raise HTTPException(
status_code=400,
detail=f"{key} must be >= 0"
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"{key} must be a number"
)
# [FIX-4] Validate width and height > 0
if crop_bounds_dict['width'] <= 0 or crop_bounds_dict['height'] <= 0:
raise HTTPException(
status_code=400,
detail="width and height must be > 0"
)
# [SECURITY] Validate file type and size
if file.content_type not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"File type not allowed: {file.content_type}"
)
contents = await file.read()
if len(contents) > _MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File exceeds 10MB limit"
)
# Get category for filename
category = db_item.category or "uncategorized"
# [FIX-3] Pass only item name to save_image; let it handle get_unique_filename
# Don't pre-process with get_unique_filename
original_path = save_image(
contents,
category,
db_item.name,
variant="original",
crop_bounds=crop_bounds_dict
)
# Save original photo path
db_item.photo_path = f"/{original_path}"
db.commit()
db.refresh(db_item)
return {
"item_id": db_item.id,
"photo_path": db_item.photo_path,
"message": "Photo uploaded successfully"
}
@router.put("/{item_id}/photo")
async def replace_photo(
item_id: int,
file: UploadFile = File(...),
crop_bounds: str = None,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Replace photo for item. [FIX-1] Uses missing_ok=True for race-safe deletion."""
# Get item
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [FIX-4] Validate and parse crop_bounds early
crop_bounds_dict = None
if crop_bounds:
try:
crop_bounds_dict = json.loads(crop_bounds)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="crop_bounds must be valid JSON")
# [FIX-4] Validate required keys
required_keys = {'x', 'y', 'width', 'height'}
if not required_keys.issubset(crop_bounds_dict.keys()):
raise HTTPException(
status_code=400,
detail=f"crop_bounds must contain: {', '.join(required_keys)}"
)
# [FIX-4] Validate all values are numbers and non-negative
for key in required_keys:
try:
val = float(crop_bounds_dict[key])
if val < 0:
raise HTTPException(
status_code=400,
detail=f"{key} must be >= 0"
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"{key} must be a number"
)
# [FIX-4] Validate width and height > 0
if crop_bounds_dict['width'] <= 0 or crop_bounds_dict['height'] <= 0:
raise HTTPException(
status_code=400,
detail="width and height must be > 0"
)
# [SECURITY] Validate file type and size
if file.content_type not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"File type not allowed: {file.content_type}"
)
contents = await file.read()
if len(contents) > _MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File exceeds 10MB limit"
)
# [FIX-1] Delete old photos using missing_ok=True (race-safe)
if db_item.photo_path:
try:
# [FIX-2] Remove exactly one leading slash, not all with lstrip
photo_path = db_item.photo_path
if photo_path.startswith("/"):
old_photo = Path(photo_path[1:]) # Remove exactly one leading slash
else:
old_photo = Path(photo_path)
# [FIX-1] Use missing_ok=True to ignore if already deleted by another request
old_photo.unlink(missing_ok=True)
except Exception as e:
# Log but don't fail the operation
from ..logger import log
log.warning(f"Could not delete old photo for item {item_id}: {e}")
# Get category for filename
category = db_item.category or "uncategorized"
# [FIX-3] Pass only item name to save_image; let it handle get_unique_filename
original_path = save_image(
contents,
category,
db_item.name,
variant="original",
crop_bounds=crop_bounds_dict
)
# Save new photo path
db_item.photo_path = f"/{original_path}"
db.commit()
db.refresh(db_item)
return {
"item_id": db_item.id,
"photo_path": db_item.photo_path,
"message": "Photo replaced successfully"
}
@router.delete("/{item_id}/photo")
def delete_photo(
item_id: int,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Delete photo for item."""
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
if not db_item.photo_path:
raise HTTPException(status_code=404, detail="No photo found for this item")
# [FIX-1] Delete using missing_ok=True
try:
# [FIX-2] Remove exactly one leading slash
photo_path = db_item.photo_path
if photo_path.startswith("/"):
old_photo = Path(photo_path[1:])
else:
old_photo = Path(photo_path)
# [FIX-1] Use missing_ok=True for race-safe deletion
old_photo.unlink(missing_ok=True)
except Exception as e:
from ..logger import log
log.warning(f"Could not delete photo for item {item_id}: {e}")
db_item.photo_path = None
db.commit()
return {"message": "Photo deleted successfully"}