feat: Allow Cookbooks To Share Names (#4186)

This commit is contained in:
Michael Genson 2024-09-15 06:42:58 -05:00 committed by GitHub
parent abe4504640
commit dbbd662e7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 185 additions and 29 deletions

View File

@ -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}`,

View File

@ -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"

View File

@ -1,4 +1,5 @@
export interface SideBarLink {
key?: string;
icon: string;
to?: string;
href?: string;

View 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)

View File

@ -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
)

View File

@ -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

View File

@ -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))
]
)

View File

@ -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