v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
|
||||
import json
|
||||
from datetime import datetime, timezone, date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
@@ -10,15 +10,21 @@ from app.models.scan import Scan
|
||||
from app.schemas.scan import ScanRequest, ScanResult, ScanHistoryItem
|
||||
from app.utils.security import get_current_user
|
||||
from app.integrations.open_food_facts import fetch_product
|
||||
from app.integrations.openai_client import analyze_product
|
||||
from app.integrations.openai_client import analyze_product, analyze_photo
|
||||
from app.config import settings
|
||||
from app.services.seed import SEED_PRODUCTS
|
||||
from app.services.achievements import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["scan"])
|
||||
|
||||
def get_user_context(user: User) -> dict:
|
||||
"""Build user context for AI analysis."""
|
||||
allergies = json.loads(user.allergies or "[]")
|
||||
health_profile = user.health_profile or "normal"
|
||||
return {"allergies": allergies, "health_profile": health_profile}
|
||||
|
||||
@router.post("/scan", response_model=ScanResult)
|
||||
async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
# Rate limit check
|
||||
if not user.is_premium:
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
result = await db.execute(
|
||||
@@ -28,7 +34,6 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
if count >= settings.FREE_SCAN_LIMIT:
|
||||
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido. Faça upgrade para Premium!")
|
||||
|
||||
# Check local cache
|
||||
result = await db.execute(select(Product).where(Product.barcode == req.barcode))
|
||||
product = result.scalar_one_or_none()
|
||||
|
||||
@@ -43,12 +48,10 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
"image_url": product.image_url,
|
||||
}
|
||||
else:
|
||||
# Check seed data
|
||||
if req.barcode in SEED_PRODUCTS:
|
||||
product_data = SEED_PRODUCTS[req.barcode].copy()
|
||||
source = "seed"
|
||||
else:
|
||||
# Fetch from Open Food Facts
|
||||
product_data = await fetch_product(req.barcode)
|
||||
source = "open_food_facts"
|
||||
|
||||
@@ -66,8 +69,38 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
if not product_data:
|
||||
raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.")
|
||||
|
||||
# AI Analysis
|
||||
analysis = await analyze_product(product_data)
|
||||
user_context = get_user_context(user)
|
||||
analysis = await analyze_product(product_data, user_context=user_context)
|
||||
|
||||
# Add allergen alerts
|
||||
allergies = json.loads(user.allergies or "[]")
|
||||
allergen_alerts = []
|
||||
if allergies and analysis.get("ingredients"):
|
||||
for ing in analysis["ingredients"]:
|
||||
ing_name = (ing.get("name", "") + " " + ing.get("popular_name", "")).lower()
|
||||
for allergy in allergies:
|
||||
allergy_lower = allergy.lower()
|
||||
# Map common allergy names to ingredient keywords
|
||||
allergy_keywords = {
|
||||
"glúten": ["glúten", "trigo", "centeio", "cevada", "aveia", "farinha de trigo", "wheat", "gluten"],
|
||||
"lactose": ["lactose", "leite", "soro de leite", "whey", "caseína", "lácteo", "milk", "dairy"],
|
||||
"amendoim": ["amendoim", "peanut"],
|
||||
"soja": ["soja", "lecitina de soja", "soy"],
|
||||
"ovo": ["ovo", "albumina", "egg"],
|
||||
"frutos do mar": ["camarão", "peixe", "lagosta", "caranguejo", "marisco", "fish", "shrimp"],
|
||||
"nozes": ["nozes", "castanha", "amêndoa", "avelã", "nuts", "almond"],
|
||||
}
|
||||
keywords = allergy_keywords.get(allergy_lower, [allergy_lower])
|
||||
for kw in keywords:
|
||||
if kw in ing_name:
|
||||
allergen_alerts.append({
|
||||
"ingredient": ing.get("name", ""),
|
||||
"allergy": allergy,
|
||||
})
|
||||
ing["is_allergen"] = True
|
||||
break
|
||||
|
||||
analysis["allergen_alerts"] = allergen_alerts
|
||||
|
||||
# Save scan
|
||||
scan = Scan(
|
||||
@@ -77,8 +110,12 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
)
|
||||
db.add(scan)
|
||||
await db.commit()
|
||||
|
||||
# Check achievements
|
||||
new_badges = await check_achievements(user.id, db, action="scan")
|
||||
|
||||
return ScanResult(
|
||||
id=scan.id,
|
||||
barcode=req.barcode,
|
||||
product_name=product_data.get("name"),
|
||||
brand=product_data.get("brand"),
|
||||
@@ -94,6 +131,9 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
|
||||
nutrition=analysis.get("nutrition"),
|
||||
nutrition_verdict=analysis.get("nutrition_verdict"),
|
||||
recipe=analysis.get("recipe"),
|
||||
substitutions=analysis.get("substitutions"),
|
||||
allergen_alerts=allergen_alerts,
|
||||
new_badges=new_badges,
|
||||
source=source,
|
||||
)
|
||||
|
||||
@@ -108,37 +148,6 @@ async def get_history(user: User = Depends(get_current_user), db: AsyncSession =
|
||||
brand=s.brand, score=s.score, scanned_at=s.scanned_at
|
||||
) for s in scans]
|
||||
|
||||
@router.get("/history/{scan_id}")
|
||||
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Scan).where(Scan.id == scan_id, Scan.user_id == user.id)
|
||||
)
|
||||
scan = result.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
# Also get product info
|
||||
prod_result = await db.execute(select(Product).where(Product.barcode == scan.barcode))
|
||||
product = prod_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"id": scan.id,
|
||||
"barcode": scan.barcode,
|
||||
"product_name": scan.product_name,
|
||||
"brand": scan.brand,
|
||||
"score": scan.score,
|
||||
"summary": scan.summary,
|
||||
"scanned_at": scan.scanned_at.isoformat() if scan.scanned_at else None,
|
||||
"category": product.category if product else None,
|
||||
"image_url": product.image_url if product else None,
|
||||
"nutri_score": product.nutri_score if product else None,
|
||||
"nova_group": product.nova_group if product else None,
|
||||
"positives": analysis.get("positives", []),
|
||||
"negatives": analysis.get("negatives", []),
|
||||
"ingredients": analysis.get("ingredients", []),
|
||||
}
|
||||
|
||||
@router.get("/history/{scan_id}")
|
||||
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
@@ -170,4 +179,88 @@ async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
"recipe": analysis.get("recipe"),
|
||||
"substitutions": analysis.get("substitutions"),
|
||||
"allergen_alerts": analysis.get("allergen_alerts", []),
|
||||
}
|
||||
|
||||
@router.post("/scan/photo")
|
||||
async def scan_photo(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), file: UploadFile = File(...)):
|
||||
if not user.is_premium:
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
result = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= today_start)
|
||||
)
|
||||
count = result.scalar()
|
||||
if count >= settings.FREE_SCAN_LIMIT:
|
||||
raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido.")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Imagem muito grande. Máximo 10MB.")
|
||||
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
try:
|
||||
img = Image.open(io.BytesIO(contents))
|
||||
img = img.convert("RGB")
|
||||
max_dim = 1024
|
||||
if max(img.size) > max_dim:
|
||||
ratio = max_dim / max(img.size)
|
||||
img = img.resize((int(img.size[0]*ratio), int(img.size[1]*ratio)), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Imagem inválida: {str(e)}")
|
||||
|
||||
user_context = get_user_context(user)
|
||||
analysis = await analyze_photo(b64, user_context=user_context)
|
||||
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=422, detail="Não foi possível analisar a imagem. Tente uma foto mais nítida do rótulo.")
|
||||
|
||||
scan = Scan(
|
||||
user_id=user.id, barcode="PHOTO",
|
||||
product_name=analysis.get("product_name", "Produto (foto)"),
|
||||
brand=analysis.get("brand", ""), score=analysis.get("score", 50),
|
||||
summary=analysis.get("summary", ""), analysis_json=json.dumps(analysis),
|
||||
)
|
||||
db.add(scan)
|
||||
|
||||
if analysis.get("product_name"):
|
||||
new_product = Product(
|
||||
barcode="PHOTO_" + str(hash(b64[:100]))[-8:],
|
||||
name=analysis.get("product_name"), brand=analysis.get("brand", ""),
|
||||
category=analysis.get("category", ""), ingredients_text=analysis.get("ingredients_text", ""),
|
||||
nutri_score=analysis.get("nutri_score"), nova_group=analysis.get("nova_group"),
|
||||
nutrition_json=json.dumps(analysis.get("nutrition", {})),
|
||||
)
|
||||
db.add(new_product)
|
||||
|
||||
await db.commit()
|
||||
|
||||
new_badges = await check_achievements(user.id, db, action="scan")
|
||||
|
||||
return {
|
||||
"id": scan.id,
|
||||
"barcode": "PHOTO",
|
||||
"product_name": analysis.get("product_name", "Produto (foto)"),
|
||||
"brand": analysis.get("brand", ""),
|
||||
"category": analysis.get("category", ""),
|
||||
"image_url": None,
|
||||
"score": analysis.get("score", 50),
|
||||
"summary": analysis.get("summary", ""),
|
||||
"positives": analysis.get("positives", []),
|
||||
"negatives": analysis.get("negatives", []),
|
||||
"ingredients": analysis.get("ingredients", []),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
"recipe": analysis.get("recipe"),
|
||||
"substitutions": analysis.get("substitutions"),
|
||||
"allergen_alerts": analysis.get("allergen_alerts", []),
|
||||
"nutri_score": analysis.get("nutri_score"),
|
||||
"nova_group": analysis.get("nova_group"),
|
||||
"new_badges": new_badges,
|
||||
"source": "photo",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user