mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	update foods and units for multitenant support
This commit is contained in:
		| @@ -55,6 +55,10 @@ class Group(SqlAlchemyBase, BaseMixins): | |||||||
|     group_reports = orm.relationship("ReportModel", **common_args) |     group_reports = orm.relationship("ReportModel", **common_args) | ||||||
|     group_event_notifiers = orm.relationship("GroupEventNotifierModel", **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: |     class Config: | ||||||
|         exclude = { |         exclude = { | ||||||
|             "users", |             "users", | ||||||
|   | |||||||
| @@ -9,6 +9,11 @@ from .._model_utils.guid import GUID | |||||||
|  |  | ||||||
| class IngredientUnitModel(SqlAlchemyBase, BaseMixins): | class IngredientUnitModel(SqlAlchemyBase, BaseMixins): | ||||||
|     __tablename__ = "ingredient_units" |     __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) |     id = Column(Integer, primary_key=True) | ||||||
|     name = Column(String) |     name = Column(String) | ||||||
|     description = Column(String) |     description = Column(String) | ||||||
| @@ -23,6 +28,11 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins): | |||||||
|  |  | ||||||
| class IngredientFoodModel(SqlAlchemyBase, BaseMixins): | class IngredientFoodModel(SqlAlchemyBase, BaseMixins): | ||||||
|     __tablename__ = "ingredient_foods" |     __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) |     id = Column(Integer, primary_key=True) | ||||||
|     name = Column(String) |     name = Column(String) | ||||||
|     description = Column(String) |     description = Column(String) | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import json | |||||||
| from typing import Generator | from typing import Generator | ||||||
|  |  | ||||||
| from mealie.schema.labels import MultiPurposeLabelSave | 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 | from ._abstract_seeder import AbstractSeeder | ||||||
|  |  | ||||||
| @@ -27,10 +27,11 @@ class MultiPurposeLabelSeeder(AbstractSeeder): | |||||||
|  |  | ||||||
|  |  | ||||||
| class IngredientUnitsSeeder(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" |         file = self.resources / "units" / "en-us.json" | ||||||
|         for unit in json.loads(file.read_text()).values(): |         for unit in json.loads(file.read_text()).values(): | ||||||
|             yield CreateIngredientUnit( |             yield SaveIngredientUnit( | ||||||
|  |                 group_id=self.group_id, | ||||||
|                 name=unit["name"], |                 name=unit["name"], | ||||||
|                 description=unit["description"], |                 description=unit["description"], | ||||||
|                 abbreviation=unit["abbreviation"], |                 abbreviation=unit["abbreviation"], | ||||||
| @@ -46,10 +47,14 @@ class IngredientUnitsSeeder(AbstractSeeder): | |||||||
|  |  | ||||||
|  |  | ||||||
| class IngredientFoodsSeeder(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" |         file = self.resources / "foods" / "en-us.json" | ||||||
|         for food in json.loads(file.read_text()): |         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: |     def seed(self) -> None: | ||||||
|         self.logger.info("Seeding Ingredient Foods") |         self.logger.info("Seeding Ingredient Foods") | ||||||
|   | |||||||
| @@ -5,8 +5,9 @@ from fastapi import APIRouter, Depends | |||||||
| from mealie.routes._base.abc_controller import BaseUserController | from mealie.routes._base.abc_controller import BaseUserController | ||||||
| from mealie.routes._base.controller import controller | from mealie.routes._base.controller import controller | ||||||
| from mealie.routes._base.mixins import CrudMixins | from mealie.routes._base.mixins import CrudMixins | ||||||
|  | from mealie.schema import mapper | ||||||
| from mealie.schema.query import GetAll | 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"]) | router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) | ||||||
|  |  | ||||||
| @@ -15,11 +16,11 @@ router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) | |||||||
| class IngredientFoodsController(BaseUserController): | class IngredientFoodsController(BaseUserController): | ||||||
|     @cached_property |     @cached_property | ||||||
|     def repo(self): |     def repo(self): | ||||||
|         return self.deps.repos.ingredient_foods |         return self.deps.repos.ingredient_foods.by_group(self.group_id) | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
|     def mixins(self): |     def mixins(self): | ||||||
|         return CrudMixins[CreateIngredientFood, IngredientFood, CreateIngredientFood]( |         return CrudMixins[SaveIngredientFood, IngredientFood, CreateIngredientFood]( | ||||||
|             self.repo, |             self.repo, | ||||||
|             self.deps.logger, |             self.deps.logger, | ||||||
|             self.registered_exceptions, |             self.registered_exceptions, | ||||||
| @@ -31,7 +32,8 @@ class IngredientFoodsController(BaseUserController): | |||||||
|  |  | ||||||
|     @router.post("", response_model=IngredientFood, status_code=201) |     @router.post("", response_model=IngredientFood, status_code=201) | ||||||
|     def create_one(self, data: CreateIngredientFood): |     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) |     @router.get("/{item_id}", response_model=IngredientFood) | ||||||
|     def get_one(self, item_id: int): |     def get_one(self, item_id: int): | ||||||
|   | |||||||
| @@ -5,8 +5,9 @@ from fastapi import APIRouter, Depends | |||||||
| from mealie.routes._base.abc_controller import BaseUserController | from mealie.routes._base.abc_controller import BaseUserController | ||||||
| from mealie.routes._base.controller import controller | from mealie.routes._base.controller import controller | ||||||
| from mealie.routes._base.mixins import CrudMixins | from mealie.routes._base.mixins import CrudMixins | ||||||
|  | from mealie.schema import mapper | ||||||
| from mealie.schema.query import GetAll | 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"]) | router = APIRouter(prefix="/units", tags=["Recipes: Units"]) | ||||||
|  |  | ||||||
| @@ -15,7 +16,7 @@ router = APIRouter(prefix="/units", tags=["Recipes: Units"]) | |||||||
| class IngredientUnitsController(BaseUserController): | class IngredientUnitsController(BaseUserController): | ||||||
|     @cached_property |     @cached_property | ||||||
|     def repo(self): |     def repo(self): | ||||||
|         return self.deps.repos.ingredient_units |         return self.deps.repos.ingredient_units.by_group(self.group_id) | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
|     def mixins(self): |     def mixins(self): | ||||||
| @@ -31,7 +32,8 @@ class IngredientUnitsController(BaseUserController): | |||||||
|  |  | ||||||
|     @router.post("", response_model=IngredientUnit, status_code=201) |     @router.post("", response_model=IngredientUnit, status_code=201) | ||||||
|     def create_one(self, data: CreateIngredientUnit): |     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) |     @router.get("/{item_id}", response_model=IngredientUnit) | ||||||
|     def get_one(self, item_id: int): |     def get_one(self, item_id: int): | ||||||
|   | |||||||
| @@ -17,6 +17,10 @@ class CreateIngredientFood(UnitFoodBase): | |||||||
|     label_id: UUID4 = None |     label_id: UUID4 = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SaveIngredientFood(CreateIngredientFood): | ||||||
|  |     group_id: UUID4 | ||||||
|  |  | ||||||
|  |  | ||||||
| class IngredientFood(CreateIngredientFood): | class IngredientFood(CreateIngredientFood): | ||||||
|     id: int |     id: int | ||||||
|     label: MultiPurposeLabelSummary = None |     label: MultiPurposeLabelSummary = None | ||||||
| @@ -30,6 +34,10 @@ class CreateIngredientUnit(UnitFoodBase): | |||||||
|     abbreviation: str = "" |     abbreviation: str = "" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SaveIngredientUnit(CreateIngredientUnit): | ||||||
|  |     group_id: UUID4 | ||||||
|  |  | ||||||
|  |  | ||||||
| class IngredientUnit(CreateIngredientUnit): | class IngredientUnit(CreateIngredientUnit): | ||||||
|     id: int |     id: int | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								tests/fixtures/__init__.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								tests/fixtures/__init__.py
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,6 @@ | |||||||
| from .fixture_admin import * | from .fixture_admin import * | ||||||
| from .fixture_database import * | from .fixture_database import * | ||||||
|  | from .fixture_multitenant import * | ||||||
| from .fixture_recipe import * | from .fixture_recipe import * | ||||||
| from .fixture_routes import * | from .fixture_routes import * | ||||||
| from .fixture_shopping_lists import * | from .fixture_shopping_lists import * | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								tests/fixtures/fixture_multitenant.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								tests/fixtures/fixture_multitenant.py
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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), | ||||||
|  |     ) | ||||||
							
								
								
									
										31
									
								
								tests/fixtures/fixture_users.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								tests/fixtures/fixture_users.py
									
									
									
									
										vendored
									
									
								
							| @@ -6,22 +6,47 @@ from pytest import fixture | |||||||
| from starlette.testclient import TestClient | from starlette.testclient import TestClient | ||||||
|  |  | ||||||
| from tests import utils | 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") | @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 the user | ||||||
|     create_data = { |     create_data = { | ||||||
|         "fullName": utils.random_string(), |         "fullName": utils.random_string(), | ||||||
|         "username": utils.random_string(), |         "username": utils.random_string(), | ||||||
|         "email": utils.random_email(), |         "email": utils.random_email(), | ||||||
|         "password": "useruser", |         "password": "useruser", | ||||||
|         "group": "New Group", |         "group": group, | ||||||
|         "admin": False, |         "admin": False, | ||||||
|         "tokens": [], |         "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) |     response = api_client.post(api_routes.users, json=create_data, headers=admin_token) | ||||||
|  |  | ||||||
|     assert response.status_code == 201 |     assert response.status_code == 201 | ||||||
|   | |||||||
							
								
								
									
										79
									
								
								tests/multitenant_tests/test_ingredient_food.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								tests/multitenant_tests/test_ingredient_food.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										21
									
								
								tests/utils/routes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								tests/utils/routes.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||||
		Reference in New Issue
	
	Block a user