[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
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .router import router
|
from fooder.router import router
|
||||||
from .settings import settings
|
from fooder.settings import settings
|
||||||
from .exc import ApiException
|
from fooder.exc import ApiException
|
||||||
|
|
||||||
app = FastAPI(title="Fooder")
|
app = FastAPI(title="Fooder")
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ from fastapi import Depends
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .db import get_db_session
|
from fooder.db import get_db_session
|
||||||
from .domain import User
|
from fooder.domain import User
|
||||||
from .repository import Repository
|
from fooder.repository import Repository
|
||||||
from .utils.datetime import utc_now
|
from fooder.utils.datetime import utc_now
|
||||||
from .utils.jwt import AccessToken
|
from fooder.utils.jwt import AccessToken
|
||||||
from .exc import Unauthorized
|
from fooder.exc import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
class Context:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from ..context import Context
|
from fooder.context import Context
|
||||||
from typing import TypeVar, Generic
|
from typing import TypeVar, Generic
|
||||||
from sqlalchemy import BinaryExpression
|
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from .base import ControllerBase
|
from fooder.controller.base import ControllerBase
|
||||||
from ..context import Context
|
from fooder.context import Context
|
||||||
from ..utils.jwt import Token, AccessToken, RefreshToken
|
from fooder.utils.jwt import Token, AccessToken, RefreshToken
|
||||||
from typing import Type, TypeVar
|
from typing import Type, TypeVar
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from .base import ModelController
|
from fooder.controller.base import ModelController
|
||||||
from ..domain import User
|
from fooder.controller.token import TokenController
|
||||||
from ..context import Context
|
from fooder.domain import User
|
||||||
from ..exc import Unauthorized
|
from fooder.context import Context
|
||||||
from .token import TokenController
|
from fooder.exc import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
class UserController(ModelController[User]):
|
class UserController(ModelController[User]):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
from typing import AsyncIterator, AsyncGenerator
|
from typing import AsyncIterator, AsyncGenerator
|
||||||
|
|
||||||
from .settings import Settings, settings
|
from fooder.settings import Settings, settings
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncConnection,
|
AsyncConnection,
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
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):
|
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 fooder.domain.base import Base, CommonMixin
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from .base import Base, CommonMixin
|
|
||||||
|
|
||||||
|
|
||||||
class Product(Base, CommonMixin):
|
class Product(Base, CommonMixin):
|
||||||
|
|
@ -16,14 +12,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]]
|
hard_coded_calories: Mapped[float | None]
|
||||||
barcode: Mapped[Optional[str]]
|
barcode: Mapped[str | None]
|
||||||
|
|
||||||
usage_count_cached: Mapped[int] = mapped_column(
|
|
||||||
BigInteger,
|
|
||||||
default=0,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def calories(self) -> float:
|
def calories(self) -> float:
|
||||||
|
|
@ -35,107 +25,3 @@ class Product(Base, CommonMixin):
|
||||||
return 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
|
|
||||||
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 sqlalchemy.orm import Mapped
|
||||||
|
|
||||||
from .base import Base, CommonMixin, PasswordMixin
|
from fooder.domain.base import Base, CommonMixin, PasswordMixin
|
||||||
|
|
||||||
|
|
||||||
class User(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
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class Product(BaseModel):
|
class ProductModelBase(BaseModel):
|
||||||
"""Product."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
name: str
|
name: str
|
||||||
|
protein: float
|
||||||
|
carb: float
|
||||||
|
fat: float
|
||||||
|
fiber: float
|
||||||
calories: float
|
calories: float
|
||||||
protein: float
|
|
||||||
carb: float
|
|
||||||
fat: float
|
|
||||||
fiber: float
|
|
||||||
barcode: str | None
|
barcode: str | None
|
||||||
usage_count_cached: int | None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class CreateProductPayload(BaseModel):
|
class ProductModel(ObjModelMixin, ProductModelBase):
|
||||||
"""ProductCreatePayload."""
|
pass
|
||||||
|
|
||||||
name: str
|
|
||||||
protein: float
|
|
||||||
carb: float
|
|
||||||
fat: float
|
|
||||||
fiber: float
|
|
||||||
|
|
||||||
|
|
||||||
class ListProductPayload(BaseModel):
|
class ProductCreateModel(ProductModelBase):
|
||||||
"""ProductListPayload."""
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete as sa_delete, ColumnElement
|
from sqlalchemy import select, delete as sa_delete, ColumnElement
|
||||||
from sqlalchemy.sql import Select
|
from sqlalchemy.sql import Select
|
||||||
from ..domain import Base
|
from fooder.domain import Base
|
||||||
|
|
||||||
T = TypeVar("T", bound=Base)
|
T = TypeVar("T", bound=Base)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
class RepositoryBase(Generic[T]):
|
class RepositoryBase(Generic[T]):
|
||||||
def __init__(self, model: Type[T], session: AsyncSession):
|
def __init__(self, model: Type[T], session: AsyncSession):
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
@ -25,8 +28,15 @@ class RepositoryBase(Generic[T]):
|
||||||
result = await self.session.execute(stmt)
|
result = await self.session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
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)
|
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)
|
result = await self.session.execute(stmt)
|
||||||
return result.scalars().all()
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .user import UserRepository
|
from .user import UserRepository
|
||||||
from ..domain import User
|
from .product import ProductRepository
|
||||||
|
from ..domain import User, Product
|
||||||
|
|
||||||
|
|
||||||
class Repository:
|
class Repository:
|
||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.user = UserRepository(User, session)
|
self.user = UserRepository(User, session)
|
||||||
|
self.product = ProductRepository(Product, session)
|
||||||
|
|
||||||
async def commit(self) -> None:
|
async def commit(self) -> None:
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from sqlalchemy import select, Select
|
|
||||||
|
|
||||||
from .base import RepositoryBase
|
from .base import RepositoryBase
|
||||||
from ..domain import User
|
from ..domain import User
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import APIRouter
|
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 = APIRouter(prefix="/api")
|
||||||
router.include_router(token_router, prefix="/token", tags=["token"])
|
router.include_router(token_router, prefix="/token", tags=["token"])
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ from pydantic import BaseModel
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
from typing import ClassVar, Literal
|
from typing import ClassVar, Literal
|
||||||
import logging
|
import logging
|
||||||
from ..settings import settings
|
from fooder.settings import settings
|
||||||
from ..exc import Unauthorized
|
from fooder.exc import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from ..settings import settings
|
from fooder.settings import settings
|
||||||
|
|
||||||
|
|
||||||
class PasswordHelper:
|
class PasswordHelper:
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,45 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..controller.product import CreateProduct, GetProductByBarCode, ListProduct
|
from fooder.repository.expression import fuzzy_match
|
||||||
from ..model.product import CreateProductPayload, ListProductPayload, Product
|
from fooder.domain import Product
|
||||||
|
from fooder.model.product import ProductModel
|
||||||
|
from fooder.context import Context, AuthContextDependency
|
||||||
|
|
||||||
router = APIRouter(tags=["product"])
|
router = APIRouter(tags=["product"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListProductPayload)
|
@router.get("/", response_model=list[ProductModel])
|
||||||
async def list_product(
|
async def list_products(
|
||||||
request: Request,
|
ctx: Context = Depends(AuthContextDependency()),
|
||||||
controller: ListProduct = Depends(ListProduct),
|
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
q: Optional[str] = None,
|
q: Optional[str] = None,
|
||||||
):
|
):
|
||||||
return ListProductPayload(
|
expressions = []
|
||||||
products=[p async for p in controller.call(limit=limit, offset=offset, q=q)]
|
if q:
|
||||||
)
|
expressions.append(
|
||||||
|
fuzzy_match(Product.name, q)
|
||||||
|
)
|
||||||
|
|
||||||
|
objs = await ctx.repo.product.list(*expressions, limit=limit, offset=offset)
|
||||||
|
return [ProductModel.model_validate(obj) for obj in objs]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=Product)
|
# @router.post("/", response_model=Product)
|
||||||
async def create_product(
|
# async def create_product(
|
||||||
request: Request,
|
# request: Request,
|
||||||
data: CreateProductPayload,
|
# data: CreateProductPayload,
|
||||||
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)
|
# @router.get("/by_barcode", response_model=Product)
|
||||||
async def get_by_bar_code(
|
# async def get_by_bar_code(
|
||||||
request: Request,
|
# request: Request,
|
||||||
barcode: str,
|
# barcode: str,
|
||||||
contoller: GetProductByBarCode = Depends(GetProductByBarCode),
|
# contoller: GetProductByBarCode = Depends(GetProductByBarCode),
|
||||||
):
|
# ):
|
||||||
return await contoller.call(barcode)
|
# return await contoller.call(barcode)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ from fastapi import APIRouter, Depends
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ..model.token import TokenResponse, RefreshTokenRequest
|
from fooder.model.token import TokenResponse, RefreshTokenRequest
|
||||||
from ..context import ContextDependency, Context
|
from fooder.context import ContextDependency, Context
|
||||||
from ..controller import UserController
|
from fooder.controller import UserController
|
||||||
|
|
||||||
router = APIRouter(tags=["token"])
|
router = APIRouter(tags=["token"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from ..controller.user import CreateUser
|
from fooder.controller.user import CreateUser
|
||||||
from ..model.user import CreateUserPayload, User
|
from fooder.model.user import CreateUserPayload, User
|
||||||
|
|
||||||
router = APIRouter(tags=["user"])
|
router = APIRouter(tags=["user"])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue