From 9a82a172cbe632bf0a2aba82c7d8b5bb38764be7 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Tue, 8 Feb 2022 14:55:18 -0900 Subject: [PATCH] update foods and units for multitenant support --- mealie/db/models/group/group.py | 4 + mealie/db/models/recipe/ingredient.py | 10 +++ mealie/repos/seed/seeders.py | 15 ++-- mealie/routes/unit_and_foods/foods.py | 10 ++- mealie/routes/unit_and_foods/units.py | 8 +- mealie/schema/recipe/recipe_ingredient.py | 8 ++ tests/fixtures/__init__.py | 1 + tests/fixtures/fixture_multitenant.py | 22 ++++++ tests/fixtures/fixture_users.py | 31 +++++++- .../multitenant_tests/test_ingredient_food.py | 79 +++++++++++++++++++ tests/utils/routes.py | 21 +++++ 11 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/fixture_multitenant.py create mode 100644 tests/multitenant_tests/test_ingredient_food.py create mode 100644 tests/utils/routes.py diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index 1aab61c2e76f..d2fc83975ad6 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -55,6 +55,10 @@ class Group(SqlAlchemyBase, BaseMixins): group_reports = orm.relationship("ReportModel", **common_args) group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args) + # Owned Models + ingredient_units = orm.relationship("IngredientUnitModel", **common_args) + ingredient_foods = orm.relationship("IngredientFoodModel", **common_args) + class Config: exclude = { "users", diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index cba0d63240b1..cf3cc7dacea0 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -9,6 +9,11 @@ from .._model_utils.guid import GUID class IngredientUnitModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_units" + + # ID Relationships + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) + group = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id]) + id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) @@ -23,6 +28,11 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientFoodModel(SqlAlchemyBase, BaseMixins): __tablename__ = "ingredient_foods" + + # ID Relationships + group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) + group = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) + id = Column(Integer, primary_key=True) name = Column(String) description = Column(String) diff --git a/mealie/repos/seed/seeders.py b/mealie/repos/seed/seeders.py index b235c024ab4b..6cc0ef504588 100644 --- a/mealie/repos/seed/seeders.py +++ b/mealie/repos/seed/seeders.py @@ -2,7 +2,7 @@ import json from typing import Generator from mealie.schema.labels import MultiPurposeLabelSave -from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit +from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit from ._abstract_seeder import AbstractSeeder @@ -27,10 +27,11 @@ class MultiPurposeLabelSeeder(AbstractSeeder): class IngredientUnitsSeeder(AbstractSeeder): - def load_data(self) -> Generator[CreateIngredientUnit, None, None]: + def load_data(self) -> Generator[SaveIngredientUnit, None, None]: file = self.resources / "units" / "en-us.json" for unit in json.loads(file.read_text()).values(): - yield CreateIngredientUnit( + yield SaveIngredientUnit( + group_id=self.group_id, name=unit["name"], description=unit["description"], abbreviation=unit["abbreviation"], @@ -46,10 +47,14 @@ class IngredientUnitsSeeder(AbstractSeeder): class IngredientFoodsSeeder(AbstractSeeder): - def load_data(self) -> Generator[CreateIngredientFood, None, None]: + def load_data(self) -> Generator[SaveIngredientFood, None, None]: file = self.resources / "foods" / "en-us.json" for food in json.loads(file.read_text()): - yield CreateIngredientFood(name=food, description="") + yield SaveIngredientFood( + group_id=self.group_id, + name=food, + description="", + ) def seed(self) -> None: self.logger.info("Seeding Ingredient Foods") diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py index 96d6a155b14d..50fb28134d2d 100644 --- a/mealie/routes/unit_and_foods/foods.py +++ b/mealie/routes/unit_and_foods/foods.py @@ -5,8 +5,9 @@ from fastapi import APIRouter, Depends from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood +from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, SaveIngredientFood router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) @@ -15,11 +16,11 @@ router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) class IngredientFoodsController(BaseUserController): @cached_property def repo(self): - return self.deps.repos.ingredient_foods + return self.deps.repos.ingredient_foods.by_group(self.group_id) @cached_property def mixins(self): - return CrudMixins[CreateIngredientFood, IngredientFood, CreateIngredientFood]( + return CrudMixins[SaveIngredientFood, IngredientFood, CreateIngredientFood]( self.repo, self.deps.logger, self.registered_exceptions, @@ -31,7 +32,8 @@ class IngredientFoodsController(BaseUserController): @router.post("", response_model=IngredientFood, status_code=201) def create_one(self, data: CreateIngredientFood): - return self.mixins.create_one(data) + save_data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id) + return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=IngredientFood) def get_one(self, item_id: int): diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py index 05bef2514cbd..47eaaaf28bbe 100644 --- a/mealie/routes/unit_and_foods/units.py +++ b/mealie/routes/unit_and_foods/units.py @@ -5,8 +5,9 @@ from fastapi import APIRouter, Depends from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins +from mealie.schema import mapper from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit +from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit, SaveIngredientUnit router = APIRouter(prefix="/units", tags=["Recipes: Units"]) @@ -15,7 +16,7 @@ router = APIRouter(prefix="/units", tags=["Recipes: Units"]) class IngredientUnitsController(BaseUserController): @cached_property def repo(self): - return self.deps.repos.ingredient_units + return self.deps.repos.ingredient_units.by_group(self.group_id) @cached_property def mixins(self): @@ -31,7 +32,8 @@ class IngredientUnitsController(BaseUserController): @router.post("", response_model=IngredientUnit, status_code=201) def create_one(self, data: CreateIngredientUnit): - return self.mixins.create_one(data) + save_data = mapper.cast(data, SaveIngredientUnit, group_id=self.group_id) + return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=IngredientUnit) def get_one(self, item_id: int): diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index ef6d6500edee..6d18fb9882eb 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -17,6 +17,10 @@ class CreateIngredientFood(UnitFoodBase): label_id: UUID4 = None +class SaveIngredientFood(CreateIngredientFood): + group_id: UUID4 + + class IngredientFood(CreateIngredientFood): id: int label: MultiPurposeLabelSummary = None @@ -30,6 +34,10 @@ class CreateIngredientUnit(UnitFoodBase): abbreviation: str = "" +class SaveIngredientUnit(CreateIngredientUnit): + group_id: UUID4 + + class IngredientUnit(CreateIngredientUnit): id: int diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 89a102a210ba..c0d7f3dc22cb 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1,5 +1,6 @@ from .fixture_admin import * from .fixture_database import * +from .fixture_multitenant import * from .fixture_recipe import * from .fixture_routes import * from .fixture_shopping_lists import * diff --git a/tests/fixtures/fixture_multitenant.py b/tests/fixtures/fixture_multitenant.py new file mode 100644 index 000000000000..354b103bb581 --- /dev/null +++ b/tests/fixtures/fixture_multitenant.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + +import pytest +from fastapi.testclient import TestClient + +from tests import utils +from tests.fixtures.fixture_users import build_unique_user +from tests.utils.factories import random_string + + +@dataclass +class MultiTenant: + user_one: utils.TestUser + user_two: utils.TestUser + + +@pytest.fixture(scope="module") +def multitenants(api_client: TestClient) -> MultiTenant: + yield MultiTenant( + user_one=build_unique_user(random_string(12), api_client), + user_two=build_unique_user(random_string(12), api_client), + ) diff --git a/tests/fixtures/fixture_users.py b/tests/fixtures/fixture_users.py index 485dcd23a556..6a30902abf00 100644 --- a/tests/fixtures/fixture_users.py +++ b/tests/fixtures/fixture_users.py @@ -6,22 +6,47 @@ from pytest import fixture from starlette.testclient import TestClient from tests import utils +from tests.utils.factories import random_string + + +def build_unique_user(group: str, api_client: requests) -> utils.TestUser: + api_routes = utils.AppRoutes() + group = group or random_string(12) + + registration = utils.user_registration_factory() + response = api_client.post("/api/users/register", json=registration.dict(by_alias=True)) + assert response.status_code == 201 + + form_data = {"username": registration.username, "password": registration.password} + + token = utils.login(form_data, api_client, api_routes) + + user_data = api_client.get(api_routes.users_self, headers=token).json() + assert token is not None + + return utils.TestUser( + _group_id=user_data.get("groupId"), + user_id=user_data.get("id"), + email=user_data.get("email"), + token=token, + ) @fixture(scope="module") -def g2_user(admin_token, api_client: requests, api_routes: utils.AppRoutes): +def g2_user(admin_token, api_client: TestClient, api_routes: utils.AppRoutes): + group = random_string(12) # Create the user create_data = { "fullName": utils.random_string(), "username": utils.random_string(), "email": utils.random_email(), "password": "useruser", - "group": "New Group", + "group": group, "admin": False, "tokens": [], } - response = api_client.post(api_routes.groups, json={"name": "New Group"}, headers=admin_token) + response = api_client.post(api_routes.groups, json={"name": group}, headers=admin_token) response = api_client.post(api_routes.users, json=create_data, headers=admin_token) assert response.status_code == 201 diff --git a/tests/multitenant_tests/test_ingredient_food.py b/tests/multitenant_tests/test_ingredient_food.py new file mode 100644 index 000000000000..600477cf9146 --- /dev/null +++ b/tests/multitenant_tests/test_ingredient_food.py @@ -0,0 +1,79 @@ +from fastapi.testclient import TestClient + +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit +from tests import utils +from tests.fixtures.fixture_multitenant import MultiTenant +from tests.utils import routes + + +def test_foods_are_private_by_group( + api_client: TestClient, multitenants: MultiTenant, database: AllRepositories +) -> None: + user1 = multitenants.user_one + user2 = multitenants.user_two + + # Bootstrap foods for user1 + food_ids: set[int] = set() + for _ in range(10): + food = database.ingredient_foods.create( + SaveIngredientFood( + group_id=user1.group_id, + name=utils.random_string(10), + ) + ) + + food_ids.add(food.id) + + expected_results = [ + (user1.token, food_ids), + (user2.token, []), + ] + + for token, expected_food_ids in expected_results: + response = api_client.get(routes.RoutesFoods.base, headers=token) + assert response.status_code == 200 + + data = response.json() + + assert len(data) == len(expected_food_ids) + + if len(data) > 0: + for food in data: + assert food["id"] in expected_food_ids + + +def test_units_are_private_by_group( + api_client: TestClient, multitenants: MultiTenant, database: AllRepositories +) -> None: + user1 = multitenants.user_one + user2 = multitenants.user_two + + # Bootstrap foods for user1 + unit_ids: set[int] = set() + for _ in range(10): + food = database.ingredient_units.create( + SaveIngredientUnit( + group_id=user1.group_id, + name=utils.random_string(10), + ) + ) + + unit_ids.add(food.id) + + expected_results = [ + (user1.token, unit_ids), + (user2.token, []), + ] + + for token, expected_unit_ids in expected_results: + response = api_client.get(routes.RoutesUnits.base, headers=token) + assert response.status_code == 200 + + data = response.json() + + assert len(data) == len(expected_unit_ids) + + if len(data) > 0: + for food in data: + assert food["id"] in expected_unit_ids diff --git a/tests/utils/routes.py b/tests/utils/routes.py new file mode 100644 index 000000000000..e474b803ff00 --- /dev/null +++ b/tests/utils/routes.py @@ -0,0 +1,21 @@ +from pydantic import UUID4 + + +class _RoutesBase: + prefix = "/api" + base = f"{prefix}/" + + def __init__(self) -> None: + raise Exception("This class is not meant to be instantiated.") + + @classmethod + def item(cls, item_id: int | str | UUID4) -> str: + return f"{cls.base}/{item_id}" + + +class RoutesFoods(_RoutesBase): + base = "/api/foods" + + +class RoutesUnits(_RoutesBase): + base = "/api/units"