mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
update foods and units for multitenant support
This commit is contained in:
parent
fbc17b670d
commit
9a82a172cb
@ -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"
|
Loading…
x
Reference in New Issue
Block a user