[product] begin implementation [import] change to abs
This commit is contained in:
parent
74ec8aa834
commit
991560d943
22 changed files with 125 additions and 205 deletions
|
|
@ -3,9 +3,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
from .router import router
|
||||
from .settings import settings
|
||||
from .exc import ApiException
|
||||
from fooder.router import router
|
||||
from fooder.settings import settings
|
||||
from fooder.exc import ApiException
|
||||
|
||||
app = FastAPI(title="Fooder")
|
||||
app.include_router(router)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ from fastapi import Depends
|
|||
from fastapi.security import OAuth2PasswordBearer
|
||||
from typing import Callable
|
||||
from datetime import datetime
|
||||
from .db import get_db_session
|
||||
from .domain import User
|
||||
from .repository import Repository
|
||||
from .utils.datetime import utc_now
|
||||
from .utils.jwt import AccessToken
|
||||
from .exc import Unauthorized
|
||||
from fooder.db import get_db_session
|
||||
from fooder.domain import User
|
||||
from fooder.repository import Repository
|
||||
from fooder.utils.datetime import utc_now
|
||||
from fooder.utils.jwt import AccessToken
|
||||
from fooder.exc import Unauthorized
|
||||
|
||||
|
||||
class Context:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from ..context import Context
|
||||
from fooder.context import Context
|
||||
from typing import TypeVar, Generic
|
||||
from sqlalchemy import BinaryExpression
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from .base import ControllerBase
|
||||
from ..context import Context
|
||||
from ..utils.jwt import Token, AccessToken, RefreshToken
|
||||
from fooder.controller.base import ControllerBase
|
||||
from fooder.context import Context
|
||||
from fooder.utils.jwt import Token, AccessToken, RefreshToken
|
||||
from typing import Type, TypeVar
|
||||
from datetime import datetime
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from .base import ModelController
|
||||
from ..domain import User
|
||||
from ..context import Context
|
||||
from ..exc import Unauthorized
|
||||
from .token import TokenController
|
||||
from fooder.controller.base import ModelController
|
||||
from fooder.controller.token import TokenController
|
||||
from fooder.domain import User
|
||||
from fooder.context import Context
|
||||
from fooder.exc import Unauthorized
|
||||
|
||||
|
||||
class UserController(ModelController[User]):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import contextlib
|
||||
from typing import AsyncIterator, AsyncGenerator
|
||||
|
||||
from .settings import Settings, settings
|
||||
from fooder.settings import Settings, settings
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncConnection,
|
||||
AsyncSession,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
||||
from ..utils.password_helper import password_helper
|
||||
from fooder.utils.password_helper import password_helper
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
from typing import AsyncIterator, Optional
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
from sqlalchemy import BigInteger, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base, CommonMixin
|
||||
from fooder.domain.base import Base, CommonMixin
|
||||
|
||||
|
||||
class Product(Base, CommonMixin):
|
||||
|
|
@ -16,14 +12,8 @@ class Product(Base, CommonMixin):
|
|||
carb: Mapped[float]
|
||||
fat: Mapped[float]
|
||||
fiber: Mapped[float]
|
||||
hard_coded_calories: Mapped[Optional[float]]
|
||||
barcode: Mapped[Optional[str]]
|
||||
|
||||
usage_count_cached: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
default=0,
|
||||
nullable=False,
|
||||
)
|
||||
hard_coded_calories: Mapped[float | None]
|
||||
barcode: Mapped[str | None]
|
||||
|
||||
@property
|
||||
def calories(self) -> float:
|
||||
|
|
@ -35,107 +25,3 @@ class Product(Base, CommonMixin):
|
|||
return self.hard_coded_calories
|
||||
|
||||
return self.protein * 4 + self.carb * 4 + self.fat * 9 + self.fiber * 2
|
||||
|
||||
@classmethod
|
||||
async def list_all(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
offset: int,
|
||||
limit: int,
|
||||
q: Optional[str] = None,
|
||||
) -> AsyncIterator["Product"]:
|
||||
query = select(cls)
|
||||
|
||||
if q:
|
||||
q_list = q.split()
|
||||
qq = "%" + "%".join(q_list) + "%"
|
||||
query = query.filter(cls.name.ilike(f"%{qq.lower()}%"))
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
stream = await session.stream_scalars(
|
||||
query.order_by(cls.usage_count_cached.desc())
|
||||
)
|
||||
async for row in stream:
|
||||
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
|
||||
async def create(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
carb: float,
|
||||
protein: float,
|
||||
fat: float,
|
||||
fiber: float,
|
||||
hard_coded_calories: Optional[float] = None,
|
||||
barcode: Optional[str] = None,
|
||||
) -> "Product":
|
||||
# validation here
|
||||
assert carb <= 100, "carb must be less than 100"
|
||||
assert protein <= 100, "protein must be less than 100"
|
||||
assert fat <= 100, "fat must be less than 100"
|
||||
assert fiber <= 100, "fiber must be less than 100"
|
||||
assert carb >= 0, "carb must be greater than 0"
|
||||
assert protein >= 0, "protein must be greater than 0"
|
||||
assert fat >= 0, "fat must be greater than 0"
|
||||
assert fiber >= 0, "fiber must be greater than 0"
|
||||
assert carb + protein + fat <= 100, "total must be less than 100"
|
||||
|
||||
# to avoid duplicates in the database keep name as lower
|
||||
name = name.lower()
|
||||
|
||||
# check if product already exists
|
||||
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)
|
||||
assert existing_product is None, "product already exists"
|
||||
|
||||
product = Product(
|
||||
name=name,
|
||||
protein=protein,
|
||||
carb=carb,
|
||||
fat=fat,
|
||||
fiber=fiber,
|
||||
hard_coded_calories=hard_coded_calories,
|
||||
barcode=barcode,
|
||||
)
|
||||
|
||||
session.add(product)
|
||||
await session.flush()
|
||||
return product
|
||||
|
||||
@classmethod
|
||||
async def cache_usage_data(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
from .entry import Entry
|
||||
|
||||
stmt = (
|
||||
update(cls)
|
||||
.where(
|
||||
cls.id.in_(
|
||||
select(Entry.product_id).where(Entry.processed == False).distinct()
|
||||
)
|
||||
)
|
||||
.values(
|
||||
usage_count_cached=cls.usage_count_cached
|
||||
+ select(func.count(Entry.id)).where(
|
||||
Entry.product_id == cls.id,
|
||||
Entry.processed == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await Entry.mark_processed(session)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqlalchemy.orm import Mapped
|
||||
|
||||
from .base import Base, CommonMixin, PasswordMixin
|
||||
from fooder.domain.base import Base, CommonMixin, PasswordMixin
|
||||
|
||||
|
||||
class User(Base, CommonMixin, PasswordMixin):
|
||||
|
|
|
|||
10
fooder/model/base.py
Normal file
10
fooder/model/base.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from pydantic import ConfigDict
|
||||
|
||||
|
||||
class ObjModelMixin:
|
||||
"""
|
||||
Shared code for ObjModel.
|
||||
"""
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
|
@ -1,36 +1,30 @@
|
|||
from typing import List
|
||||
|
||||
from .base import ObjModelMixin
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Product(BaseModel):
|
||||
"""Product."""
|
||||
|
||||
id: int
|
||||
class ProductModelBase(BaseModel):
|
||||
name: str
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
calories: float
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
barcode: str | None
|
||||
usage_count_cached: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateProductPayload(BaseModel):
|
||||
"""ProductCreatePayload."""
|
||||
|
||||
name: str
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
class ProductModel(ObjModelMixin, ProductModelBase):
|
||||
pass
|
||||
|
||||
|
||||
class ListProductPayload(BaseModel):
|
||||
"""ProductListPayload."""
|
||||
class ProductCreateModel(ProductModelBase):
|
||||
pass
|
||||
|
||||
products: List[Product]
|
||||
|
||||
class ProductUpdateModel(ProductModelBase):
|
||||
name: str | None = None
|
||||
protein: float | None = None
|
||||
carb: float | None = None
|
||||
fat: float | None = None
|
||||
fiber: float | None = None
|
||||
calories: float | None = None
|
||||
barcode: str | None = None
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ from typing import TypeVar, Generic, Type, Sequence
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete as sa_delete, ColumnElement
|
||||
from sqlalchemy.sql import Select
|
||||
from ..domain import Base
|
||||
from fooder.domain import Base
|
||||
|
||||
T = TypeVar("T", bound=Base)
|
||||
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
|
||||
|
||||
class RepositoryBase(Generic[T]):
|
||||
def __init__(self, model: Type[T], session: AsyncSession):
|
||||
self.model = model
|
||||
|
|
@ -25,8 +28,15 @@ class RepositoryBase(Generic[T]):
|
|||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list(self, *expressions: ColumnElement) -> Sequence[T]:
|
||||
async def list(self, *expressions: ColumnElement, offset: int = 0, limit: int | None = DEFAULT_LIMIT) -> Sequence[T]:
|
||||
stmt = self._build_select(*expressions)
|
||||
|
||||
if offset:
|
||||
result = stmt.offset(offset)
|
||||
|
||||
if limit is not None:
|
||||
result = stmt.limit(limit)
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
|
|
|||
8
fooder/repository/expression.py
Normal file
8
fooder/repository/expression.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy import ColumnElement
|
||||
|
||||
|
||||
def fuzzy_match(attr: InstrumentedAttribute[str], q: str) -> ColumnElement:
|
||||
q_list = q.split()
|
||||
qq = "%" + "%".join(q_list) + "%"
|
||||
return attr.ilike(f"%{qq.lower()}%")
|
||||
6
fooder/repository/product.py
Normal file
6
fooder/repository/product.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .base import RepositoryBase
|
||||
from ..domain import Product
|
||||
|
||||
|
||||
class ProductRepository(RepositoryBase[Product]):
|
||||
pass
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .user import UserRepository
|
||||
from ..domain import User
|
||||
from .product import ProductRepository
|
||||
from ..domain import User, Product
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
self.user = UserRepository(User, session)
|
||||
self.product = ProductRepository(Product, session)
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self.session.commit()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from sqlalchemy import select, Select
|
||||
|
||||
from .base import RepositoryBase
|
||||
from ..domain import User
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from .view.token import router as token_router
|
||||
from fooder.view.token import router as token_router
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router.include_router(token_router, prefix="/token", tags=["token"])
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ from pydantic import BaseModel
|
|||
from datetime import timedelta, datetime
|
||||
from typing import ClassVar, Literal
|
||||
import logging
|
||||
from ..settings import settings
|
||||
from ..exc import Unauthorized
|
||||
from fooder.settings import settings
|
||||
from fooder.exc import Unauthorized
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from passlib.context import CryptContext
|
||||
from ..settings import settings
|
||||
from fooder.settings import settings
|
||||
|
||||
|
||||
class PasswordHelper:
|
||||
|
|
|
|||
|
|
@ -1,39 +1,45 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..controller.product import CreateProduct, GetProductByBarCode, ListProduct
|
||||
from ..model.product import CreateProductPayload, ListProductPayload, Product
|
||||
from fooder.repository.expression import fuzzy_match
|
||||
from fooder.domain import Product
|
||||
from fooder.model.product import ProductModel
|
||||
from fooder.context import Context, AuthContextDependency
|
||||
|
||||
router = APIRouter(tags=["product"])
|
||||
|
||||
|
||||
@router.get("", response_model=ListProductPayload)
|
||||
async def list_product(
|
||||
request: Request,
|
||||
controller: ListProduct = Depends(ListProduct),
|
||||
@router.get("/", response_model=list[ProductModel])
|
||||
async def list_products(
|
||||
ctx: Context = Depends(AuthContextDependency()),
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
q: Optional[str] = None,
|
||||
):
|
||||
return ListProductPayload(
|
||||
products=[p async for p in controller.call(limit=limit, offset=offset, q=q)]
|
||||
expressions = []
|
||||
if q:
|
||||
expressions.append(
|
||||
fuzzy_match(Product.name, q)
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=Product)
|
||||
async def create_product(
|
||||
request: Request,
|
||||
data: CreateProductPayload,
|
||||
contoller: CreateProduct = Depends(CreateProduct),
|
||||
):
|
||||
return await contoller.call(data)
|
||||
objs = await ctx.repo.product.list(*expressions, limit=limit, offset=offset)
|
||||
return [ProductModel.model_validate(obj) for obj in objs]
|
||||
|
||||
|
||||
@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)
|
||||
# @router.post("/", response_model=Product)
|
||||
# async def create_product(
|
||||
# request: Request,
|
||||
# data: CreateProductPayload,
|
||||
# contoller: CreateProduct = Depends(CreateProduct),
|
||||
# ):
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ from fastapi import APIRouter, Depends
|
|||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from datetime import datetime
|
||||
|
||||
from ..model.token import TokenResponse, RefreshTokenRequest
|
||||
from ..context import ContextDependency, Context
|
||||
from ..controller import UserController
|
||||
from fooder.model.token import TokenResponse, RefreshTokenRequest
|
||||
from fooder.context import ContextDependency, Context
|
||||
from fooder.controller import UserController
|
||||
|
||||
router = APIRouter(tags=["token"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from ..controller.user import CreateUser
|
||||
from ..model.user import CreateUserPayload, User
|
||||
from fooder.controller.user import CreateUser
|
||||
from fooder.model.user import CreateUserPayload, User
|
||||
|
||||
router = APIRouter(tags=["user"])
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue