[preset] implement
This commit is contained in:
parent
50b49c8bb7
commit
705baa2857
18 changed files with 533 additions and 7 deletions
30
Makefile
Normal file
30
Makefile
Normal 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
|
|
@ -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])
|
||||
|
|
16
fooder/controller/preset.py
Normal file
16
fooder/controller/preset.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
104
fooder/domain/preset.py
Normal file
104
fooder/domain/preset.py
Normal 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)
|
88
fooder/domain/preset_entry.py
Normal file
88
fooder/domain/preset_entry.py
Normal 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")
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
30
fooder/model/preset.py
Normal file
30
fooder/model/preset.py
Normal 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]
|
21
fooder/model/preset_entry.py
Normal file
21
fooder/model/preset_entry.py
Normal 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
|
|
@ -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"])
|
||||
|
|
23
fooder/test/fixtures/meal.py
vendored
23
fooder/test/fixtures/meal.py
vendored
|
@ -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
|
||||
|
|
69
fooder/test/test_preset.py
Normal file
69
fooder/test/test_preset.py
Normal 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}"
|
|
@ -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)
|
||||
|
|
19
fooder/view/preset.py
Normal file
19
fooder/view/preset.py
Normal 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
8
requirements_local.txt
Normal file
|
@ -0,0 +1,8 @@
|
|||
fastapi
|
||||
pydantic
|
||||
pydantic_settings
|
||||
sqlalchemy[postgresql_asyncpg]
|
||||
uvicorn[standard]
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
fastapi-users
|
Loading…
Reference in a new issue