Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow Cookbooks To Share Names #4186

1 change: 1 addition & 0 deletions frontend/components/Layout/DefaultLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
20 changes: 10 additions & 10 deletions frontend/components/Layout/LayoutParts/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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>
Expand All @@ -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"
>
Expand All @@ -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"
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions frontend/types/application-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface SideBarLink {
key?: string;
icon: string;
to?: string;
href?: string;
Expand Down
66 changes: 66 additions & 0 deletions mealie/repos/repository_cookbooks.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 3 additions & 2 deletions mealie/repos/repository_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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
Expand Down Expand Up @@ -231,8 +232,8 @@ def household_preferences(self) -> HouseholdRepositoryGeneric[ReadHouseholdPrefe
)

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

Expand Down
12 changes: 0 additions & 12 deletions mealie/schema/cookbook/cookbook.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
]
)
Expand Down
98 changes: 98 additions & 0 deletions tests/unit_tests/repository_tests/test_cookbook_repository.py
Original file line number Diff line number Diff line change
@@ -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