[diary] most of the logic implemented + changed imports to absolute

This commit is contained in:
Piotr Domański 2026-04-07 18:14:20 +02:00
parent 45c0a91e1e
commit f2dd9bfea4
35 changed files with 822 additions and 31 deletions

View file

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

View 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

View 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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
from typing import Annotated
from .base import (
from fooder.model.base import (
ObjModelMixin,
Calories,
)

View file

@ -1,4 +1,4 @@
from .base import ObjModelMixin, Macronutrient, Calories
from fooder.model.base import ObjModelMixin, Macronutrient, Calories
from pydantic import BaseModel

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

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
from fooder.domain import User
from .base import RepositoryBase
from fooder.repository.base import RepositoryBase
class UserRepository(RepositoryBase[User]):

View file

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

View file

@ -1,5 +1,5 @@
from fooder.domain import UserSettings
from .base import RepositoryBase
from fooder.repository.base import RepositoryBase
class UserSettingsRepository(RepositoryBase[UserSettings]):

View file

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

View file

@ -14,4 +14,4 @@ os.environ.update(
}
)
from .fixtures import *
from fooder.test.fixtures import *

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import pytest
from ..fixtures.db import TestModel
from fooder.test.fixtures.db import TestModel
from fooder.exc import NotFound
# ------------------------------------------------------------------ create ---

View 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

View 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

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