[product] search by barcode
This commit is contained in:
parent
556480a6d0
commit
e5565dbf87
5 changed files with 128 additions and 21 deletions
|
|
@ -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,
|
||||
))
|
||||
|
|
|
|||
38
fooder/test/fixtures/product.py
vendored
38
fooder/test/fixtures/product.py
vendored
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue