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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,5 +1,3 @@
from sqlalchemy import select, Select
from .base import RepositoryBase
from ..domain import User

View file

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

View file

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

View file

@ -1,5 +1,5 @@
from passlib.context import CryptContext
from ..settings import settings
from fooder.settings import settings
class PasswordHelper:

View file

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

View file

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

View file

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