v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav
This commit is contained in:
57
backend/app/routers/achievements.py
Normal file
57
backend/app/routers/achievements.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["achievements"])
|
||||
|
||||
@router.get("/achievements")
|
||||
async def get_achievements(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
# Get all achievements
|
||||
res = await db.execute(select(Achievement).order_by(Achievement.id))
|
||||
all_achievements = res.scalars().all()
|
||||
|
||||
# Get user unlocked
|
||||
unlocked_res = await db.execute(
|
||||
select(UserAchievement).where(UserAchievement.user_id == user.id)
|
||||
)
|
||||
unlocked = {ua.achievement_id: ua.unlocked_at for ua in unlocked_res.scalars().all()}
|
||||
|
||||
# Get progress counts
|
||||
total_scans_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
|
||||
total_scans = total_scans_res.scalar() or 0
|
||||
|
||||
photo_scans_res = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.barcode == "PHOTO")
|
||||
)
|
||||
photo_scans = photo_scans_res.scalar() or 0
|
||||
|
||||
progress_map = {
|
||||
"first_scan": min(total_scans, 1),
|
||||
"detective": min(total_scans, 10),
|
||||
"expert": min(total_scans, 50),
|
||||
"master": min(total_scans, 100),
|
||||
"photographer": min(photo_scans, 5),
|
||||
"comparator": 0, # tracked separately
|
||||
}
|
||||
|
||||
result = []
|
||||
for a in all_achievements:
|
||||
is_unlocked = a.id in unlocked
|
||||
result.append({
|
||||
"id": a.id,
|
||||
"code": a.code,
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"emoji": a.emoji,
|
||||
"target": a.target,
|
||||
"progress": progress_map.get(a.code, 0),
|
||||
"unlocked": is_unlocked,
|
||||
"unlocked_at": unlocked[a.id].isoformat() if is_unlocked else None,
|
||||
})
|
||||
|
||||
return {"achievements": result, "total_scans": total_scans}
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse
|
||||
@@ -8,6 +9,14 @@ from app.utils.security import hash_password, verify_password, create_access_tok
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
def user_dict(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id, "email": user.email, "name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(select(User).where(User.email == req.email))
|
||||
@@ -20,10 +29,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
await db.refresh(user)
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
|
||||
)
|
||||
return TokenResponse(access_token=token, user=user_dict(user))
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
@@ -33,11 +39,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status_code=401, detail="Email ou senha incorretos")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
|
||||
)
|
||||
return TokenResponse(access_token=token, user=user_dict(user))
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return UserResponse(id=user.id, email=user.email, name=user.name, is_premium=user.is_premium)
|
||||
return user_dict(user)
|
||||
|
||||
48
backend/app/routers/compare.py
Normal file
48
backend/app/routers/compare.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.models.product import Product
|
||||
from app.utils.security import get_current_user
|
||||
from app.services.achievements import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["compare"])
|
||||
|
||||
@router.post("/compare")
|
||||
async def compare_products(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
scan_ids = data.get("scan_ids", [])
|
||||
if len(scan_ids) < 2 or len(scan_ids) > 4:
|
||||
raise HTTPException(status_code=400, detail="Selecione entre 2 e 4 produtos")
|
||||
|
||||
results = []
|
||||
for sid in scan_ids:
|
||||
res = await db.execute(select(Scan).where(Scan.id == sid, Scan.user_id == user.id))
|
||||
scan = res.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail=f"Scan {sid} não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
prod_res = await db.execute(select(Product).where(Product.barcode == scan.barcode))
|
||||
product = prod_res.scalar_one_or_none()
|
||||
|
||||
results.append({
|
||||
"scan_id": scan.id,
|
||||
"product_name": scan.product_name,
|
||||
"brand": scan.brand,
|
||||
"score": scan.score,
|
||||
"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", []),
|
||||
"nutrition": analysis.get("nutrition", {}),
|
||||
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
|
||||
})
|
||||
|
||||
# Check comparison achievement
|
||||
await check_achievements(user.id, db, action="compare")
|
||||
|
||||
return {"products": results}
|
||||
51
backend/app/routers/profile.py
Normal file
51
backend/app/routers/profile.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["profile"])
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
allergies: Optional[List[str]] = None
|
||||
health_profile: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
VALID_PROFILES = ["normal", "crianca", "gestante", "diabetico", "hipertenso"]
|
||||
|
||||
@router.put("/profile")
|
||||
async def update_profile(data: ProfileUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
if data.allergies is not None:
|
||||
user.allergies = json.dumps(data.allergies)
|
||||
if data.health_profile is not None:
|
||||
if data.health_profile not in VALID_PROFILES:
|
||||
raise HTTPException(status_code=400, detail=f"Perfil inválido. Use: {', '.join(VALID_PROFILES)}")
|
||||
user.health_profile = data.health_profile
|
||||
if data.name is not None:
|
||||
user.name = data.name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_premium": user.is_premium,
|
||||
"allergies": json.loads(user.allergies or "[]"),
|
||||
"health_profile": user.health_profile or "normal",
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
58
backend/app/routers/share.py
Normal file
58
backend/app/routers/share.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
from app.database import get_db
|
||||
from app.models.scan import Scan
|
||||
from app.models.product import Product
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["share"])
|
||||
|
||||
@router.get("/scan/{scan_id}/share", response_class=HTMLResponse)
|
||||
async def share_scan(scan_id: int, db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = res.scalar_one_or_none()
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan não encontrado")
|
||||
|
||||
analysis = json.loads(scan.analysis_json or '{}')
|
||||
score = scan.score or 0
|
||||
color = '#10B981' if score >= 70 else '#EAB308' if score >= 50 else '#F97316' if score >= 30 else '#EF4444'
|
||||
label = 'Excelente' if score >= 90 else 'Bom' if score >= 70 else 'Regular' if score >= 50 else 'Ruim' if score >= 30 else 'Péssimo'
|
||||
|
||||
positives = ''.join(f'<li style="color:#10B981">✅ {p}</li>' for p in analysis.get("positives", []))
|
||||
negatives = ''.join(f'<li style="color:#EF4444">❌ {n}</li>' for n in analysis.get("negatives", []))
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta property="og:title" content="ALETHEIA: {scan.product_name} - Score {score}/100">
|
||||
<meta property="og:description" content="{scan.summary or ''}">
|
||||
<title>ALETHEIA - {scan.product_name}</title>
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#0A0E17;color:white;font-family:Inter,system-ui,sans-serif;padding:20px;max-width:500px;margin:0 auto}}
|
||||
.card{{background:#111827;border-radius:20px;padding:24px;margin-bottom:16px;border:1px solid rgba(255,255,255,0.05)}}
|
||||
.score{{width:120px;height:120px;border-radius:50%;border:6px solid {color};display:flex;align-items:center;justify-content:center;margin:0 auto 16px;flex-direction:column}}
|
||||
.score span{{font-size:36px;font-weight:900;color:{color}}}
|
||||
.score small{{font-size:12px;color:#9CA3AF}}
|
||||
h1{{font-size:20px;text-align:center;margin-bottom:4px}}
|
||||
h2{{font-size:14px;color:#9CA3AF;text-align:center;margin-bottom:16px}}
|
||||
.label{{display:inline-block;padding:4px 16px;border-radius:20px;font-weight:700;font-size:14px;color:{color};background:{color}15;text-align:center;margin:0 auto 20px;display:block;width:fit-content}}
|
||||
ul{{list-style:none;padding:0}}li{{font-size:13px;margin-bottom:6px;color:#D1D5DB}}
|
||||
.logo{{text-align:center;margin-top:24px;font-size:12px;color:#6B7280}}
|
||||
.logo b{{background:linear-gradient(135deg,#00D4AA,#7C3AED);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="score"><span>{score}</span><small>/100</small></div>
|
||||
<h1>{scan.product_name or 'Produto'}</h1>
|
||||
<h2>{scan.brand or ''}</h2>
|
||||
<div class="label">{label}</div>
|
||||
<p style="font-size:13px;color:#D1D5DB;text-align:center;margin-bottom:16px">{scan.summary or ''}</p>
|
||||
{f'<ul>{positives}</ul>' if positives else ''}
|
||||
{f'<ul style="margin-top:12px">{negatives}</ul>' if negatives else ''}
|
||||
</div>
|
||||
<div class="logo">Analisado por <b>ALETHEIA</b> — A verdade sobre o que você come</div>
|
||||
</body></html>"""
|
||||
return HTMLResponse(content=html)
|
||||
52
backend/app/routers/shopping.py
Normal file
52
backend/app/routers/shopping.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.shopping_list import ShoppingItem
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["shopping"])
|
||||
|
||||
class ShoppingAdd(BaseModel):
|
||||
product_name: str
|
||||
barcode: Optional[str] = None
|
||||
|
||||
@router.post("/shopping-list")
|
||||
async def add_to_list(data: ShoppingAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
item = ShoppingItem(user_id=user.id, product_name=data.product_name, barcode=data.barcode)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return {"id": item.id, "product_name": item.product_name, "barcode": item.barcode, "checked": item.checked, "added_at": item.added_at.isoformat()}
|
||||
|
||||
@router.get("/shopping-list")
|
||||
async def get_list(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(
|
||||
select(ShoppingItem).where(ShoppingItem.user_id == user.id).order_by(ShoppingItem.added_at.desc())
|
||||
)
|
||||
items = res.scalars().all()
|
||||
return [{"id": i.id, "product_name": i.product_name, "barcode": i.barcode, "checked": i.checked,
|
||||
"added_at": i.added_at.isoformat()} for i in items]
|
||||
|
||||
@router.delete("/shopping-list/{item_id}")
|
||||
async def delete_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
|
||||
item = res.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item não encontrado")
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.put("/shopping-list/{item_id}/toggle")
|
||||
async def toggle_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id))
|
||||
item = res.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item não encontrado")
|
||||
item.checked = not item.checked
|
||||
await db.commit()
|
||||
return {"id": item.id, "checked": item.checked}
|
||||
87
backend/app/routers/stats.py
Normal file
87
backend/app/routers/stats.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc, asc
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.scan import Scan
|
||||
from app.utils.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["stats"])
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Total scans
|
||||
total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id))
|
||||
total_scans = total_res.scalar() or 0
|
||||
|
||||
# Average score
|
||||
avg_res = await db.execute(select(func.avg(Scan.score)).where(Scan.user_id == user.id))
|
||||
avg_score = round(avg_res.scalar() or 0, 1)
|
||||
|
||||
# Monthly scans
|
||||
monthly_res = await db.execute(
|
||||
select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
)
|
||||
monthly_scans = monthly_res.scalar() or 0
|
||||
|
||||
# Top 10 best this month
|
||||
best_res = await db.execute(
|
||||
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
.order_by(desc(Scan.score)).limit(10)
|
||||
)
|
||||
best = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
|
||||
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in best_res.scalars().all()]
|
||||
|
||||
# Top 10 worst this month
|
||||
worst_res = await db.execute(
|
||||
select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start)
|
||||
.order_by(asc(Scan.score)).limit(10)
|
||||
)
|
||||
worst = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score,
|
||||
"scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in worst_res.scalars().all()]
|
||||
|
||||
return {
|
||||
"total_scans": total_scans,
|
||||
"avg_score": avg_score,
|
||||
"monthly_scans": monthly_scans,
|
||||
"best": best,
|
||||
"worst": worst,
|
||||
}
|
||||
|
||||
@router.get("/stats/evolution")
|
||||
async def get_evolution(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc)
|
||||
three_months_ago = now - timedelta(days=90)
|
||||
|
||||
result = await db.execute(
|
||||
select(Scan.scanned_at, Scan.score)
|
||||
.where(Scan.user_id == user.id, Scan.scanned_at >= three_months_ago)
|
||||
.order_by(Scan.scanned_at)
|
||||
)
|
||||
scans = result.all()
|
||||
|
||||
# Group by week
|
||||
weeks = {}
|
||||
for scanned_at, score in scans:
|
||||
# ISO week
|
||||
week_key = scanned_at.strftime("%Y-W%W")
|
||||
week_start = scanned_at - timedelta(days=scanned_at.weekday())
|
||||
if week_key not in weeks:
|
||||
weeks[week_key] = {"week": week_start.strftime("%d/%m"), "scores": [], "count": 0}
|
||||
weeks[week_key]["scores"].append(score)
|
||||
weeks[week_key]["count"] += 1
|
||||
|
||||
evolution = []
|
||||
for key in sorted(weeks.keys()):
|
||||
w = weeks[key]
|
||||
evolution.append({
|
||||
"week": w["week"],
|
||||
"avg_score": round(sum(w["scores"]) / len(w["scores"]), 1),
|
||||
"count": w["count"],
|
||||
})
|
||||
|
||||
return {"evolution": evolution}
|
||||
Reference in New Issue
Block a user