[barcode] add supports

This commit is contained in:
Piotr Domański 2024-03-25 18:22:18 +01:00
parent 1eabac6d9f
commit 04be0d16dc
9 changed files with 133 additions and 5 deletions

View file

@ -15,7 +15,7 @@ services:
api: api:
restart: unless-stopped restart: unless-stopped
image: api image: registry.domandoman.xyz/fooder/api
build: build:
dockerfile: Dockerfile dockerfile: Dockerfile
context: . context: .

View file

@ -2,6 +2,7 @@ from typing import AsyncIterator, Optional
from fastapi import HTTPException from fastapi import HTTPException
from ..utils import product_finder
from ..model.product import Product, CreateProductPayload from ..model.product import Product, CreateProductPayload
from ..domain.product import Product as DBProduct from ..domain.product import Product as DBProduct
from .base import AuthorizedController from .base import AuthorizedController
@ -33,3 +34,36 @@ class ListProduct(AuthorizedController):
session, limit=limit, offset=offset, q=q session, limit=limit, offset=offset, q=q
): ):
yield Product.from_orm(product) yield Product.from_orm(product)
class GetProductByBarCode(AuthorizedController):
async def call(self, barcode: str) -> Product:
async with self.async_session() as session:
product = await DBProduct.get_by_barcode(session, barcode)
if product:
return Product.from_orm(product)
try:
product_data = product_finder.find(barcode)
except product_finder.ProductNotFound:
raise HTTPException(status_code=404, detail="Product not found")
except product_finder.ParseError:
raise HTTPException(
status_code=400, detail="Product was found, but unable to import"
)
try:
product = await DBProduct.create(
session,
product_data.name,
product_data.carb,
product_data.protein,
product_data.fat,
product_data.fiber,
product_data.kcal,
barcode,
)
return Product.from_orm(product)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])

View file

@ -15,6 +15,8 @@ class Product(Base, CommonMixin):
carb: Mapped[float] carb: Mapped[float]
fat: Mapped[float] fat: Mapped[float]
fiber: Mapped[float] fiber: Mapped[float]
hard_coded_calories: Mapped[Optional[float]] = None
barcode: Mapped[Optional[str]] = None
@property @property
def calories(self) -> float: def calories(self) -> float:
@ -22,11 +24,18 @@ class Product(Base, CommonMixin):
:rtype: float :rtype: float
""" """
if self.hard_coded_calories:
return self.hard_coded_calories
return self.protein * 4 + self.carb * 4 + self.fat * 9 + self.fiber * 2 return self.protein * 4 + self.carb * 4 + self.fat * 9 + self.fiber * 2
@classmethod @classmethod
async def list_all( async def list_all(
cls, session: AsyncSession, offset: int, limit: int, q: Optional[str] = None cls,
session: AsyncSession,
offset: int,
limit: int,
q: Optional[str] = None,
) -> AsyncIterator["Product"]: ) -> AsyncIterator["Product"]:
query = select(cls) query = select(cls)
@ -40,6 +49,13 @@ class Product(Base, CommonMixin):
async for row in stream: async for row in stream:
yield row yield row
@classmethod
async def get_by_barcode(
cls, session: AsyncSession, barcode: str
) -> Optional["Product"]:
query = select(cls).where(cls.barcode == barcode)
return await session.scalar(query)
@classmethod @classmethod
async def create( async def create(
cls, cls,
@ -49,6 +65,8 @@ class Product(Base, CommonMixin):
protein: float, protein: float,
fat: float, fat: float,
fiber: float, fiber: float,
hard_coded_calories: Optional[float] = None,
barcode: Optional[str] = None,
) -> "Product": ) -> "Product":
# validation here # validation here
assert carb <= 100, "carb must be less than 100" assert carb <= 100, "carb must be less than 100"
@ -65,7 +83,11 @@ class Product(Base, CommonMixin):
name = name.lower() name = name.lower()
# check if product already exists # check if product already exists
query = select(cls).where(cls.name == name) if barcode is not None:
query = select(cls).where((cls.name == name) | (cls.barcode == barcode))
else:
query = select(cls).where(cls.name == name)
existing_product = await session.scalar(query) existing_product = await session.scalar(query)
assert existing_product is None, "product already exists" assert existing_product is None, "product already exists"
@ -75,7 +97,10 @@ class Product(Base, CommonMixin):
carb=carb, carb=carb,
fat=fat, fat=fat,
fiber=fiber, fiber=fiber,
hard_coded_calories=hard_coded_calories,
barcode=barcode,
) )
session.add(product) session.add(product)
await session.flush() await session.flush()
return product return product

View file

@ -1,5 +1,4 @@
import pytest import pytest
import datetime
@pytest.mark.dependency() @pytest.mark.dependency()
@ -20,3 +19,9 @@ def test_list_product(client):
for product in data: for product in data:
assert product["id"] not in product_ids assert product["id"] not in product_ids
product_ids.add(product["id"]) product_ids.add(product["id"])
@pytest.mark.dependency(depends=["test_create_product"])
def test_get_product_by_barcode(client):
response = client.get("product/by_barcode", params={"barcode": "4056489666028"})
assert response.status_code == 200, response.json()

0
fooder/utils/__init__.py Normal file
View file

View file

@ -0,0 +1,52 @@
import requests as r
from dataclasses import dataclass
from logging import getLogger
logger = getLogger(__name__)
class NotFound(Exception):
pass
class ParseError(Exception):
pass
@dataclass
class Product:
name: str
kcal: float
fat: float
protein: float
carb: float
fiber: float
def find(bar_code: str) -> Product:
url = f"https://world.openfoodfacts.org/api/v2/product/{bar_code}.json"
response = r.get(url)
if response.status_code == 404:
raise NotFound()
try:
data = response.json()
name = data["product"]["product_name"]
if data["product"]["brands"]:
name = data["product"]["brands"] + " " + name
return Product(
name=name,
kcal=data["product"]["nutriments"]["energy-kcal_100g"],
fat=data["product"]["nutriments"]["fat_100g"],
protein=data["product"]["nutriments"]["proteins_100g"],
carb=data["product"]["nutriments"]["carbohydrates_100g"],
fiber=data["product"]["nutriments"].get("fiber_100g", 0.0),
)
except Exception as e:
logger.error(e)
raise ParseError()

View file

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from ..model.product import Product, CreateProductPayload, ListProductPayload from ..model.product import Product, CreateProductPayload, ListProductPayload
from ..controller.product import ListProduct, CreateProduct from ..controller.product import ListProduct, CreateProduct, GetProductByBarCode
from typing import Optional from typing import Optional
@ -27,3 +27,12 @@ async def create_product(
contoller: CreateProduct = Depends(CreateProduct), contoller: CreateProduct = Depends(CreateProduct),
): ):
return await contoller.call(data) return await contoller.call(data)
@router.get("/by_barcode", response_model=Product)
async def get_by_bar_code(
request: Request,
barcode: str,
contoller: GetProductByBarCode = Depends(GetProductByBarCode),
):
return await contoller.call(barcode)

View file

@ -8,3 +8,4 @@ psycopg2-binary==2.9.3
python-jose[cryptography] python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
fastapi-users fastapi-users
requests

View file

@ -7,3 +7,5 @@ python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
fastapi-users fastapi-users
pytest pytest
requests
black