[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.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,
|
||||||
|
))
|
||||||
|
|
|
||||||
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.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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue