diff --git a/fooder/__main__.py b/fooder/__main__.py index 05fb204..596e0db 100644 --- a/fooder/__main__.py +++ b/fooder/__main__.py @@ -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", "") diff --git a/fooder/command/create_diary.py b/fooder/command/create_diary.py new file mode 100644 index 0000000..e9092d0 --- /dev/null +++ b/fooder/command/create_diary.py @@ -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 diff --git a/fooder/command/create_entry.py b/fooder/command/create_entry.py new file mode 100644 index 0000000..d8f18a6 --- /dev/null +++ b/fooder/command/create_entry.py @@ -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 diff --git a/fooder/controller/diary.py b/fooder/controller/diary.py new file mode 100644 index 0000000..9b29803 --- /dev/null +++ b/fooder/controller/diary.py @@ -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) diff --git a/fooder/controller/entry.py b/fooder/controller/entry.py new file mode 100644 index 0000000..a01594d --- /dev/null +++ b/fooder/controller/entry.py @@ -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) diff --git a/fooder/controller/meal.py b/fooder/controller/meal.py new file mode 100644 index 0000000..8d11f7b --- /dev/null +++ b/fooder/controller/meal.py @@ -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) diff --git a/fooder/domain/__init__.py b/fooder/domain/__init__.py index 39fc3a5..e45cced 100644 --- a/fooder/domain/__init__.py +++ b/fooder/domain/__init__.py @@ -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 diff --git a/fooder/domain/diary.py b/fooder/domain/diary.py index 7c4517a..198f3c2 100644 --- a/fooder/domain/diary.py +++ b/fooder/domain/diary.py @@ -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")) diff --git a/fooder/domain/entry.py b/fooder/domain/entry.py index bc3b5c3..ae20337 100644 --- a/fooder/domain/entry.py +++ b/fooder/domain/entry.py @@ -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) diff --git a/fooder/domain/meal.py b/fooder/domain/meal.py index a76cd75..28ecad3 100644 --- a/fooder/domain/meal.py +++ b/fooder/domain/meal.py @@ -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" ) diff --git a/fooder/model/diary.py b/fooder/model/diary.py new file mode 100644 index 0000000..9c5facf --- /dev/null +++ b/fooder/model/diary.py @@ -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 diff --git a/fooder/model/entry.py b/fooder/model/entry.py new file mode 100644 index 0000000..bfb41e3 --- /dev/null +++ b/fooder/model/entry.py @@ -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 diff --git a/fooder/model/meal.py b/fooder/model/meal.py new file mode 100644 index 0000000..494ac71 --- /dev/null +++ b/fooder/model/meal.py @@ -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 diff --git a/fooder/model/product.py b/fooder/model/product.py index a960958..1a58396 100644 --- a/fooder/model/product.py +++ b/fooder/model/product.py @@ -1,5 +1,5 @@ from typing import Annotated -from .base import ( +from fooder.model.base import ( ObjModelMixin, Calories, ) diff --git a/fooder/model/user_settings.py b/fooder/model/user_settings.py index f69eb98..32cffd8 100644 --- a/fooder/model/user_settings.py +++ b/fooder/model/user_settings.py @@ -1,4 +1,4 @@ -from .base import ObjModelMixin, Macronutrient, Calories +from fooder.model.base import ObjModelMixin, Macronutrient, Calories from pydantic import BaseModel diff --git a/fooder/repository/diary.py b/fooder/repository/diary.py new file mode 100644 index 0000000..ebea707 --- /dev/null +++ b/fooder/repository/diary.py @@ -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) diff --git a/fooder/repository/entry.py b/fooder/repository/entry.py new file mode 100644 index 0000000..3fbdc6b --- /dev/null +++ b/fooder/repository/entry.py @@ -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) diff --git a/fooder/repository/meal.py b/fooder/repository/meal.py new file mode 100644 index 0000000..e179586 --- /dev/null +++ b/fooder/repository/meal.py @@ -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 diff --git a/fooder/repository/product.py b/fooder/repository/product.py index 3a13f6a..078a3f4 100644 --- a/fooder/repository/product.py +++ b/fooder/repository/product.py @@ -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]): diff --git a/fooder/repository/repository.py b/fooder/repository/repository.py index f2fbc5d..cc6443c 100644 --- a/fooder/repository/repository.py +++ b/fooder/repository/repository.py @@ -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: diff --git a/fooder/repository/user.py b/fooder/repository/user.py index 4cba5a0..9a0e647 100644 --- a/fooder/repository/user.py +++ b/fooder/repository/user.py @@ -1,5 +1,5 @@ from fooder.domain import User -from .base import RepositoryBase +from fooder.repository.base import RepositoryBase class UserRepository(RepositoryBase[User]): diff --git a/fooder/repository/user_product_usage.py b/fooder/repository/user_product_usage.py index 0d4b284..0f3856b 100644 --- a/fooder/repository/user_product_usage.py +++ b/fooder/repository/user_product_usage.py @@ -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) diff --git a/fooder/repository/user_settings.py b/fooder/repository/user_settings.py index 867dc48..a19fdfe 100644 --- a/fooder/repository/user_settings.py +++ b/fooder/repository/user_settings.py @@ -1,5 +1,5 @@ from fooder.domain import UserSettings -from .base import RepositoryBase +from fooder.repository.base import RepositoryBase class UserSettingsRepository(RepositoryBase[UserSettings]): diff --git a/fooder/router.py b/fooder/router.py index a002c04..1e12fd8 100644 --- a/fooder/router.py +++ b/fooder/router.py @@ -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"] +) diff --git a/fooder/test/conftest.py b/fooder/test/conftest.py index 34336ed..531e7d1 100644 --- a/fooder/test/conftest.py +++ b/fooder/test/conftest.py @@ -14,4 +14,4 @@ os.environ.update( } ) -from .fixtures import * +from fooder.test.fixtures import * diff --git a/fooder/test/fixtures/__init__.py b/fooder/test/fixtures/__init__.py index 9c6c331..e6467f2 100644 --- a/fooder/test/fixtures/__init__.py +++ b/fooder/test/fixtures/__init__.py @@ -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 diff --git a/fooder/test/fixtures/context.py b/fooder/test/fixtures/context.py index a89b596..20bb99a 100644 --- a/fooder/test/fixtures/context.py +++ b/fooder/test/fixtures/context.py @@ -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 diff --git a/fooder/test/fixtures/diary.py b/fooder/test/fixtures/diary.py new file mode 100644 index 0000000..d9a9271 --- /dev/null +++ b/fooder/test/fixtures/diary.py @@ -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} diff --git a/fooder/test/repository/test_base.py b/fooder/test/repository/test_base.py index b5eb0ee..7f0bd66 100644 --- a/fooder/test/repository/test_base.py +++ b/fooder/test/repository/test_base.py @@ -1,5 +1,5 @@ import pytest -from ..fixtures.db import TestModel +from fooder.test.fixtures.db import TestModel from fooder.exc import NotFound # ------------------------------------------------------------------ create --- diff --git a/fooder/test/view/test_diary.py b/fooder/test/view/test_diary.py new file mode 100644 index 0000000..a308c2a --- /dev/null +++ b/fooder/test/view/test_diary.py @@ -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 diff --git a/fooder/test/view/test_entry.py b/fooder/test/view/test_entry.py new file mode 100644 index 0000000..007cc73 --- /dev/null +++ b/fooder/test/view/test_entry.py @@ -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 diff --git a/fooder/test/view/test_meal.py b/fooder/test/view/test_meal.py new file mode 100644 index 0000000..15a7f29 --- /dev/null +++ b/fooder/test/view/test_meal.py @@ -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 diff --git a/fooder/view/diary.py b/fooder/view/diary.py new file mode 100644 index 0000000..119a16d --- /dev/null +++ b/fooder/view/diary.py @@ -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 diff --git a/fooder/view/entry.py b/fooder/view/entry.py new file mode 100644 index 0000000..a48a805 --- /dev/null +++ b/fooder/view/entry.py @@ -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) diff --git a/fooder/view/meal.py b/fooder/view/meal.py new file mode 100644 index 0000000..e8ef43e --- /dev/null +++ b/fooder/view/meal.py @@ -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)