diff --git a/fooder/controller/user_settings.py b/fooder/controller/user_settings.py new file mode 100644 index 0000000..cff3c1e --- /dev/null +++ b/fooder/controller/user_settings.py @@ -0,0 +1,19 @@ +from fooder.controller.base import ModelController +from fooder.domain import UserSettings +from fooder.model.user_settings import UserSettingsUpdateModel + + +class UserSettingsController(ModelController[UserSettings]): + async def update(self, data: UserSettingsUpdateModel) -> 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.user_settings.update(self.obj) diff --git a/fooder/model/user_settings.py b/fooder/model/user_settings.py new file mode 100644 index 0000000..830b7b5 --- /dev/null +++ b/fooder/model/user_settings.py @@ -0,0 +1,18 @@ +from .base import ObjModelMixin, Calories, OptionalCalories +from pydantic import BaseModel + + +class UserSettingsModel(ObjModelMixin, BaseModel): + protein_goal: Calories + carb_goal: Calories + fat_goal: Calories + fiber_goal: Calories + calories_goal: Calories + + +class UserSettingsUpdateModel(BaseModel): + protein_goal: OptionalCalories = None + carb_goal: OptionalCalories = None + fat_goal: OptionalCalories = None + fiber_goal: OptionalCalories = None + calories_goal: OptionalCalories = None diff --git a/fooder/repository/repository.py b/fooder/repository/repository.py index a3b5b05..f2fbc5d 100644 --- a/fooder/repository/repository.py +++ b/fooder/repository/repository.py @@ -6,7 +6,8 @@ from sqlalchemy.exc import IntegrityError from fooder.repository.user import UserRepository from fooder.repository.product import ProductRepository from fooder.repository.user_product_usage import UserProductUsageRepository -from fooder.domain import User, Product, UserProductUsage +from fooder.repository.user_settings import UserSettingsRepository +from fooder.domain import User, Product, UserProductUsage, UserSettings from fooder.exc import Conflict @@ -16,6 +17,7 @@ class Repository: self.user = UserRepository(User, session) self.product = ProductRepository(Product, session) self.user_product_usage = UserProductUsageRepository(UserProductUsage, session) + self.user_settings = UserSettingsRepository(UserSettings, session) async def commit(self) -> None: try: diff --git a/fooder/repository/user_settings.py b/fooder/repository/user_settings.py new file mode 100644 index 0000000..867dc48 --- /dev/null +++ b/fooder/repository/user_settings.py @@ -0,0 +1,7 @@ +from fooder.domain import UserSettings +from .base import RepositoryBase + + +class UserSettingsRepository(RepositoryBase[UserSettings]): + async def get_by_user_id(self, user_id: int) -> UserSettings: + return await self._get(UserSettings.user_id == user_id) diff --git a/fooder/router.py b/fooder/router.py index 058ddb7..a002c04 100644 --- a/fooder/router.py +++ b/fooder/router.py @@ -2,7 +2,11 @@ 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 router = APIRouter(prefix="/api") router.include_router(token_router, prefix="/token", tags=["token"]) router.include_router(product_router, prefix="/product", tags=["product"]) +router.include_router( + user_settings_router, prefix="/user/settings", tags=["user_settings"] +) diff --git a/fooder/test/fixtures/__init__.py b/fooder/test/fixtures/__init__.py index 09a0005..9c6c331 100644 --- a/fooder/test/fixtures/__init__.py +++ b/fooder/test/fixtures/__init__.py @@ -5,6 +5,7 @@ from .user import * from .client import * from .context import * from .product import * +from .user_settings import * @pytest.fixture diff --git a/fooder/test/fixtures/user_settings.py b/fooder/test/fixtures/user_settings.py new file mode 100644 index 0000000..0035a5a --- /dev/null +++ b/fooder/test/fixtures/user_settings.py @@ -0,0 +1,17 @@ +import pytest_asyncio + +from fooder.domain.user_settings import UserSettings + + +@pytest_asyncio.fixture +async def user_settings(ctx, user): + settings = UserSettings( + user_id=user.id, + protein_goal=150.0, + carb_goal=200.0, + fat_goal=70.0, + fiber_goal=30.0, + calories_goal=2000.0, + ) + await ctx.repo.user_settings.create(settings) + return settings diff --git a/fooder/test/view/test_user_settings.py b/fooder/test/view/test_user_settings.py new file mode 100644 index 0000000..9decf87 --- /dev/null +++ b/fooder/test/view/test_user_settings.py @@ -0,0 +1,80 @@ +async def test_get_user_settings_returns_200(auth_client, user_settings): + response = await auth_client.get("/api/user/settings") + assert response.status_code == 200 + + +async def test_get_user_settings_returns_correct_fields(auth_client, user_settings): + response = await auth_client.get("/api/user/settings") + 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 + assert body["id"] == user_settings.id + + +async def test_get_user_settings_not_found_returns_404(auth_client): + response = await auth_client.get("/api/user/settings") + assert response.status_code == 404 + + +async def test_get_user_settings_without_auth_returns_401(client): + response = await client.get("/api/user/settings") + assert response.status_code == 401 + + +async def test_update_user_settings_returns_200(auth_client, user_settings): + response = await auth_client.patch( + "/api/user/settings", json={"protein_goal": 180.0} + ) + assert response.status_code == 200 + + +async def test_update_user_settings_partial_update(auth_client, user_settings): + response = await auth_client.patch( + "/api/user/settings", json={"protein_goal": 180.0} + ) + body = response.json() + assert body["protein_goal"] == 180.0 + assert body["carb_goal"] == user_settings.carb_goal + assert body["fat_goal"] == user_settings.fat_goal + assert body["calories_goal"] == user_settings.calories_goal + + +async def test_update_user_settings_all_fields(auth_client, user_settings): + 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("/api/user/settings", 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_user_settings_not_found_returns_404(auth_client): + response = await auth_client.patch( + "/api/user/settings", json={"protein_goal": 180.0} + ) + assert response.status_code == 404 + + +async def test_update_user_settings_negative_value_returns_422( + auth_client, user_settings +): + response = await auth_client.patch( + "/api/user/settings", json={"protein_goal": -10.0} + ) + assert response.status_code == 422 + + +async def test_update_user_settings_without_auth_returns_401(client): + response = await client.patch("/api/user/settings", json={"protein_goal": 180.0}) + assert response.status_code == 401 diff --git a/fooder/view/user_settings.py b/fooder/view/user_settings.py new file mode 100644 index 0000000..1544ed7 --- /dev/null +++ b/fooder/view/user_settings.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends + +from fooder.context import Context, AuthContextDependency +from fooder.controller.user_settings import UserSettingsController +from fooder.model.user_settings import UserSettingsModel, UserSettingsUpdateModel + +router = APIRouter(tags=["user_settings"]) + +_auth_ctx = AuthContextDependency() + + +@router.get("", response_model=UserSettingsModel) +async def get_user_settings( + ctx: Context = Depends(_auth_ctx), +): + return await ctx.repo.user_settings.get_by_user_id(ctx.user.id) + + +@router.patch("", response_model=UserSettingsModel) +async def update_user_settings( + data: UserSettingsUpdateModel, + ctx: Context = Depends(_auth_ctx), +): + async with ctx.repo.transaction(): + settings = await ctx.repo.user_settings.get_by_user_id(ctx.user.id) + await UserSettingsController(ctx, settings).update(data) + return settings