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 [];
|
||||
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
|
Loading…
x
Reference in New Issue
Block a user