mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: Allow Cookbooks To Share Names (#4186)
This commit is contained in:
parent
abe4504640
commit
dbbd662e7d
@ -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
|
Loading…
x
Reference in New Issue
Block a user