diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..08f559c --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +VERSION=0.`git rev-list --count HEAD` +.PHONY: black + + +black: + black . + +.PHONY: mypy +mypy: + mypy . + +.PHONY: flake +flake: + flake8 . + +.PHONY: lint +lint: black mypy flake + +.PHONY: version +version: + @echo $(VERSION) + +.PHONY: create-venv +create-venv: + python3 -m venv .venv --prompt="fooderapi-venv" --system-site-packages + bash -c "source .venv/bin/activate && pip install -r requirements_local.txt" + +.PHONY: test +test: + ./test.sh diff --git a/fooder/controller/meal.py b/fooder/controller/meal.py index b43254d..b12cda8 100644 --- a/fooder/controller/meal.py +++ b/fooder/controller/meal.py @@ -1,9 +1,14 @@ -from typing import AsyncIterator from fastapi import HTTPException -from ..model.meal import Meal, CreateMealPayload +from ..model.meal import ( + Meal, + CreateMealPayload, + SaveMealPayload, + CreateMealFromPresetPayload, +) from ..domain.meal import Meal as DBMeal from ..domain.diary import Diary as DBDiary +from ..domain.preset import Preset as DBPreset from .base import AuthorizedController @@ -22,3 +27,40 @@ class CreateMeal(AuthorizedController): return Meal.from_orm(meal) except AssertionError as e: raise HTTPException(status_code=400, detail=e.args[0]) + + +class SaveMeal(AuthorizedController): + async def call(self, meal_id: id, payload: SaveMealPayload) -> None: + async with self.async_session.begin() as session: + meal = await DBMeal.get_by_id(session, self.user.id, meal_id) + if meal is None: + raise HTTPException(status_code=404, detail="meal not found") + + try: + await DBPreset.create( + session, user_id=self.user.id, name=payload.name, meal=meal + ) + except AssertionError as e: + raise HTTPException(status_code=400, detail=e.args[0]) + + +class CreateMealFromPreset(AuthorizedController): + async def call(self, content: CreateMealFromPresetPayload) -> Meal: + async with self.async_session.begin() as session: + if not await DBDiary.has_permission( + session, self.user.id, content.diary_id + ): + raise HTTPException(status_code=404, detail="diary not found") + + preset = await DBPreset.get(session, self.user.id, content.preset_id) + + if preset is None: + raise HTTPException(status_code=404, detail="preset not found") + + try: + meal = await DBMeal.create_from_preset( + session, content.diary_id, content.order, content.name, preset + ) + return Meal.from_orm(meal) + except AssertionError as e: + raise HTTPException(status_code=400, detail=e.args[0]) diff --git a/fooder/controller/preset.py b/fooder/controller/preset.py new file mode 100644 index 0000000..0dbf0b7 --- /dev/null +++ b/fooder/controller/preset.py @@ -0,0 +1,16 @@ +from typing import AsyncIterator, Optional + +from ..model.preset import Preset +from ..domain.preset import Preset as DBPreset +from .base import AuthorizedController + + +class ListPresets(AuthorizedController): + async def call( + self, limit: int, offset: int, q: Optional[str] + ) -> AsyncIterator[Preset]: + async with self.async_session() as session: + async for preset in DBPreset.list_all( + session, limit=limit, offset=offset, q=q + ): + yield Preset.from_orm(preset) diff --git a/fooder/domain/__init__.py b/fooder/domain/__init__.py index c9e10a6..09b4eb6 100644 --- a/fooder/domain/__init__.py +++ b/fooder/domain/__init__.py @@ -5,3 +5,5 @@ from .meal import Meal from .product import Product from .user import User from .token import RefreshToken +from .preset import Preset +from .preset_entry import PresetEntry diff --git a/fooder/domain/entry.py b/fooder/domain/entry.py index 2a91dc2..ef877c3 100644 --- a/fooder/domain/entry.py +++ b/fooder/domain/entry.py @@ -107,14 +107,14 @@ class Entry(Base, CommonMixin): if meal_id is not None: self.meal_id = meal_id try: - session.flush() + await session.flush() except IntegrityError: raise AssertionError("meal does not exist") if product_id is not None: self.product_id = product_id try: - session.flush() + await session.flush() except IntegrityError: raise AssertionError("product does not exist") diff --git a/fooder/domain/meal.py b/fooder/domain/meal.py index 0e262eb..712cb87 100644 --- a/fooder/domain/meal.py +++ b/fooder/domain/meal.py @@ -7,6 +7,7 @@ from typing import Optional from .base import Base, CommonMixin from .entry import Entry +from .preset import Preset class Meal(Base, CommonMixin): @@ -87,6 +88,39 @@ class Meal(Base, CommonMixin): raise RuntimeError() return meal + @classmethod + async def create_from_preset( + cls, + session: AsyncSession, + diary_id: int, + order: int, + name: Optional[str], + preset: Preset, + ) -> "Meal": + # check if order already exists in diary + query = select(cls).where(cls.diary_id == diary_id).where(cls.order == order) + existing_meal = await session.scalar(query) + assert existing_meal is None, "order already exists in diary" + + if name is None: + name = preset.name or f"Meal {order}" + + meal = Meal(diary_id=diary_id, name=name, order=order) + session.add(meal) + + try: + await session.flush() + except IntegrityError: + raise AssertionError("diary does not exist") + + for entry in preset.entries: + await Entry.create(session, meal.id, entry.product_id, entry.grams) + + meal = await cls._get_by_id(session, meal.id) + if not meal: + raise RuntimeError() + return meal + @classmethod async def _get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]": """get_by_id.""" diff --git a/fooder/domain/preset.py b/fooder/domain/preset.py new file mode 100644 index 0000000..6a1227e --- /dev/null +++ b/fooder/domain/preset.py @@ -0,0 +1,104 @@ +from sqlalchemy.orm import relationship, Mapped, mapped_column, joinedload +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import ForeignKey, Integer, select + +from .base import Base, CommonMixin +from .preset_entry import PresetEntry +from typing import AsyncIterator, Optional, TYPE_CHECKING + + +if TYPE_CHECKING: + from .meal import Meal + + +class Preset(Base, CommonMixin): + """Preset.""" + + name: Mapped[str] + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id")) + entries: Mapped[list[PresetEntry]] = relationship( + lazy="selectin", order_by=PresetEntry.last_changed + ) + + @property + def calories(self) -> float: + """calories. + + :rtype: float + """ + return sum(entry.calories for entry in self.entries) + + @property + def protein(self) -> float: + """protein. + + :rtype: float + """ + return sum(entry.protein for entry in self.entries) + + @property + def carb(self) -> float: + """carb. + + :rtype: float + """ + return sum(entry.carb for entry in self.entries) + + @property + def fat(self) -> float: + """fat. + + :rtype: float + """ + return sum(entry.fat for entry in self.entries) + + @property + def fiber(self) -> float: + """fiber. + + :rtype: float + """ + return sum(entry.fiber for entry in self.entries) + + @classmethod + async def create( + cls, session: AsyncSession, user_id: int, name: str, meal: "Meal" + ) -> None: + preset = Preset(user_id=user_id, name=name) + + session.add(preset) + + try: + await session.flush() + except Exception: + raise RuntimeError() + + for entry in meal.entries: + PresetEntry.create(session, preset.id, entry) + + @classmethod + async def list_all( + cls, session: AsyncSession, offset: int, limit: int, q: Optional[str] = None + ) -> AsyncIterator["Preset"]: + query = select(cls) + + if q: + query = query.filter(cls.name.ilike(f"%{q.lower()}%")) + + query = query.offset(offset).limit(limit) + stream = await session.stream_scalars(query.order_by(cls.id)) + async for row in stream: + yield row + + @classmethod + async def get( + cls, session: AsyncSession, user_id: int, preset_id: int + ) -> "Optional[Preset]": + """get.""" + query = ( + select(cls) + .where(cls.id == preset_id) + .where(cls.user_id == user_id) + .options(joinedload(cls.entries).joinedload(PresetEntry.product)) + ) + return await session.scalar(query) diff --git a/fooder/domain/preset_entry.py b/fooder/domain/preset_entry.py new file mode 100644 index 0000000..741f29f --- /dev/null +++ b/fooder/domain/preset_entry.py @@ -0,0 +1,88 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError +from sqlalchemy import ForeignKey, Integer, DateTime +from datetime import datetime + +from .base import Base, CommonMixin +from .product import Product +from .entry import Entry + + +class PresetEntry(Base, CommonMixin): + """Entry.""" + + grams: Mapped[float] + product_id: Mapped[int] = mapped_column(Integer, ForeignKey("product.id")) + product: Mapped[Product] = relationship(lazy="selectin") + preset_id: Mapped[int] = mapped_column(Integer, ForeignKey("preset.id")) + last_changed: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + @property + def amount(self) -> float: + """amount. + + :rtype: float + """ + return self.grams / 100 + + @property + def calories(self) -> float: + """calories. + + :rtype: float + """ + return self.amount * self.product.calories + + @property + def protein(self) -> float: + """protein. + + :rtype: float + """ + return self.amount * self.product.protein + + @property + def carb(self) -> float: + """carb. + + :rtype: float + """ + return self.amount * self.product.carb + + @property + def fat(self) -> float: + """fat. + + :rtype: float + """ + return self.amount * self.product.fat + + @property + def fiber(self) -> float: + """fiber. + + :rtype: float + """ + return self.amount * self.product.fiber + + @classmethod + async def create( + self, + session: AsyncSession, + preset_id: int, + entry: Entry, + ) -> None: + pentry = PresetEntry( + preset_id=preset_id, + product_id=entry.product_id, + grams=entry.grams, + ) + session.add(pentry) + + try: + await session.flush() + except IntegrityError: + raise AssertionError("preset or product does not exist") diff --git a/fooder/domain/product.py b/fooder/domain/product.py index ce3f61c..15351fe 100644 --- a/fooder/domain/product.py +++ b/fooder/domain/product.py @@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from typing import AsyncIterator, Optional from .base import Base, CommonMixin -from .user import User class Product(Base, CommonMixin): diff --git a/fooder/model/meal.py b/fooder/model/meal.py index cfdda00..739db4f 100644 --- a/fooder/model/meal.py +++ b/fooder/model/meal.py @@ -27,3 +27,18 @@ class CreateMealPayload(BaseModel): name: Optional[str] order: int diary_id: int + + +class SaveMealPayload(BaseModel): + """SaveMealPayload.""" + + name: Optional[str] + + +class CreateMealFromPresetPayload(BaseModel): + """CreateMealPayload.""" + + name: Optional[str] + order: int + diary_id: int + preset_id: int diff --git a/fooder/model/preset.py b/fooder/model/preset.py new file mode 100644 index 0000000..eaa12a8 --- /dev/null +++ b/fooder/model/preset.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel +from typing import List +from .preset_entry import PresetEntry + + +class Preset(BaseModel): + """Preset.""" + + id: int + name: str + calories: float + protein: float + carb: float + fat: float + fiber: float + + class Config: + from_attributes = True + + +class PresetDetails(Preset): + """PresetDetails.""" + + entries: List[PresetEntry] + + +class ListPresetsPayload(BaseModel): + """ListPresetsPayload.""" + + presets: List[Preset] diff --git a/fooder/model/preset_entry.py b/fooder/model/preset_entry.py new file mode 100644 index 0000000..8d2ab93 --- /dev/null +++ b/fooder/model/preset_entry.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from typing import Optional + +from .product import Product + + +class PresetEntry(BaseModel): + """PresetEntry.""" + + id: int + grams: float + product: Product + preset_id: int + calories: float + protein: float + carb: float + fat: float + fiber: float + + class Config: + from_attributes = True diff --git a/fooder/router.py b/fooder/router.py index 744aed5..7dcdeb4 100644 --- a/fooder/router.py +++ b/fooder/router.py @@ -5,6 +5,7 @@ from .view.meal import router as meal_router from .view.entry import router as entry_router from .view.token import router as token_router from .view.user import router as user_router +from .view.preset import router as preset_router router = APIRouter(prefix="/api") @@ -14,3 +15,4 @@ router.include_router(meal_router, prefix="/meal", tags=["meal"]) router.include_router(entry_router, prefix="/entry", tags=["entry"]) router.include_router(token_router, prefix="/token", tags=["token"]) router.include_router(user_router, prefix="/user", tags=["user"]) +router.include_router(preset_router, prefix="/preset", tags=["preset"]) diff --git a/fooder/test/fixtures/meal.py b/fooder/test/fixtures/meal.py index 44fe769..98d98be 100644 --- a/fooder/test/fixtures/meal.py +++ b/fooder/test/fixtures/meal.py @@ -12,3 +12,26 @@ def meal_payload_factory() -> Callable[[int, int], dict[str, int | str]]: } return factory + + +@pytest.fixture +def meal_save_payload() -> Callable[[int], dict[str, str]]: + def factory(meal_id: int) -> dict[str, str]: + return { + "name": "new name", + } + + return factory + + +@pytest.fixture +def meal_from_preset() -> Callable[[int, int, int], dict[str, str | int]]: + def factory(order: int, diary_id: int, preset_id: int) -> dict[str, str | int]: + return { + "name": "new name", + "order": order, + "diary_id": diary_id, + "preset_id": preset_id, + } + + return factory diff --git a/fooder/test/test_preset.py b/fooder/test/test_preset.py new file mode 100644 index 0000000..de02222 --- /dev/null +++ b/fooder/test/test_preset.py @@ -0,0 +1,69 @@ +import datetime +import pytest + + +@pytest.mark.dependency() +def test_create_meal(client, meal_payload_factory): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + + diary_id = response.json()["id"] + meal_order = len(response.json()["meals"]) + 1 + + response = client.post("meal", json=meal_payload_factory(diary_id, meal_order)) + assert response.status_code == 200, response.json() + + +@pytest.mark.dependency(depends=["test_create_meal"]) +def test_save_meal(client, meal_save_payload): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + + meal_id = response.json()["meals"][0]["id"] + save_payload = meal_save_payload(meal_id) + + response = client.post(f"meal/{meal_id}/save", json=save_payload) + assert response.status_code == 200, response.json() + + +@pytest.mark.dependency(depends=["test_create_meal"]) +def test_list_presets(client, meal_save_payload): + response = client.get("preset") + assert response.status_code == 200, response.json() + assert len(response.json()["presets"]) > 0, response.json() + + name = meal_save_payload(0)["name"] + response = client.get(f"preset?q={name}") + assert response.status_code == 200, response.json() + assert len(response.json()["presets"]) > 0, response.json() + + +@pytest.mark.dependency(depends=["test_list_presets"]) +def test_create_meal_from_preset(client, meal_from_preset): + today = datetime.date.today().isoformat() + response = client.get("diary", params={"date": today}) + + diary_id = response.json()["id"] + meal_order = len(response.json()["meals"]) + 1 + + response = client.get("preset") + assert response.status_code == 200, response.json() + assert len(response.json()["presets"]) > 0, response.json() + + preset = response.json()["presets"][0] + + payload = meal_from_preset( + meal_order, + diary_id, + preset["id"], + ) + + response = client.post("meal/from_preset", json=payload) + assert response.status_code == 200, response.json() + meal = response.json() + + for k, v in preset.items(): + if k in ("id", "name", "entries"): + continue + + assert meal[k] == v, f"{k} != {v}" diff --git a/fooder/view/meal.py b/fooder/view/meal.py index 864c9cb..13c0507 100644 --- a/fooder/view/meal.py +++ b/fooder/view/meal.py @@ -1,6 +1,11 @@ from fastapi import APIRouter, Depends, Request -from ..model.meal import Meal, CreateMealPayload -from ..controller.meal import CreateMeal +from ..model.meal import ( + Meal, + CreateMealPayload, + SaveMealPayload, + CreateMealFromPresetPayload, +) +from ..controller.meal import CreateMeal, SaveMeal, CreateMealFromPreset router = APIRouter(tags=["meal"]) @@ -13,3 +18,22 @@ async def create_meal( contoller: CreateMeal = Depends(CreateMeal), ): return await contoller.call(data) + + +@router.post("/{meal_id}/save") +async def save_meal( + request: Request, + meal_id: int, + data: SaveMealPayload, + contoller: SaveMeal = Depends(SaveMeal), +): + await contoller.call(meal_id, data) + + +@router.post("/from_preset", response_model=Meal) +async def create_meal_from_preset( + request: Request, + data: CreateMealFromPresetPayload, + contoller: CreateMealFromPreset = Depends(CreateMealFromPreset), +): + return await contoller.call(data) diff --git a/fooder/view/preset.py b/fooder/view/preset.py new file mode 100644 index 0000000..d645279 --- /dev/null +++ b/fooder/view/preset.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, Request +from ..model.preset import ListPresetsPayload +from ..controller.preset import ListPresets + + +router = APIRouter(tags=["preset"]) + + +@router.get("", response_model=ListPresetsPayload) +async def list_presets( + request: Request, + limit: int = 10, + offset: int = 0, + q: str | None = None, + controller: ListPresets = Depends(ListPresets), +): + return ListPresetsPayload( + presets=[p async for p in controller.call(limit=limit, offset=offset, q=q)] + ) diff --git a/requirements_local.txt b/requirements_local.txt new file mode 100644 index 0000000..80f1e13 --- /dev/null +++ b/requirements_local.txt @@ -0,0 +1,8 @@ +fastapi +pydantic +pydantic_settings +sqlalchemy[postgresql_asyncpg] +uvicorn[standard] +python-jose[cryptography] +passlib[bcrypt] +fastapi-users