[product] begin implementation [import] change to abs

This commit is contained in:
Piotr Domański 2026-04-04 23:06:30 +02:00
parent 74ec8aa834
commit 991560d943
22 changed files with 125 additions and 205 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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]):

View file

@ -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,

View file

@ -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):

View file

@ -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)

View file

@ -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
View file

@ -0,0 +1,10 @@
from pydantic import ConfigDict
class ObjModelMixin:
"""
Shared code for ObjModel.
"""
id: int
model_config = ConfigDict(from_attributes=True)

View file

@ -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

View file

@ -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()

View 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()}%")

View file

@ -0,0 +1,6 @@
from .base import RepositoryBase
from ..domain import Product
class ProductRepository(RepositoryBase[Product]):
pass

View file

@ -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()

View file

@ -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

View file

@ -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"])

View file

@ -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):

View file

@ -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:

View file

@ -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)
@router.post("", response_model=Product) return [ProductModel.model_validate(obj) for obj in objs]
async def create_product(
request: Request,
data: CreateProductPayload,
contoller: CreateProduct = Depends(CreateProduct),
):
return await contoller.call(data)
@router.get("/by_barcode", response_model=Product) # @router.post("/", response_model=Product)
async def get_by_bar_code( # async def create_product(
request: Request, # request: Request,
barcode: str, # data: CreateProductPayload,
contoller: GetProductByBarCode = Depends(GetProductByBarCode), # contoller: CreateProduct = Depends(CreateProduct),
): # ):
return await contoller.call(barcode) # 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

@ -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"])

View file

@ -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"])