[diary] most of the logic implemented + changed imports to absolute
This commit is contained in:
parent
45c0a91e1e
commit
f2dd9bfea4
35 changed files with 822 additions and 31 deletions
|
|
@ -11,8 +11,8 @@ if __name__ == "__main__":
|
|||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.orm import Session
|
||||
from .domain import Base
|
||||
from .settings import settings
|
||||
from fooder.domain import Base
|
||||
from fooder.settings import settings
|
||||
|
||||
engine = sqlalchemy.create_engine(
|
||||
settings.DB_URI.replace("+asyncpg", "").replace("+aiosqlite", "")
|
||||
|
|
|
|||
19
fooder/command/create_diary.py
Normal file
19
fooder/command/create_diary.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import datetime
|
||||
|
||||
from fooder.context import Context
|
||||
from fooder.controller.diary import DiaryController
|
||||
from fooder.controller.meal import MealController
|
||||
from fooder.domain import Diary
|
||||
from fooder.model.meal import MealCreateModel
|
||||
|
||||
|
||||
async def create_diary(ctx: Context, date: datetime.date) -> Diary:
|
||||
settings = await ctx.repo.user_settings.get_by_user_id(ctx.user.id)
|
||||
diary_ctrl = await DiaryController.create(ctx, date=date, settings=settings)
|
||||
await MealController.create(
|
||||
ctx,
|
||||
diary_id=diary_ctrl.obj.id,
|
||||
data=MealCreateModel(name="Breakfast"),
|
||||
)
|
||||
await ctx.repo.diary.session.refresh(diary_ctrl.obj)
|
||||
return diary_ctrl.obj
|
||||
13
fooder/command/create_entry.py
Normal file
13
fooder/command/create_entry.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fooder.context import Context
|
||||
from fooder.controller.entry import EntryController
|
||||
from fooder.domain import Entry
|
||||
from fooder.model.entry import EntryCreateModel
|
||||
|
||||
|
||||
async def create_entry(ctx: Context, meal_id: int, data: EntryCreateModel) -> Entry:
|
||||
ctrl = await EntryController.create(ctx, meal_id=meal_id, data=data)
|
||||
await ctx.repo.user_product_usage.increment(
|
||||
user_id=ctx.user.id,
|
||||
product_id=data.product_id,
|
||||
)
|
||||
return ctrl.obj
|
||||
38
fooder/controller/diary.py
Normal file
38
fooder/controller/diary.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import datetime
|
||||
|
||||
from fooder.context import Context
|
||||
from fooder.controller.base import ModelController
|
||||
from fooder.domain import Diary
|
||||
from fooder.domain.user_settings import UserSettings
|
||||
from fooder.model.diary import DiaryUpdateModel
|
||||
|
||||
|
||||
class DiaryController(ModelController[Diary]):
|
||||
@classmethod
|
||||
async def create(
|
||||
cls, ctx: Context, date: datetime.date, settings: UserSettings
|
||||
) -> "DiaryController":
|
||||
obj = Diary(
|
||||
user_id=ctx.user.id,
|
||||
date=date,
|
||||
protein_goal=settings.protein_goal,
|
||||
carb_goal=settings.carb_goal,
|
||||
fat_goal=settings.fat_goal,
|
||||
fiber_goal=settings.fiber_goal,
|
||||
calories_goal=settings.calories_goal,
|
||||
)
|
||||
await ctx.repo.diary.create(obj)
|
||||
return cls(ctx, obj)
|
||||
|
||||
async def update(self, data: DiaryUpdateModel) -> None:
|
||||
if data.protein_goal is not None:
|
||||
self.obj.protein_goal = data.protein_goal
|
||||
if data.carb_goal is not None:
|
||||
self.obj.carb_goal = data.carb_goal
|
||||
if data.fat_goal is not None:
|
||||
self.obj.fat_goal = data.fat_goal
|
||||
if data.fiber_goal is not None:
|
||||
self.obj.fiber_goal = data.fiber_goal
|
||||
if data.calories_goal is not None:
|
||||
self.obj.calories_goal = data.calories_goal
|
||||
await self.ctx.repo.diary.update(self.obj)
|
||||
19
fooder/controller/entry.py
Normal file
19
fooder/controller/entry.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from fooder.context import Context
|
||||
from fooder.controller.base import ModelController
|
||||
from fooder.domain import Entry
|
||||
from fooder.model.entry import EntryCreateModel, EntryUpdateModel
|
||||
|
||||
|
||||
class EntryController(ModelController[Entry]):
|
||||
@classmethod
|
||||
async def create(
|
||||
cls, ctx: Context, meal_id: int, data: EntryCreateModel
|
||||
) -> "EntryController":
|
||||
obj = Entry(grams=data.grams, product_id=data.product_id, meal_id=meal_id)
|
||||
await ctx.repo.entry.create(obj)
|
||||
return cls(ctx, obj)
|
||||
|
||||
async def update(self, data: EntryUpdateModel) -> None:
|
||||
if data.grams is not None:
|
||||
self.obj.grams = data.grams
|
||||
await self.ctx.repo.entry.update(self.obj)
|
||||
26
fooder/controller/meal.py
Normal file
26
fooder/controller/meal.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from fooder.context import Context
|
||||
from fooder.controller.base import ModelController
|
||||
from fooder.domain import Meal
|
||||
from fooder.model.meal import MealCreateModel, MealUpdateModel
|
||||
|
||||
|
||||
class MealController(ModelController[Meal]):
|
||||
@classmethod
|
||||
async def create(
|
||||
cls, ctx: Context, diary_id: int, data: MealCreateModel
|
||||
) -> "MealController":
|
||||
order = (
|
||||
data.order
|
||||
if data.order is not None
|
||||
else await ctx.repo.meal.next_order(diary_id)
|
||||
)
|
||||
obj = Meal(name=data.name, order=order, diary_id=diary_id)
|
||||
await ctx.repo.meal.create(obj)
|
||||
return cls(ctx, obj)
|
||||
|
||||
async def update(self, data: MealUpdateModel) -> None:
|
||||
if data.name is not None:
|
||||
self.obj.name = data.name
|
||||
if data.order is not None:
|
||||
self.obj.order = data.order
|
||||
await self.ctx.repo.meal.update(self.obj)
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
from .base import Base # noqa
|
||||
from .diary import Diary # noqa
|
||||
from .entry import Entry # noqa
|
||||
from .meal import Meal # noqa
|
||||
from .product import Product # noqa
|
||||
from .user import User # noqa
|
||||
from .user_product_usage import UserProductUsage # noqa
|
||||
from .user_settings import UserSettings # noqa
|
||||
from .preset import Preset # noqa
|
||||
from .preset_entry import PresetEntry # noqa
|
||||
from fooder.domain.base import Base # noqa
|
||||
from fooder.domain.diary import Diary # noqa
|
||||
from fooder.domain.entry import Entry # noqa
|
||||
from fooder.domain.meal import Meal # noqa
|
||||
from fooder.domain.product import Product # noqa
|
||||
from fooder.domain.user import User # noqa
|
||||
from fooder.domain.user_product_usage import UserProductUsage # noqa
|
||||
from fooder.domain.user_settings import UserSettings # noqa
|
||||
from fooder.domain.preset import Preset # noqa
|
||||
from fooder.domain.preset_entry import PresetEntry # noqa
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Diary(Base, CommonMixin):
|
|||
__table_args__ = (UniqueConstraint("user_id", "date"),)
|
||||
|
||||
meals: Mapped[list[Meal]] = relationship(
|
||||
lazy="selectin", order_by=Meal.order.desc()
|
||||
lazy="selectin", order_by=Meal.order.desc(), cascade="all, delete-orphan"
|
||||
)
|
||||
date: Mapped[datetime.date] = mapped_column(Date)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from sqlalchemy import Boolean, ForeignKey, Integer
|
||||
from sqlalchemy import ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fooder.domain.base import Base, CommonMixin, EntryMacrosMixin
|
||||
|
|
@ -12,4 +12,3 @@ class Entry(Base, CommonMixin, EntryMacrosMixin):
|
|||
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("product.id"))
|
||||
product: Mapped[Product] = relationship(lazy="selectin")
|
||||
meal_id: Mapped[int] = mapped_column(Integer, ForeignKey("meal.id"))
|
||||
processed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ class Meal(Base, CommonMixin, AggregateMacrosMixin):
|
|||
order: Mapped[int]
|
||||
diary_id: Mapped[int] = mapped_column(Integer, ForeignKey("diary.id"))
|
||||
entries: Mapped[list[Entry]] = relationship(
|
||||
lazy="selectin", order_by=Entry.last_changed
|
||||
lazy="selectin", order_by=Entry.last_changed, cascade="all, delete-orphan"
|
||||
)
|
||||
|
|
|
|||
33
fooder/model/diary.py
Normal file
33
fooder/model/diary.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fooder.model.base import ObjModelMixin, Calories
|
||||
from fooder.model.meal import MealModel
|
||||
|
||||
|
||||
class DiaryModel(ObjModelMixin, BaseModel):
|
||||
date: datetime.date
|
||||
protein_goal: Calories
|
||||
carb_goal: Calories
|
||||
fat_goal: Calories
|
||||
fiber_goal: Calories
|
||||
calories_goal: Calories
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
calories: float
|
||||
meals: list[MealModel]
|
||||
|
||||
|
||||
class DiaryCreateModel(BaseModel):
|
||||
date: datetime.date
|
||||
|
||||
|
||||
class DiaryUpdateModel(BaseModel):
|
||||
protein_goal: Calories | None = None
|
||||
carb_goal: Calories | None = None
|
||||
fat_goal: Calories | None = None
|
||||
fiber_goal: Calories | None = None
|
||||
calories_goal: Calories | None = None
|
||||
29
fooder/model/entry.py
Normal file
29
fooder/model/entry.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fooder.model.base import ObjModelMixin
|
||||
from fooder.model.product import ProductModel
|
||||
|
||||
Grams = Annotated[float, Field(gt=0)]
|
||||
|
||||
|
||||
class EntryModel(ObjModelMixin, BaseModel):
|
||||
grams: Grams
|
||||
product_id: int
|
||||
meal_id: int
|
||||
product: ProductModel
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
calories: float
|
||||
|
||||
|
||||
class EntryCreateModel(BaseModel):
|
||||
grams: Grams
|
||||
product_id: int
|
||||
|
||||
|
||||
class EntryUpdateModel(BaseModel):
|
||||
grams: Grams | None = None
|
||||
30
fooder/model/meal.py
Normal file
30
fooder/model/meal.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fooder.model.base import ObjModelMixin
|
||||
from fooder.model.entry import EntryModel
|
||||
|
||||
MealOrder = Annotated[int, Field(ge=0)]
|
||||
|
||||
|
||||
class MealModel(ObjModelMixin, BaseModel):
|
||||
name: str
|
||||
order: MealOrder
|
||||
diary_id: int
|
||||
protein: float
|
||||
carb: float
|
||||
fat: float
|
||||
fiber: float
|
||||
calories: float
|
||||
entries: list[EntryModel]
|
||||
|
||||
|
||||
class MealCreateModel(BaseModel):
|
||||
name: str
|
||||
order: MealOrder | None = None
|
||||
|
||||
|
||||
class MealUpdateModel(BaseModel):
|
||||
name: str | None = None
|
||||
order: MealOrder | None = None
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from typing import Annotated
|
||||
from .base import (
|
||||
from fooder.model.base import (
|
||||
ObjModelMixin,
|
||||
Calories,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from .base import ObjModelMixin, Macronutrient, Calories
|
||||
from fooder.model.base import ObjModelMixin, Macronutrient, Calories
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
|
|
|||
15
fooder/repository/diary.py
Normal file
15
fooder/repository/diary.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import datetime
|
||||
from typing import Sequence
|
||||
|
||||
from fooder.domain import Diary
|
||||
from fooder.repository.base import RepositoryBase, DEFAULT_LIMIT
|
||||
|
||||
|
||||
class DiaryRepository(RepositoryBase[Diary]):
|
||||
async def get_by_user_and_date(self, user_id: int, date: datetime.date) -> Diary:
|
||||
return await self._get(Diary.user_id == user_id, Diary.date == date)
|
||||
|
||||
async def list_by_user(
|
||||
self, user_id: int, offset: int = 0, limit: int | None = DEFAULT_LIMIT
|
||||
) -> Sequence[Diary]:
|
||||
return await self._list(Diary.user_id == user_id, offset=offset, limit=limit)
|
||||
10
fooder/repository/entry.py
Normal file
10
fooder/repository/entry.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from fooder.domain import Entry
|
||||
from fooder.repository.base import RepositoryBase
|
||||
|
||||
|
||||
class EntryRepository(RepositoryBase[Entry]):
|
||||
async def get_by_id_and_meal(self, entry_id: int, meal_id: int) -> Entry:
|
||||
return await self._get(Entry.id == entry_id, Entry.meal_id == meal_id)
|
||||
|
||||
async def delete_by_id_and_meal(self, entry_id: int, meal_id: int) -> None:
|
||||
await self._delete(Entry.id == entry_id, Entry.meal_id == meal_id)
|
||||
18
fooder/repository/meal.py
Normal file
18
fooder/repository/meal.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from sqlalchemy import select, func
|
||||
|
||||
from fooder.domain import Meal
|
||||
from fooder.repository.base import RepositoryBase
|
||||
|
||||
|
||||
class MealRepository(RepositoryBase[Meal]):
|
||||
async def get_by_id_and_diary(self, meal_id: int, diary_id: int) -> Meal:
|
||||
return await self._get(Meal.id == meal_id, Meal.diary_id == diary_id)
|
||||
|
||||
async def delete_by_id_and_diary(self, meal_id: int, diary_id: int) -> None:
|
||||
await self._delete(Meal.id == meal_id, Meal.diary_id == diary_id)
|
||||
|
||||
async def next_order(self, diary_id: int) -> int:
|
||||
stmt = select(func.max(Meal.order)).where(Meal.diary_id == diary_id)
|
||||
result = await self.session.execute(stmt)
|
||||
max_order = result.scalar_one_or_none()
|
||||
return 0 if max_order is None else max_order + 1
|
||||
|
|
@ -2,7 +2,7 @@ from typing import Sequence
|
|||
|
||||
from fooder.domain import Product
|
||||
from fooder.repository.expression import fuzzy_match
|
||||
from .base import RepositoryBase, DEFAULT_LIMIT
|
||||
from fooder.repository.base import RepositoryBase, DEFAULT_LIMIT
|
||||
|
||||
|
||||
class ProductRepository(RepositoryBase[Product]):
|
||||
|
|
|
|||
|
|
@ -7,7 +7,18 @@ from fooder.repository.user import UserRepository
|
|||
from fooder.repository.product import ProductRepository
|
||||
from fooder.repository.user_product_usage import UserProductUsageRepository
|
||||
from fooder.repository.user_settings import UserSettingsRepository
|
||||
from fooder.domain import User, Product, UserProductUsage, UserSettings
|
||||
from fooder.repository.diary import DiaryRepository
|
||||
from fooder.repository.meal import MealRepository
|
||||
from fooder.repository.entry import EntryRepository
|
||||
from fooder.domain import (
|
||||
User,
|
||||
Product,
|
||||
UserProductUsage,
|
||||
UserSettings,
|
||||
Diary,
|
||||
Meal,
|
||||
Entry,
|
||||
)
|
||||
from fooder.exc import Conflict
|
||||
|
||||
|
||||
|
|
@ -18,6 +29,9 @@ class Repository:
|
|||
self.product = ProductRepository(Product, session)
|
||||
self.user_product_usage = UserProductUsageRepository(UserProductUsage, session)
|
||||
self.user_settings = UserSettingsRepository(UserSettings, session)
|
||||
self.diary = DiaryRepository(Diary, session)
|
||||
self.meal = MealRepository(Meal, session)
|
||||
self.entry = EntryRepository(Entry, session)
|
||||
|
||||
async def commit(self) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from fooder.domain import User
|
||||
from .base import RepositoryBase
|
||||
from fooder.repository.base import RepositoryBase
|
||||
|
||||
|
||||
class UserRepository(RepositoryBase[User]):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ from fooder.repository.base import RepositoryBase
|
|||
|
||||
|
||||
class UserProductUsageRepository(RepositoryBase[UserProductUsage]):
|
||||
async def get_by_user_and_product(
|
||||
self, user_id: int, product_id: int
|
||||
) -> UserProductUsage:
|
||||
return await self._get(
|
||||
UserProductUsage.user_id == user_id,
|
||||
UserProductUsage.product_id == product_id,
|
||||
)
|
||||
|
||||
async def increment(self, user_id: int, product_id: int, count: int = 1) -> None:
|
||||
stmt = (
|
||||
sa_update(UserProductUsage)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from fooder.domain import UserSettings
|
||||
from .base import RepositoryBase
|
||||
from fooder.repository.base import RepositoryBase
|
||||
|
||||
|
||||
class UserSettingsRepository(RepositoryBase[UserSettings]):
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ from fastapi import APIRouter
|
|||
from fooder.view.token import router as token_router
|
||||
from fooder.view.product import router as product_router
|
||||
from fooder.view.user_settings import router as user_settings_router
|
||||
from fooder.view.diary import router as diary_router
|
||||
from fooder.view.meal import router as meal_router
|
||||
from fooder.view.entry import router as entry_router
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router.include_router(token_router, prefix="/token", tags=["token"])
|
||||
|
|
@ -10,3 +13,8 @@ router.include_router(product_router, prefix="/product", tags=["product"])
|
|||
router.include_router(
|
||||
user_settings_router, prefix="/user/settings", tags=["user_settings"]
|
||||
)
|
||||
router.include_router(diary_router, prefix="/diary", tags=["diary"])
|
||||
router.include_router(meal_router, prefix="/diary/{date}/meal", tags=["meal"])
|
||||
router.include_router(
|
||||
entry_router, prefix="/diary/{date}/meal/{meal_id}/entry", tags=["entry"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,4 +14,4 @@ os.environ.update(
|
|||
}
|
||||
)
|
||||
|
||||
from .fixtures import *
|
||||
from fooder.test.fixtures import *
|
||||
|
|
|
|||
15
fooder/test/fixtures/__init__.py
vendored
15
fooder/test/fixtures/__init__.py
vendored
|
|
@ -1,11 +1,12 @@
|
|||
import pytest
|
||||
from .db import *
|
||||
from .faker import *
|
||||
from .user import *
|
||||
from .client import *
|
||||
from .context import *
|
||||
from .product import *
|
||||
from .user_settings import *
|
||||
from fooder.test.fixtures.db import *
|
||||
from fooder.test.fixtures.faker import *
|
||||
from fooder.test.fixtures.user import *
|
||||
from fooder.test.fixtures.client import *
|
||||
from fooder.test.fixtures.context import *
|
||||
from fooder.test.fixtures.product import *
|
||||
from fooder.test.fixtures.user_settings import *
|
||||
from fooder.test.fixtures.diary import *
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
7
fooder/test/fixtures/context.py
vendored
7
fooder/test/fixtures/context.py
vendored
|
|
@ -6,3 +6,10 @@ from fooder.repository import Repository
|
|||
@pytest.fixture
|
||||
def ctx(db_session):
|
||||
return Context(repo=Repository(db_session))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_ctx(db_session, user):
|
||||
ctx = Context(repo=Repository(db_session))
|
||||
ctx.set_user(user)
|
||||
return ctx
|
||||
|
|
|
|||
58
fooder/test/fixtures/diary.py
vendored
Normal file
58
fooder/test/fixtures/diary.py
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from fooder.controller.diary import DiaryController
|
||||
from fooder.controller.meal import MealController
|
||||
from fooder.controller.entry import EntryController
|
||||
from fooder.model.meal import MealCreateModel
|
||||
from fooder.model.entry import EntryCreateModel
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def diary(auth_ctx, user_settings):
|
||||
async with auth_ctx.repo.transaction():
|
||||
ctrl = await DiaryController.create(
|
||||
auth_ctx,
|
||||
date=datetime.date.today(),
|
||||
settings=user_settings,
|
||||
)
|
||||
await MealController.create(
|
||||
auth_ctx,
|
||||
diary_id=ctrl.obj.id,
|
||||
data=MealCreateModel(name="Breakfast"),
|
||||
)
|
||||
return ctrl.obj
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def meal(auth_ctx, diary):
|
||||
async with auth_ctx.repo.transaction():
|
||||
ctrl = await MealController.create(
|
||||
auth_ctx,
|
||||
diary_id=diary.id,
|
||||
data=MealCreateModel(name="Lunch"),
|
||||
)
|
||||
return ctrl.obj
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def entry(auth_ctx, meal, product):
|
||||
async with auth_ctx.repo.transaction():
|
||||
ctrl = await EntryController.create(
|
||||
auth_ctx,
|
||||
meal_id=meal.id,
|
||||
data=EntryCreateModel(grams=100.0, product_id=product.id),
|
||||
)
|
||||
return ctrl.obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meal_payload():
|
||||
return {"name": "Dinner"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry_payload(product):
|
||||
return {"grams": 150.0, "product_id": product.id}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import pytest
|
||||
from ..fixtures.db import TestModel
|
||||
from fooder.test.fixtures.db import TestModel
|
||||
from fooder.exc import NotFound
|
||||
|
||||
# ------------------------------------------------------------------ create ---
|
||||
|
|
|
|||
120
fooder/test/view/test_diary.py
Normal file
120
fooder/test/view/test_diary.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import datetime
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
async def test_get_diary_returns_200(auth_client, diary):
|
||||
response = await auth_client.get(f"/api/diary/{TODAY}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_get_diary_returns_correct_fields(auth_client, diary, user_settings):
|
||||
response = await auth_client.get(f"/api/diary/{TODAY}")
|
||||
body = response.json()
|
||||
assert body["date"] == TODAY
|
||||
assert body["protein_goal"] == user_settings.protein_goal
|
||||
assert body["calories_goal"] == user_settings.calories_goal
|
||||
assert "meals" in body
|
||||
assert "id" in body
|
||||
|
||||
|
||||
async def test_get_diary_not_found_returns_404(auth_client):
|
||||
response = await auth_client.get("/api/diary/2000-01-01")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_get_diary_without_auth_returns_401(client):
|
||||
response = await client.get(f"/api/diary/{TODAY}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_create_diary_returns_201(auth_client, user_settings):
|
||||
response = await auth_client.post("/api/diary", json={"date": "2030-06-01"})
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
async def test_create_diary_copies_goals_from_user_settings(auth_client, user_settings):
|
||||
response = await auth_client.post("/api/diary", json={"date": "2030-06-02"})
|
||||
body = response.json()
|
||||
assert body["protein_goal"] == user_settings.protein_goal
|
||||
assert body["carb_goal"] == user_settings.carb_goal
|
||||
assert body["fat_goal"] == user_settings.fat_goal
|
||||
assert body["fiber_goal"] == user_settings.fiber_goal
|
||||
assert body["calories_goal"] == user_settings.calories_goal
|
||||
|
||||
|
||||
async def test_create_diary_creates_breakfast_meal(auth_client, user_settings):
|
||||
response = await auth_client.post("/api/diary", json={"date": "2030-06-03"})
|
||||
meals = response.json()["meals"]
|
||||
assert len(meals) == 1
|
||||
assert meals[0]["name"] == "Breakfast"
|
||||
assert meals[0]["order"] == 0
|
||||
|
||||
|
||||
async def test_create_diary_without_settings_returns_404(auth_client):
|
||||
response = await auth_client.post("/api/diary", json={"date": "2030-06-04"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_create_diary_duplicate_date_returns_409(auth_client, user_settings):
|
||||
await auth_client.post("/api/diary", json={"date": "2030-06-05"})
|
||||
response = await auth_client.post("/api/diary", json={"date": "2030-06-05"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
async def test_create_diary_without_auth_returns_401(client):
|
||||
response = await client.post("/api/diary", json={"date": "2030-06-06"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_update_diary_returns_200(auth_client, diary):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}", json={"protein_goal": 180.0}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_update_diary_partial_update(auth_client, diary, user_settings):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}", json={"protein_goal": 180.0}
|
||||
)
|
||||
body = response.json()
|
||||
assert body["protein_goal"] == 180.0
|
||||
assert body["carb_goal"] == user_settings.carb_goal
|
||||
assert body["calories_goal"] == user_settings.calories_goal
|
||||
|
||||
|
||||
async def test_update_diary_all_goals(auth_client, diary):
|
||||
payload = {
|
||||
"protein_goal": 120.0,
|
||||
"carb_goal": 250.0,
|
||||
"fat_goal": 60.0,
|
||||
"fiber_goal": 25.0,
|
||||
"calories_goal": 1800.0,
|
||||
}
|
||||
response = await auth_client.patch(f"/api/diary/{TODAY}", json=payload)
|
||||
body = response.json()
|
||||
assert body["protein_goal"] == 120.0
|
||||
assert body["carb_goal"] == 250.0
|
||||
assert body["fat_goal"] == 60.0
|
||||
assert body["fiber_goal"] == 25.0
|
||||
assert body["calories_goal"] == 1800.0
|
||||
|
||||
|
||||
async def test_update_diary_not_found_returns_404(auth_client):
|
||||
response = await auth_client.patch(
|
||||
"/api/diary/2000-01-01", json={"protein_goal": 180.0}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_update_diary_negative_goal_returns_422(auth_client, diary):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}", json={"protein_goal": -10.0}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_update_diary_without_auth_returns_401(client):
|
||||
response = await client.patch(f"/api/diary/{TODAY}", json={"protein_goal": 180.0})
|
||||
assert response.status_code == 401
|
||||
113
fooder/test/view/test_entry.py
Normal file
113
fooder/test/view/test_entry.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import datetime
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
async def test_create_entry_returns_201(auth_client, diary, meal, entry_payload):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry", json=entry_payload
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
async def test_create_entry_returns_correct_fields(
|
||||
auth_client, diary, meal, entry_payload, product
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry", json=entry_payload
|
||||
)
|
||||
body = response.json()
|
||||
assert body["grams"] == entry_payload["grams"]
|
||||
assert body["product_id"] == product.id
|
||||
assert body["meal_id"] == meal.id
|
||||
assert "protein" in body
|
||||
assert "calories" in body
|
||||
|
||||
|
||||
async def test_create_entry_zero_grams_returns_422(auth_client, diary, meal, product):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry",
|
||||
json={"grams": 0.0, "product_id": product.id},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_create_entry_negative_grams_returns_422(
|
||||
auth_client, diary, meal, product
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry",
|
||||
json={"grams": -10.0, "product_id": product.id},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_create_entry_increments_user_product_usage(
|
||||
auth_client, auth_ctx, diary, meal, entry_payload, product
|
||||
):
|
||||
await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry", json=entry_payload
|
||||
)
|
||||
await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry", json=entry_payload
|
||||
)
|
||||
usage = await auth_ctx.repo.user_product_usage.get_by_user_and_product(
|
||||
auth_ctx.user.id, product.id
|
||||
)
|
||||
assert usage.count == 2
|
||||
|
||||
|
||||
async def test_create_entry_meal_not_found_returns_404(
|
||||
auth_client, diary, entry_payload
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal/99999/entry", json=entry_payload
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_create_entry_without_auth_returns_401(
|
||||
client, diary, meal, entry_payload
|
||||
):
|
||||
response = await client.post(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry", json=entry_payload
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_update_entry_returns_200(auth_client, diary, meal, entry):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry/{entry.id}",
|
||||
json={"grams": 200.0},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_update_entry_changes_grams(auth_client, diary, meal, entry):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry/{entry.id}",
|
||||
json={"grams": 200.0},
|
||||
)
|
||||
assert response.json()["grams"] == 200.0
|
||||
|
||||
|
||||
async def test_update_entry_not_found_returns_404(auth_client, diary, meal):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry/99999",
|
||||
json={"grams": 200.0},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_entry_returns_204(auth_client, diary, meal, entry):
|
||||
response = await auth_client.delete(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry/{entry.id}"
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
async def test_delete_entry_without_auth_returns_401(client, diary, meal, entry):
|
||||
response = await client.delete(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}/entry/{entry.id}"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
73
fooder/test/view/test_meal.py
Normal file
73
fooder/test/view/test_meal.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import datetime
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
async def test_create_meal_returns_201(auth_client, diary, meal_payload):
|
||||
response = await auth_client.post(f"/api/diary/{TODAY}/meal", json=meal_payload)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
async def test_create_meal_auto_increments_order(auth_client, diary, meal_payload):
|
||||
r1 = await auth_client.post(f"/api/diary/{TODAY}/meal", json=meal_payload)
|
||||
r2 = await auth_client.post(f"/api/diary/{TODAY}/meal", json=meal_payload)
|
||||
assert r2.json()["order"] == r1.json()["order"] + 1
|
||||
|
||||
|
||||
async def test_create_meal_first_order_is_one(auth_client, diary, meal_payload):
|
||||
# diary fixture already creates a Breakfast meal with order=0
|
||||
response = await auth_client.post(f"/api/diary/{TODAY}/meal", json=meal_payload)
|
||||
assert response.json()["order"] == 1
|
||||
|
||||
|
||||
async def test_create_meal_uses_provided_order(auth_client, diary, meal_payload):
|
||||
response = await auth_client.post(
|
||||
f"/api/diary/{TODAY}/meal", json={**meal_payload, "order": 99}
|
||||
)
|
||||
assert response.json()["order"] == 99
|
||||
|
||||
|
||||
async def test_create_meal_diary_not_found_returns_404(auth_client, meal_payload):
|
||||
response = await auth_client.post("/api/diary/2000-01-01/meal", json=meal_payload)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_create_meal_without_auth_returns_401(client, diary, meal_payload):
|
||||
response = await client.post(f"/api/diary/{TODAY}/meal", json=meal_payload)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_update_meal_returns_200(auth_client, diary, meal):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}", json={"name": "Supper"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_update_meal_changes_name(auth_client, diary, meal):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/{meal.id}", json={"name": "Supper"}
|
||||
)
|
||||
assert response.json()["name"] == "Supper"
|
||||
|
||||
|
||||
async def test_update_meal_not_found_returns_404(auth_client, diary):
|
||||
response = await auth_client.patch(
|
||||
f"/api/diary/{TODAY}/meal/99999", json={"name": "Ghost"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_meal_returns_204(auth_client, diary, meal):
|
||||
response = await auth_client.delete(f"/api/diary/{TODAY}/meal/{meal.id}")
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
async def test_delete_meal_not_found_returns_404(auth_client, diary):
|
||||
response = await auth_client.delete(f"/api/diary/{TODAY}/meal/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_meal_without_auth_returns_401(client, diary, meal):
|
||||
response = await client.delete(f"/api/diary/{TODAY}/meal/{meal.id}")
|
||||
assert response.status_code == 401
|
||||
36
fooder/view/diary.py
Normal file
36
fooder/view/diary.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from fooder.command.create_diary import create_diary
|
||||
from fooder.context import AuthContextDependency, Context
|
||||
from fooder.controller.diary import DiaryController
|
||||
from fooder.model.diary import DiaryCreateModel, DiaryModel, DiaryUpdateModel
|
||||
|
||||
router = APIRouter(tags=["diary"])
|
||||
|
||||
_auth_ctx = AuthContextDependency()
|
||||
|
||||
|
||||
@router.get("/{date}", response_model=DiaryModel)
|
||||
async def get_diary(date: datetime.date, ctx: Context = Depends(_auth_ctx)):
|
||||
return await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
|
||||
|
||||
@router.patch("/{date}", response_model=DiaryModel)
|
||||
async def update_diary(
|
||||
date: datetime.date,
|
||||
data: DiaryUpdateModel,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
await DiaryController(ctx, diary).update(data)
|
||||
return diary
|
||||
|
||||
|
||||
@router.post("", response_model=DiaryModel, status_code=201)
|
||||
async def create_diary_route(data: DiaryCreateModel, ctx: Context = Depends(_auth_ctx)):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await create_diary(ctx, date=data.date)
|
||||
return diary
|
||||
55
fooder/view/entry.py
Normal file
55
fooder/view/entry.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from fooder.command.create_entry import create_entry
|
||||
from fooder.context import AuthContextDependency, Context
|
||||
from fooder.controller.entry import EntryController
|
||||
from fooder.model.entry import EntryCreateModel, EntryModel, EntryUpdateModel
|
||||
|
||||
router = APIRouter(tags=["entry"])
|
||||
|
||||
_auth_ctx = AuthContextDependency()
|
||||
|
||||
|
||||
@router.post("", response_model=EntryModel, status_code=201)
|
||||
async def create_entry_route(
|
||||
date: datetime.date,
|
||||
meal_id: int,
|
||||
data: EntryCreateModel,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id)
|
||||
entry = await create_entry(ctx, meal_id=meal_id, data=data)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=EntryModel)
|
||||
async def update_entry(
|
||||
date: datetime.date,
|
||||
meal_id: int,
|
||||
entry_id: int,
|
||||
data: EntryUpdateModel,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id)
|
||||
entry = await ctx.repo.entry.get_by_id_and_meal(entry_id, meal_id)
|
||||
await EntryController(ctx, entry).update(data)
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
async def delete_entry(
|
||||
date: datetime.date,
|
||||
meal_id: int,
|
||||
entry_id: int,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id)
|
||||
await ctx.repo.entry.delete_by_id_and_meal(entry_id, meal_id)
|
||||
49
fooder/view/meal.py
Normal file
49
fooder/view/meal.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from fooder.context import AuthContextDependency, Context
|
||||
from fooder.controller.meal import MealController
|
||||
from fooder.model.meal import MealCreateModel, MealModel, MealUpdateModel
|
||||
|
||||
router = APIRouter(tags=["meal"])
|
||||
|
||||
_auth_ctx = AuthContextDependency()
|
||||
|
||||
|
||||
@router.post("", response_model=MealModel, status_code=201)
|
||||
async def create_meal(
|
||||
date: datetime.date,
|
||||
data: MealCreateModel,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
ctrl = await MealController.create(ctx, diary_id=diary.id, data=data)
|
||||
return ctrl.obj
|
||||
|
||||
|
||||
@router.patch("/{meal_id}", response_model=MealModel)
|
||||
async def update_meal(
|
||||
date: datetime.date,
|
||||
meal_id: int,
|
||||
data: MealUpdateModel,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
meal = await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id)
|
||||
await MealController(ctx, meal).update(data)
|
||||
return meal
|
||||
|
||||
|
||||
@router.delete("/{meal_id}", status_code=204)
|
||||
async def delete_meal(
|
||||
date: datetime.date,
|
||||
meal_id: int,
|
||||
ctx: Context = Depends(_auth_ctx),
|
||||
):
|
||||
async with ctx.repo.transaction():
|
||||
diary = await ctx.repo.diary.get_by_user_and_date(ctx.user.id, date)
|
||||
await ctx.repo.meal.get_by_id_and_diary(meal_id, diary.id)
|
||||
await ctx.repo.meal.delete_by_id_and_diary(meal_id, diary.id)
|
||||
Loading…
Reference in a new issue