diff --git a/fooder/controller/product.py b/fooder/controller/product.py index 09bd254..bba12aa 100644 --- a/fooder/controller/product.py +++ b/fooder/controller/product.py @@ -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, + )) diff --git a/fooder/test/fixtures/product.py b/fooder/test/fixtures/product.py index faaac39..31c1ef9 100644 --- a/fooder/test/fixtures/product.py +++ b/fooder/test/fixtures/product.py @@ -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) + + diff --git a/fooder/test/view/test_product.py b/fooder/test/view/test_product.py index 31f354d..778c8cb 100644 --- a/fooder/test/view/test_product.py +++ b/fooder/test/view/test_product.py @@ -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 diff --git a/fooder/utils/product_finder.py b/fooder/utils/product_finder.py index e2c9e40..da7af49 100644 --- a/fooder/utils/product_finder.py +++ b/fooder/utils/product_finder.py @@ -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 \ No newline at end of file diff --git a/fooder/view/product.py b/fooder/view/product.py index 7bbff7b..ebd8e11 100644 --- a/fooder/view/product.py +++ b/fooder/view/product.py @@ -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,