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 []; |       if (!cookbooks.value) return []; | ||||||
|       return cookbooks.value.map((cookbook) => { |       return cookbooks.value.map((cookbook) => { | ||||||
|         return { |         return { | ||||||
|  |           key: cookbook.slug, | ||||||
|           icon: $globals.icons.pages, |           icon: $globals.icons.pages, | ||||||
|           title: cookbook.name, |           title: cookbook.name, | ||||||
|           to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`, |           to: `/g/${groupSlug.value}/cookbooks/${cookbook.slug as string}`, | ||||||
|   | |||||||
| @@ -26,11 +26,11 @@ | |||||||
|     <template v-if="topLink"> |     <template v-if="topLink"> | ||||||
|       <v-list nav dense> |       <v-list nav dense> | ||||||
|         <template v-for="nav in topLink"> |         <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 --> |             <!-- Multi Items --> | ||||||
|             <v-list-group |             <v-list-group | ||||||
|               v-if="nav.children" |               v-if="nav.children" | ||||||
|               :key="nav.title + 'multi-item'" |               :key="(nav.key || nav.title) + 'multi-item'" | ||||||
|               v-model="dropDowns[nav.title]" |               v-model="dropDowns[nav.title]" | ||||||
|               color="primary" |               color="primary" | ||||||
|               :prepend-icon="nav.icon" |               :prepend-icon="nav.icon" | ||||||
| @@ -39,7 +39,7 @@ | |||||||
|                 <v-list-item-title>{{ nav.title }}</v-list-item-title> |                 <v-list-item-title>{{ nav.title }}</v-list-item-title> | ||||||
|               </template> |               </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-list-item-icon> | ||||||
|                   <v-icon>{{ child.icon }}</v-icon> |                   <v-icon>{{ child.icon }}</v-icon> | ||||||
|                 </v-list-item-icon> |                 </v-list-item-icon> | ||||||
| @@ -50,7 +50,7 @@ | |||||||
|             <!-- Single Item --> |             <!-- Single Item --> | ||||||
|             <v-list-item-group |             <v-list-item-group | ||||||
|               v-else |               v-else | ||||||
|               :key="nav.title + 'single-item'" |               :key="(nav.key || nav.title) + 'single-item'" | ||||||
|               v-model="secondarySelected" |               v-model="secondarySelected" | ||||||
|               color="primary" |               color="primary" | ||||||
|             > |             > | ||||||
| @@ -71,11 +71,11 @@ | |||||||
|       <v-divider class="mt-2"></v-divider> |       <v-divider class="mt-2"></v-divider> | ||||||
|       <v-list nav dense exact> |       <v-list nav dense exact> | ||||||
|         <template v-for="nav in secondaryLinks"> |         <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 --> |             <!-- Multi Items --> | ||||||
|             <v-list-group |             <v-list-group | ||||||
|               v-if="nav.children" |               v-if="nav.children" | ||||||
|               :key="nav.title + 'multi-item'" |               :key="(nav.key || nav.title) + 'multi-item'" | ||||||
|               v-model="dropDowns[nav.title]" |               v-model="dropDowns[nav.title]" | ||||||
|               color="primary" |               color="primary" | ||||||
|               :prepend-icon="nav.icon" |               :prepend-icon="nav.icon" | ||||||
| @@ -84,7 +84,7 @@ | |||||||
|                 <v-list-item-title>{{ nav.title }}</v-list-item-title> |                 <v-list-item-title>{{ nav.title }}</v-list-item-title> | ||||||
|               </template> |               </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-list-item-icon> | ||||||
|                   <v-icon>{{ child.icon }}</v-icon> |                   <v-icon>{{ child.icon }}</v-icon> | ||||||
|                 </v-list-item-icon> |                 </v-list-item-icon> | ||||||
| @@ -94,7 +94,7 @@ | |||||||
|             </v-list-group> |             </v-list-group> | ||||||
|  |  | ||||||
|             <!-- Single Item --> |             <!-- 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 exact link :to="nav.to"> | ||||||
|                 <v-list-item-icon> |                 <v-list-item-icon> | ||||||
|                   <v-icon>{{ nav.icon }}</v-icon> |                   <v-icon>{{ nav.icon }}</v-icon> | ||||||
| @@ -112,9 +112,9 @@ | |||||||
|       <v-list nav dense> |       <v-list nav dense> | ||||||
|         <v-list-item-group v-model="bottomSelected" color="primary"> |         <v-list-item-group v-model="bottomSelected" color="primary"> | ||||||
|           <template v-for="nav in bottomLinks"> |           <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 |               <v-list-item | ||||||
|                 :key="nav.title" |                 :key="nav.key || nav.title" | ||||||
|                 exact |                 exact | ||||||
|                 link |                 link | ||||||
|                 :to="nav.to || null" |                 :to="nav.to || null" | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| export interface SideBarLink { | export interface SideBarLink { | ||||||
|  |   key?: string; | ||||||
|   icon: string; |   icon: string; | ||||||
|   to?: string; |   to?: string; | ||||||
|   href?: 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 import LongLiveToken, User | ||||||
| from mealie.db.models.users.password_reset import PasswordResetModel | from mealie.db.models.users.password_reset import PasswordResetModel | ||||||
| from mealie.db.models.users.user_to_recipe import UserToRecipe | 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_foods import RepositoryFood | ||||||
| from mealie.repos.repository_household import RepositoryHousehold | from mealie.repos.repository_household import RepositoryHousehold | ||||||
| from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules | from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules | ||||||
| @@ -231,8 +232,8 @@ class AllRepositories: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
|     def cookbooks(self) -> HouseholdRepositoryGeneric[ReadCookBook, CookBook]: |     def cookbooks(self) -> RepositoryCookbooks: | ||||||
|         return HouseholdRepositoryGeneric( |         return RepositoryCookbooks( | ||||||
|             self.session, PK_ID, CookBook, ReadCookBook, group_id=self.group_id, household_id=self.household_id |             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 typing import Annotated | ||||||
|  |  | ||||||
| from pydantic import UUID4, ConfigDict, Field, field_validator | 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 import joinedload | ||||||
| from sqlalchemy.orm.interfaces import LoaderOption | from sqlalchemy.orm.interfaces import LoaderOption | ||||||
|  |  | ||||||
| @@ -31,16 +29,6 @@ class CreateCookBook(MealieModel): | |||||||
|     def validate_public(public: bool | None) -> bool: |     def validate_public(public: bool | None) -> bool: | ||||||
|         return False if public is None else public |         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): | class SaveCookBook(CreateCookBook): | ||||||
|     group_id: UUID4 |     group_id: UUID4 | ||||||
|   | |||||||
| @@ -34,11 +34,12 @@ def test_get_all_cookbooks( | |||||||
|     household_private_map: dict[UUID4, bool] = {} |     household_private_map: dict[UUID4, bool] = {} | ||||||
|     public_cookbooks: list[ReadCookBook] = [] |     public_cookbooks: list[ReadCookBook] = [] | ||||||
|     private_cookbooks: list[ReadCookBook] = [] |     private_cookbooks: list[ReadCookBook] = [] | ||||||
|     for database, is_private_household in [ |     for user, is_private_household in [ | ||||||
|         (unique_user.repos, is_household_1_private), |         (unique_user, is_household_1_private), | ||||||
|         (h2_user.repos, is_household_2_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 |         assert household and household.preferences | ||||||
|  |  | ||||||
|         household_private_map[household.id] = is_private_household |         household_private_map[household.id] = is_private_household | ||||||
| @@ -49,7 +50,7 @@ def test_get_all_cookbooks( | |||||||
|         ## Set Up Cookbooks |         ## Set Up Cookbooks | ||||||
|         default_cookbooks = database.cookbooks.create_many( |         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)) |                 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