[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.controller.base import ModelController
from fooder.domain import Product from fooder.domain import Product
from fooder.context import Context from fooder.context import Context
from fooder.exc import NotFound, InvalidValue
from fooder.model.product import ProductCreateModel, ProductUpdateModel from fooder.model.product import ProductCreateModel, ProductUpdateModel
from fooder.utils import product_finder
from fooder.utils.calories import calculate_calories from fooder.utils.calories import calculate_calories
@ -51,3 +53,22 @@ class ProductController(ModelController[Product]):
) )
await self.ctx.repo.product.update(self.obj) 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.controller.product import ProductController
from fooder.model.product import ProductCreateModel from fooder.model.product import ProductCreateModel
from fooder.utils import product_finder
from fooder.utils.product_finder import ExternalProduct
@pytest.fixture @pytest.fixture
@ -24,3 +26,39 @@ async def product(ctx):
return ctrl.obj 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): async def test_list_products_without_auth_returns_401(client):
response = await client.get("/api/product") response = await client.get("/api/product")
assert response.status_code == 401 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 dataclasses import dataclass
from logging import getLogger from logging import getLogger
import requests as r import httpx
logger = getLogger(__name__) logger = getLogger(__name__)
@ -15,7 +15,7 @@ class ParseError(Exception):
@dataclass @dataclass
class Product: class ExternalProduct:
name: str name: str
kcal: float kcal: float
fat: float fat: float
@ -24,9 +24,12 @@ class Product:
fiber: float fiber: float
def find(bar_code: str) -> Product: async def find(barcode: str) -> ExternalProduct:
url = f"https://world.openfoodfacts.org/api/v2/product/{bar_code}.json" async with httpx.AsyncClient() as client:
response = r.get(url, headers={"User-Agent": "fooder/1.0"}) 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: if response.status_code == 404:
raise NotFound() raise NotFound()
@ -34,22 +37,18 @@ def find(bar_code: str) -> Product:
try: try:
data = response.json() data = response.json()
product_data = data["product"] product_data = data["product"]
name = product_data.get("product_name_pl") or product_data.get("product_name") name = product_data.get("product_name_pl") or product_data.get("product_name")
if brands := product_data.get("brands"):
brands = product_data.get("brands") name = f"{brands} {name}"
if brands: n = product_data["nutriments"]
name = brands + " " + name return ExternalProduct(
nutriments = product_data["nutriments"]
return Product(
name=name, name=name,
kcal=nutriments.get("energy-kcal_100g") or 0.0, kcal=n.get("energy-kcal_100g") or 0.0,
fat=nutriments.get("fat_100g") or 0.0, fat=n.get("fat_100g") or 0.0,
protein=nutriments.get("proteins_100g") or 0.0, protein=n.get("proteins_100g") or 0.0,
carb=nutriments.get("carbohydrates_100g") or 0.0, carb=n.get("carbohydrates_100g") or 0.0,
fiber=nutriments.get("fiber_100g") or 0.0, fiber=n.get("fiber_100g") or 0.0,
) )
except Exception as e: except (KeyError, TypeError) as e:
logger.error(e) logger.error("Failed to parse product %s: %s", barcode, e)
raise ParseError() 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.model.product import ProductModel, ProductCreateModel, ProductUpdateModel
from fooder.controller.product import ProductController from fooder.controller.product import ProductController
from fooder.context import Context, AuthContextDependency from fooder.context import Context, AuthContextDependency
from fooder.exc import NotFound
router = APIRouter(tags=["product"]) router = APIRouter(tags=["product"])
@ -29,6 +30,19 @@ async def update_product(
return obj 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) @router.post("", response_model=ProductModel, status_code=201)
async def create_product( async def create_product(
data: ProductCreateModel, data: ProductCreateModel,