diff --git a/fooder/app.py b/fooder/app.py index c970475..ee581d2 100644 --- a/fooder/app.py +++ b/fooder/app.py @@ -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) diff --git a/fooder/context.py b/fooder/context.py index febfbf0..602e596 100644 --- a/fooder/context.py +++ b/fooder/context.py @@ -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: diff --git a/fooder/controller/base.py b/fooder/controller/base.py index ae326d3..5850e4b 100644 --- a/fooder/controller/base.py +++ b/fooder/controller/base.py @@ -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") diff --git a/fooder/controller/token.py b/fooder/controller/token.py index dc437ce..58e8279 100644 --- a/fooder/controller/token.py +++ b/fooder/controller/token.py @@ -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 diff --git a/fooder/controller/user.py b/fooder/controller/user.py index c3f5f1a..8d320fa 100644 --- a/fooder/controller/user.py +++ b/fooder/controller/user.py @@ -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]): diff --git a/fooder/db.py b/fooder/db.py index 7fa2ccf..ab53951 100644 --- a/fooder/db.py +++ b/fooder/db.py @@ -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, diff --git a/fooder/domain/base.py b/fooder/domain/base.py index bcaf75b..fb4b556 100644 --- a/fooder/domain/base.py +++ b/fooder/domain/base.py @@ -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): diff --git a/fooder/domain/product.py b/fooder/domain/product.py index 1ad7aa3..cbb0c85 100644 --- a/fooder/domain/product.py +++ b/fooder/domain/product.py @@ -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) diff --git a/fooder/domain/user.py b/fooder/domain/user.py index 3042a72..c6c3ca6 100644 --- a/fooder/domain/user.py +++ b/fooder/domain/user.py @@ -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): diff --git a/fooder/model/base.py b/fooder/model/base.py new file mode 100644 index 0000000..39c41e2 --- /dev/null +++ b/fooder/model/base.py @@ -0,0 +1,10 @@ +from pydantic import ConfigDict + + +class ObjModelMixin: + """ + Shared code for ObjModel. + """ + id: int + + model_config = ConfigDict(from_attributes=True) diff --git a/fooder/model/product.py b/fooder/model/product.py index 0e7cf14..89ac253 100644 --- a/fooder/model/product.py +++ b/fooder/model/product.py @@ -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 diff --git a/fooder/repository/base.py b/fooder/repository/base.py index 11e3da8..39b0340 100644 --- a/fooder/repository/base.py +++ b/fooder/repository/base.py @@ -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() diff --git a/fooder/repository/expression.py b/fooder/repository/expression.py new file mode 100644 index 0000000..953275c --- /dev/null +++ b/fooder/repository/expression.py @@ -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()}%") diff --git a/fooder/repository/product.py b/fooder/repository/product.py new file mode 100644 index 0000000..e0a1e7b --- /dev/null +++ b/fooder/repository/product.py @@ -0,0 +1,6 @@ +from .base import RepositoryBase +from ..domain import Product + + +class ProductRepository(RepositoryBase[Product]): + pass diff --git a/fooder/repository/repository.py b/fooder/repository/repository.py index 188ef06..8aa467d 100644 --- a/fooder/repository/repository.py +++ b/fooder/repository/repository.py @@ -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() diff --git a/fooder/repository/user.py b/fooder/repository/user.py index f921404..ca35abe 100644 --- a/fooder/repository/user.py +++ b/fooder/repository/user.py @@ -1,5 +1,3 @@ -from sqlalchemy import select, Select - from .base import RepositoryBase from ..domain import User diff --git a/fooder/router.py b/fooder/router.py index 560e8f2..cd9ae26 100644 --- a/fooder/router.py +++ b/fooder/router.py @@ -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"]) diff --git a/fooder/utils/jwt.py b/fooder/utils/jwt.py index fa0e667..ff6d833 100644 --- a/fooder/utils/jwt.py +++ b/fooder/utils/jwt.py @@ -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): diff --git a/fooder/utils/password_helper.py b/fooder/utils/password_helper.py index a28aedd..384ab46 100644 --- a/fooder/utils/password_helper.py +++ b/fooder/utils/password_helper.py @@ -1,5 +1,5 @@ from passlib.context import CryptContext -from ..settings import settings +from fooder.settings import settings class PasswordHelper: diff --git a/fooder/view/product.py b/fooder/view/product.py index e8d76c0..35bc364 100644 --- a/fooder/view/product.py +++ b/fooder/view/product.py @@ -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) + ) + + 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) -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) +# @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) diff --git a/fooder/view/token.py b/fooder/view/token.py index 6b44e2a..2b28ed0 100644 --- a/fooder/view/token.py +++ b/fooder/view/token.py @@ -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"]) diff --git a/fooder/view/user.py b/fooder/view/user.py index 65c020e..48375d8 100644 --- a/fooder/view/user.py +++ b/fooder/view/user.py @@ -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"])