[product] search by barcode

This commit is contained in:
Piotr Domański 2026-04-07 15:20:02 +02:00
parent 556480a6d0
commit e5565dbf87
5 changed files with 128 additions and 21 deletions

View file

@ -1,7 +1,9 @@
from fooder.controller.base import ModelController
from fooder.domain import Product
from fooder.context import Context
from fooder.exc import NotFound, InvalidValue
from fooder.model.product import ProductCreateModel, ProductUpdateModel
from fooder.utils import product_finder
from fooder.utils.calories import calculate_calories
@ -51,3 +53,22 @@ class ProductController(ModelController[Product]):
)
await self.ctx.repo.product.update(self.obj)
@classmethod
async def import_by_barcode(cls, ctx: Context, barcode: str) -> "ProductController":
try:
found = await product_finder.find(barcode)
except product_finder.NotFound:
raise NotFound()
except product_finder.ParseError:
raise InvalidValue()
return await cls.create(ctx, ProductCreateModel(
name=found.name,
calories=found.kcal,
fat=found.fat,
protein=found.protein,
carb=found.carb,
fiber=found.fiber,
barcode=barcode,
))

View file

@ -3,6 +3,8 @@ import pytest_asyncio
from fooder.controller.product import ProductController
from fooder.model.product import ProductCreateModel
from fooder.utils import product_finder
from fooder.utils.product_finder import ExternalProduct
@pytest.fixture
@ -24,3 +26,39 @@ async def product(ctx):
return ctrl.obj
@pytest_asyncio.fixture
async def product_with_barcode(ctx):
data = ProductCreateModel(name="Barcoded Product", protein=10.0, carb=5.0, fat=2.0, fiber=1.0, barcode="1234567890")
async with ctx.repo.transaction():
ctrl = await ProductController.create(ctx, data)
return ctrl.obj
@pytest.fixture
def external_product():
return ExternalProduct(
name="External Brand External Product",
kcal=250.0,
fat=8.0,
protein=20.0,
carb=30.0,
fiber=3.0,
)
@pytest.fixture
def mock_product_finder(monkeypatch, external_product):
async def fake_find(barcode: str) -> ExternalProduct:
return external_product
monkeypatch.setattr(product_finder, "find", fake_find)
@pytest.fixture
def mock_product_finder_not_found(monkeypatch):
async def fake_find(barcode: str) -> ExternalProduct:
raise product_finder.NotFound()
monkeypatch.setattr(product_finder, "find", fake_find)

View file

@ -104,3 +104,38 @@ async def test_list_products_filters_by_name(auth_client, product):
async def test_list_products_without_auth_returns_401(client):
response = await client.get("/api/product")
assert response.status_code == 401
async def test_get_by_barcode_returns_product_from_db(auth_client, product_with_barcode):
response = await auth_client.get(f"/api/product/barcode/{product_with_barcode.barcode}")
assert response.status_code == 200
assert response.json()["id"] == product_with_barcode.id
async def test_get_by_barcode_imports_when_not_in_db(auth_client, mock_product_finder, external_product):
response = await auth_client.get("/api/product/barcode/9999999999")
assert response.status_code == 200
body = response.json()
assert body["name"] == external_product.name
assert body["protein"] == external_product.protein
assert body["fat"] == external_product.fat
assert body["carb"] == external_product.carb
assert body["fiber"] == external_product.fiber
assert body["calories"] == external_product.kcal
assert body["barcode"] == "9999999999"
async def test_get_by_barcode_persists_imported_product(auth_client, mock_product_finder):
await auth_client.get("/api/product/barcode/8888888888")
response = await auth_client.get("/api/product/barcode/8888888888")
assert response.status_code == 200
async def test_get_by_barcode_not_found_returns_404(auth_client, mock_product_finder_not_found):
response = await auth_client.get("/api/product/barcode/0000000000")
assert response.status_code == 404
async def test_get_by_barcode_without_auth_returns_401(client):
response = await client.get("/api/product/barcode/1234567890")
assert response.status_code == 401

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass
from logging import getLogger
import requests as r
import httpx
logger = getLogger(__name__)
@ -15,7 +15,7 @@ class ParseError(Exception):
@dataclass
class Product:
class ExternalProduct:
name: str
kcal: float
fat: float
@ -24,9 +24,12 @@ class Product:
fiber: float
def find(bar_code: str) -> Product:
url = f"https://world.openfoodfacts.org/api/v2/product/{bar_code}.json"
response = r.get(url, headers={"User-Agent": "fooder/1.0"})
async def find(barcode: str) -> ExternalProduct:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json",
headers={"User-Agent": "fooder/1.0"},
)
if response.status_code == 404:
raise NotFound()
@ -34,22 +37,18 @@ def find(bar_code: str) -> Product:
try:
data = response.json()
product_data = data["product"]
name = product_data.get("product_name_pl") or product_data.get("product_name")
brands = product_data.get("brands")
if brands:
name = brands + " " + name
nutriments = product_data["nutriments"]
return Product(
if brands := product_data.get("brands"):
name = f"{brands} {name}"
n = product_data["nutriments"]
return ExternalProduct(
name=name,
kcal=nutriments.get("energy-kcal_100g") or 0.0,
fat=nutriments.get("fat_100g") or 0.0,
protein=nutriments.get("proteins_100g") or 0.0,
carb=nutriments.get("carbohydrates_100g") or 0.0,
fiber=nutriments.get("fiber_100g") or 0.0,
kcal=n.get("energy-kcal_100g") or 0.0,
fat=n.get("fat_100g") or 0.0,
protein=n.get("proteins_100g") or 0.0,
carb=n.get("carbohydrates_100g") or 0.0,
fiber=n.get("fiber_100g") or 0.0,
)
except Exception as e:
logger.error(e)
raise ParseError()
except (KeyError, TypeError) as e:
logger.error("Failed to parse product %s: %s", barcode, e)
raise ParseError() from e

View file

@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from fooder.model.product import ProductModel, ProductCreateModel, ProductUpdateModel
from fooder.controller.product import ProductController
from fooder.context import Context, AuthContextDependency
from fooder.exc import NotFound
router = APIRouter(tags=["product"])
@ -29,6 +30,19 @@ async def update_product(
return obj
@router.get("/barcode/{barcode}", response_model=ProductModel)
async def get_by_barcode(
barcode: str,
ctx: Context = Depends(AuthContextDependency()),
):
try:
return await ctx.repo.product.get_by_barcode(barcode)
except NotFound:
async with ctx.repo.transaction():
ctrl = await ProductController.import_by_barcode(ctx, barcode)
return ctrl.obj
@router.post("", response_model=ProductModel, status_code=201)
async def create_product(
data: ProductCreateModel,