mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Allow Cookbooks To Share Names (#4186)
This commit is contained in:
		| @@ -117,6 +117,7 @@ export default defineComponent({ | ||||
|       if (!cookbooks.value) return []; | ||||
|       return cookbooks.value.map((cookbook) => { | ||||
|         return { | ||||
|           key: cookbook.slug, | ||||
|           icon: $globals.icons.pages, | ||||
|           title: cookbook.name, | ||||
|           to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`, | ||||
|   | ||||
| @@ -26,11 +26,11 @@ | ||||
|     <template v-if="topLink"> | ||||
|       <v-list nav dense> | ||||
|         <template v-for="nav in topLink"> | ||||
|           <div v-if="!nav.restricted || isOwnGroup" :key="nav.title"> | ||||
|           <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> | ||||
|             <!-- Multi Items --> | ||||
|             <v-list-group | ||||
|               v-if="nav.children" | ||||
|               :key="nav.title + 'multi-item'" | ||||
|               :key="(nav.key || nav.title) + 'multi-item'" | ||||
|               v-model="dropDowns[nav.title]" | ||||
|               color="primary" | ||||
|               :prepend-icon="nav.icon" | ||||
| @@ -39,7 +39,7 @@ | ||||
|                 <v-list-item-title>{{ nav.title }}</v-list-item-title> | ||||
|               </template> | ||||
|  | ||||
|               <v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to" class="ml-2"> | ||||
|               <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2"> | ||||
|                 <v-list-item-icon> | ||||
|                   <v-icon>{{ child.icon }}</v-icon> | ||||
|                 </v-list-item-icon> | ||||
| @@ -50,7 +50,7 @@ | ||||
|             <!-- Single Item --> | ||||
|             <v-list-item-group | ||||
|               v-else | ||||
|               :key="nav.title + 'single-item'" | ||||
|               :key="(nav.key || nav.title) + 'single-item'" | ||||
|               v-model="secondarySelected" | ||||
|               color="primary" | ||||
|             > | ||||
| @@ -71,11 +71,11 @@ | ||||
|       <v-divider class="mt-2"></v-divider> | ||||
|       <v-list nav dense exact> | ||||
|         <template v-for="nav in secondaryLinks"> | ||||
|           <div v-if="!nav.restricted || isOwnGroup" :key="nav.title"> | ||||
|           <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> | ||||
|             <!-- Multi Items --> | ||||
|             <v-list-group | ||||
|               v-if="nav.children" | ||||
|               :key="nav.title + 'multi-item'" | ||||
|               :key="(nav.key || nav.title) + 'multi-item'" | ||||
|               v-model="dropDowns[nav.title]" | ||||
|               color="primary" | ||||
|               :prepend-icon="nav.icon" | ||||
| @@ -84,7 +84,7 @@ | ||||
|                 <v-list-item-title>{{ nav.title }}</v-list-item-title> | ||||
|               </template> | ||||
|  | ||||
|               <v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to"> | ||||
|               <v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"> | ||||
|                 <v-list-item-icon> | ||||
|                   <v-icon>{{ child.icon }}</v-icon> | ||||
|                 </v-list-item-icon> | ||||
| @@ -94,7 +94,7 @@ | ||||
|             </v-list-group> | ||||
|  | ||||
|             <!-- Single Item --> | ||||
|             <v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary"> | ||||
|             <v-list-item-group v-else :key="(nav.key || nav.title) + 'single-item'" v-model="secondarySelected" color="primary"> | ||||
|               <v-list-item exact link :to="nav.to"> | ||||
|                 <v-list-item-icon> | ||||
|                   <v-icon>{{ nav.icon }}</v-icon> | ||||
| @@ -112,9 +112,9 @@ | ||||
|       <v-list nav dense> | ||||
|         <v-list-item-group v-model="bottomSelected" color="primary"> | ||||
|           <template v-for="nav in bottomLinks"> | ||||
|             <div v-if="!nav.restricted || isOwnGroup" :key="nav.title"> | ||||
|             <div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title"> | ||||
|               <v-list-item | ||||
|                 :key="nav.title" | ||||
|                 :key="nav.key || nav.title" | ||||
|                 exact | ||||
|                 link | ||||
|                 :to="nav.to || null" | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| export interface SideBarLink { | ||||
|   key?: string; | ||||
|   icon: string; | ||||
|   to?: string; | ||||
|   href?: string; | ||||
|   | ||||
							
								
								
									
										66
									
								
								mealie/repos/repository_cookbooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								mealie/repos/repository_cookbooks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import re | ||||
| from collections.abc import Iterable | ||||
|  | ||||
| from fastapi import HTTPException, status | ||||
| from pydantic import UUID4 | ||||
| from slugify import slugify | ||||
| from sqlalchemy.exc import IntegrityError | ||||
|  | ||||
| from mealie.db.models.household.cookbook import CookBook | ||||
| from mealie.repos.repository_generic import HouseholdRepositoryGeneric | ||||
| from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook | ||||
| from mealie.schema.response.responses import ErrorResponse | ||||
|  | ||||
|  | ||||
| class RepositoryCookbooks(HouseholdRepositoryGeneric[ReadCookBook, CookBook]): | ||||
|     def create(self, data: SaveCookBook | dict) -> ReadCookBook: | ||||
|         if isinstance(data, dict): | ||||
|             data = SaveCookBook(**data) | ||||
|         data.slug = slugify(data.name) | ||||
|  | ||||
|         max_retries = 10 | ||||
|         for i in range(max_retries): | ||||
|             try: | ||||
|                 return super().create(data) | ||||
|             except IntegrityError: | ||||
|                 self.session.rollback() | ||||
|                 data.slug = slugify(f"{data.name} ({i+1})") | ||||
|  | ||||
|         raise  # raise the last IntegrityError | ||||
|  | ||||
|     def create_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]: | ||||
|         return [self.create(entry) for entry in data] | ||||
|  | ||||
|     def update(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook: | ||||
|         if isinstance(data, dict): | ||||
|             data = SaveCookBook(**data) | ||||
|  | ||||
|         new_slug = slugify(data.name) | ||||
|         if not (data.slug and re.match(f"^({new_slug})(-\d+)?$", data.slug)): | ||||
|             data.slug = new_slug | ||||
|  | ||||
|         max_retries = 10 | ||||
|         for i in range(max_retries): | ||||
|             try: | ||||
|                 return super().update(match_value, data) | ||||
|             except IntegrityError: | ||||
|                 self.session.rollback() | ||||
|                 data.slug = slugify(f"{data.name} ({i+1})") | ||||
|  | ||||
|         raise  # raise the last IntegrityError | ||||
|  | ||||
|     def update_many(self, data: Iterable[ReadCookBook | dict]) -> list[ReadCookBook]: | ||||
|         return [self.update(entry.id if isinstance(entry, ReadCookBook) else entry["id"], entry) for entry in data] | ||||
|  | ||||
|     def patch(self, match_value: str | int | UUID4, data: SaveCookBook | dict) -> ReadCookBook: | ||||
|         cookbook = self.get_one(match_value) | ||||
|         if not cookbook: | ||||
|             raise HTTPException( | ||||
|                 status.HTTP_404_NOT_FOUND, | ||||
|                 detail=ErrorResponse.respond(message="Not found."), | ||||
|             ) | ||||
|         cookbook_data = cookbook.model_dump() | ||||
|  | ||||
|         if not isinstance(data, dict): | ||||
|             data = data.model_dump() | ||||
|         return self.update(match_value, cookbook_data | data) | ||||
| @@ -35,6 +35,7 @@ from mealie.db.models.recipe.tool import Tool | ||||
| from mealie.db.models.users import LongLiveToken, User | ||||
| from mealie.db.models.users.password_reset import PasswordResetModel | ||||
| from mealie.db.models.users.user_to_recipe import UserToRecipe | ||||
| from mealie.repos.repository_cookbooks import RepositoryCookbooks | ||||
| from mealie.repos.repository_foods import RepositoryFood | ||||
| from mealie.repos.repository_household import RepositoryHousehold | ||||
| from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules | ||||
| @@ -231,8 +232,8 @@ class AllRepositories: | ||||
|         ) | ||||
|  | ||||
|     @cached_property | ||||
|     def cookbooks(self) -> HouseholdRepositoryGeneric[ReadCookBook, CookBook]: | ||||
|         return HouseholdRepositoryGeneric( | ||||
|     def cookbooks(self) -> RepositoryCookbooks: | ||||
|         return RepositoryCookbooks( | ||||
|             self.session, PK_ID, CookBook, ReadCookBook, group_id=self.group_id, household_id=self.household_id | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| from typing import Annotated | ||||
|  | ||||
| from pydantic import UUID4, ConfigDict, Field, field_validator | ||||
| from pydantic_core.core_schema import ValidationInfo | ||||
| from slugify import slugify | ||||
| from sqlalchemy.orm import joinedload | ||||
| from sqlalchemy.orm.interfaces import LoaderOption | ||||
|  | ||||
| @@ -31,16 +29,6 @@ class CreateCookBook(MealieModel): | ||||
|     def validate_public(public: bool | None) -> bool: | ||||
|         return False if public is None else public | ||||
|  | ||||
|     @field_validator("slug", mode="before") | ||||
|     def validate_slug(slug: str, info: ValidationInfo): | ||||
|         name: str = info.data["name"] | ||||
|         calc_slug: str = slugify(name) | ||||
|  | ||||
|         if slug != calc_slug: | ||||
|             slug = calc_slug | ||||
|  | ||||
|         return slug | ||||
|  | ||||
|  | ||||
| class SaveCookBook(CreateCookBook): | ||||
|     group_id: UUID4 | ||||
|   | ||||
| @@ -34,11 +34,12 @@ def test_get_all_cookbooks( | ||||
|     household_private_map: dict[UUID4, bool] = {} | ||||
|     public_cookbooks: list[ReadCookBook] = [] | ||||
|     private_cookbooks: list[ReadCookBook] = [] | ||||
|     for database, is_private_household in [ | ||||
|         (unique_user.repos, is_household_1_private), | ||||
|         (h2_user.repos, is_household_2_private), | ||||
|     for user, is_private_household in [ | ||||
|         (unique_user, is_household_1_private), | ||||
|         (h2_user, is_household_2_private), | ||||
|     ]: | ||||
|         household = database.households.get_one(unique_user.household_id) | ||||
|         database = user.repos | ||||
|         household = database.households.get_one(user.household_id) | ||||
|         assert household and household.preferences | ||||
|  | ||||
|         household_private_map[household.id] = is_private_household | ||||
| @@ -49,7 +50,7 @@ def test_get_all_cookbooks( | ||||
|         ## Set Up Cookbooks | ||||
|         default_cookbooks = database.cookbooks.create_many( | ||||
|             [ | ||||
|                 SaveCookBook(name=random_string(), group_id=unique_user.group_id, household_id=unique_user.household_id) | ||||
|                 SaveCookBook(name=random_string(), group_id=user.group_id, household_id=user.household_id) | ||||
|                 for _ in range(random_int(15, 20)) | ||||
|             ] | ||||
|         ) | ||||
|   | ||||
| @@ -0,0 +1,98 @@ | ||||
| from uuid import UUID | ||||
|  | ||||
| import pytest | ||||
| from slugify import slugify | ||||
|  | ||||
| from mealie.schema.cookbook.cookbook import SaveCookBook | ||||
| from tests.utils.factories import random_string | ||||
| from tests.utils.fixture_schemas import TestUser | ||||
|  | ||||
|  | ||||
| def cookbook_data(user: TestUser, **kwargs): | ||||
|     data = { | ||||
|         "name": random_string(), | ||||
|         "group_id": UUID(user.group_id), | ||||
|         "household_id": UUID(user.household_id), | ||||
|     } | kwargs | ||||
|  | ||||
|     return SaveCookBook(**data) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("use_create_many", [True, False]) | ||||
| def test_create_cookbook_ignores_slug(unique_user: TestUser, use_create_many: bool): | ||||
|     bad_slug = random_string() | ||||
|     cb_data = cookbook_data(unique_user, slug=bad_slug) | ||||
|     if use_create_many: | ||||
|         result = unique_user.repos.cookbooks.create_many([cb_data]) | ||||
|         assert len(result) == 1 | ||||
|         cb = result[0] | ||||
|     else: | ||||
|         cb = unique_user.repos.cookbooks.create(cb_data) | ||||
|     assert cb.slug == slugify(cb.name) != bad_slug | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("use_create_many", [True, False]) | ||||
| def test_create_cookbook_duplicate_name(unique_user: TestUser, use_create_many: bool): | ||||
|     cb_1_data = cookbook_data(unique_user) | ||||
|     cb_2_data = cookbook_data(unique_user, name=cb_1_data.name) | ||||
|  | ||||
|     cb_1 = unique_user.repos.cookbooks.create(cb_1_data) | ||||
|     unique_user.repos.session.commit() | ||||
|  | ||||
|     if use_create_many: | ||||
|         result = unique_user.repos.cookbooks.create_many([cb_2_data]) | ||||
|         assert len(result) == 1 | ||||
|         cb_2 = result[0] | ||||
|     else: | ||||
|         cb_2 = unique_user.repos.cookbooks.create(cb_2_data) | ||||
|  | ||||
|     assert cb_1.id != cb_2.id | ||||
|     assert cb_1.name == cb_2.name | ||||
|     assert cb_1.slug != cb_2.slug | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("method", ["update", "update_many", "patch"]) | ||||
| def test_update_cookbook_updates_slug(unique_user: TestUser, method: str): | ||||
|     cb_data = cookbook_data(unique_user) | ||||
|     cb = unique_user.repos.cookbooks.create(cb_data) | ||||
|     unique_user.repos.session.commit() | ||||
|  | ||||
|     new_name = random_string() | ||||
|     cb.name = new_name | ||||
|  | ||||
|     if method == "update": | ||||
|         cb = unique_user.repos.cookbooks.update(cb.id, cb) | ||||
|     if method == "update_many": | ||||
|         result = unique_user.repos.cookbooks.update_many([cb]) | ||||
|         assert len(result) == 1 | ||||
|         cb = result[0] | ||||
|     else: | ||||
|         cb = unique_user.repos.cookbooks.patch(cb.id, cb) | ||||
|  | ||||
|     assert cb.name == new_name | ||||
|     assert cb.slug == slugify(new_name) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("method", ["update", "update_many", "patch"]) | ||||
| def test_update_cookbook_duplicate_name(unique_user: TestUser, method: str): | ||||
|     cb_1_data = cookbook_data(unique_user) | ||||
|     cb_2_data = cookbook_data(unique_user) | ||||
|  | ||||
|     cb_1 = unique_user.repos.cookbooks.create(cb_1_data) | ||||
|     unique_user.repos.session.commit() | ||||
|     cb_2 = unique_user.repos.cookbooks.create(cb_2_data) | ||||
|     unique_user.repos.session.commit() | ||||
|  | ||||
|     cb_2.name = cb_1.name | ||||
|     if method == "update": | ||||
|         cb_2 = unique_user.repos.cookbooks.update(cb_2.id, cb_2) | ||||
|     if method == "update_many": | ||||
|         result = unique_user.repos.cookbooks.update_many([cb_2]) | ||||
|         assert len(result) == 1 | ||||
|         cb_2 = result[0] | ||||
|     else: | ||||
|         cb_2 = unique_user.repos.cookbooks.patch(cb_2.id, cb_2) | ||||
|  | ||||
|     assert cb_1.id != cb_2.id | ||||
|     assert cb_1.name == cb_2.name | ||||
|     assert cb_1.slug != cb_2.slug | ||||
		Reference in New Issue
	
	Block a user