[preset] implement

This commit is contained in:
doman 2023-10-27 15:12:48 +02:00
parent 50b49c8bb7
commit 705baa2857
18 changed files with 533 additions and 7 deletions

30
Makefile Normal file
View file

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

View file

@ -1,9 +1,14 @@
from typing import AsyncIterator
from fastapi import HTTPException 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.meal import Meal as DBMeal
from ..domain.diary import Diary as DBDiary from ..domain.diary import Diary as DBDiary
from ..domain.preset import Preset as DBPreset
from .base import AuthorizedController from .base import AuthorizedController
@ -22,3 +27,40 @@ class CreateMeal(AuthorizedController):
return Meal.from_orm(meal) return Meal.from_orm(meal)
except AssertionError as e: except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0]) 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])

View file

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

View file

@ -5,3 +5,5 @@ from .meal import Meal
from .product import Product from .product import Product
from .user import User from .user import User
from .token import RefreshToken from .token import RefreshToken
from .preset import Preset
from .preset_entry import PresetEntry

View file

@ -107,14 +107,14 @@ class Entry(Base, CommonMixin):
if meal_id is not None: if meal_id is not None:
self.meal_id = meal_id self.meal_id = meal_id
try: try:
session.flush() await session.flush()
except IntegrityError: except IntegrityError:
raise AssertionError("meal does not exist") raise AssertionError("meal does not exist")
if product_id is not None: if product_id is not None:
self.product_id = product_id self.product_id = product_id
try: try:
session.flush() await session.flush()
except IntegrityError: except IntegrityError:
raise AssertionError("product does not exist") raise AssertionError("product does not exist")

View file

@ -7,6 +7,7 @@ from typing import Optional
from .base import Base, CommonMixin from .base import Base, CommonMixin
from .entry import Entry from .entry import Entry
from .preset import Preset
class Meal(Base, CommonMixin): class Meal(Base, CommonMixin):
@ -87,6 +88,39 @@ class Meal(Base, CommonMixin):
raise RuntimeError() raise RuntimeError()
return meal 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 @classmethod
async def _get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]": async def _get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]":
"""get_by_id.""" """get_by_id."""

104
fooder/domain/preset.py Normal file
View file

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

View file

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

View file

@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from typing import AsyncIterator, Optional from typing import AsyncIterator, Optional
from .base import Base, CommonMixin from .base import Base, CommonMixin
from .user import User
class Product(Base, CommonMixin): class Product(Base, CommonMixin):

View file

@ -27,3 +27,18 @@ class CreateMealPayload(BaseModel):
name: Optional[str] name: Optional[str]
order: int order: int
diary_id: 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

30
fooder/model/preset.py Normal file
View file

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

View file

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

View file

@ -5,6 +5,7 @@ from .view.meal import router as meal_router
from .view.entry import router as entry_router from .view.entry import router as entry_router
from .view.token import router as token_router from .view.token import router as token_router
from .view.user import router as user_router from .view.user import router as user_router
from .view.preset import router as preset_router
router = APIRouter(prefix="/api") 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(entry_router, prefix="/entry", tags=["entry"])
router.include_router(token_router, prefix="/token", tags=["token"]) router.include_router(token_router, prefix="/token", tags=["token"])
router.include_router(user_router, prefix="/user", tags=["user"]) router.include_router(user_router, prefix="/user", tags=["user"])
router.include_router(preset_router, prefix="/preset", tags=["preset"])

View file

@ -12,3 +12,26 @@ def meal_payload_factory() -> Callable[[int, int], dict[str, int | str]]:
} }
return factory 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

View file

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

View file

@ -1,6 +1,11 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from ..model.meal import Meal, CreateMealPayload from ..model.meal import (
from ..controller.meal import CreateMeal Meal,
CreateMealPayload,
SaveMealPayload,
CreateMealFromPresetPayload,
)
from ..controller.meal import CreateMeal, SaveMeal, CreateMealFromPreset
router = APIRouter(tags=["meal"]) router = APIRouter(tags=["meal"])
@ -13,3 +18,22 @@ async def create_meal(
contoller: CreateMeal = Depends(CreateMeal), contoller: CreateMeal = Depends(CreateMeal),
): ):
return await contoller.call(data) 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)

19
fooder/view/preset.py Normal file
View file

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

8
requirements_local.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi
pydantic
pydantic_settings
sqlalchemy[postgresql_asyncpg]
uvicorn[standard]
python-jose[cryptography]
passlib[bcrypt]
fastapi-users