feat: additional cookbook features (tags, tools, and public) (#1116)

* migration: add public, tags, and tools

* generate frontend types

* add help icon

* start replacement for tool-tag-category selector

* add help icon utility

* use generator types

* add support for cookbook features

* add UI elements for cookbook features

* fix tests

* fix type error
This commit is contained in:
Hayden 2022-04-01 09:50:31 -08:00 committed by GitHub
parent 1092e0ce7c
commit cfaac2e060
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 374 additions and 97 deletions

View file

@ -0,0 +1,57 @@
"""add tags to cookbooks
Revision ID: 59eb59135381
Revises: f1a2dbee5fe9
Create Date: 2022-03-31 19:19:55.428965
"""
import sqlalchemy as sa
import mealie.db.migration_types
from alembic import op
# revision identifiers, used by Alembic.
revision = "59eb59135381"
down_revision = "f1a2dbee5fe9"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"cookbooks_to_tags",
sa.Column("cookbook_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("tag_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["cookbook_id"],
["cookbooks.id"],
),
sa.ForeignKeyConstraint(
["tag_id"],
["tags.id"],
),
)
op.create_table(
"cookbooks_to_tools",
sa.Column("cookbook_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("tool_id", mealie.db.migration_types.GUID(), nullable=True),
sa.ForeignKeyConstraint(
["cookbook_id"],
["cookbooks.id"],
),
sa.ForeignKeyConstraint(
["tool_id"],
["tools.id"],
),
)
op.add_column("cookbooks", sa.Column("public", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("cookbooks", "public")
op.drop_table("cookbooks_to_tools")
op.drop_table("cookbooks_to_tags")
# ### end Alembic commands ###

View file

@ -1,32 +1,18 @@
import { BaseCRUDAPI } from "../_base"; import { BaseCRUDAPI } from "../_base";
import { CategoryBase } from "~/types/api-types/recipe"; import { CreateCookBook, RecipeCookBook, UpdateCookBook } from "~/types/api-types/cookbook";
import { RecipeCategory } from "~/types/api-types/user";
const prefix = "/api"; const prefix = "/api";
export interface CreateCookBook {
name: string;
}
export interface CookBook extends CreateCookBook {
id: number;
slug: string;
description: string;
position: number;
group_id: number;
categories: RecipeCategory[] | CategoryBase[];
}
const routes = { const routes = {
cookbooks: `${prefix}/groups/cookbooks`, cookbooks: `${prefix}/groups/cookbooks`,
cookbooksId: (id: number) => `${prefix}/groups/cookbooks/${id}`, cookbooksId: (id: number) => `${prefix}/groups/cookbooks/${id}`,
}; };
export class CookbookAPI extends BaseCRUDAPI<CookBook, CreateCookBook> { export class CookbookAPI extends BaseCRUDAPI<RecipeCookBook, CreateCookBook> {
baseRoute: string = routes.cookbooks; baseRoute: string = routes.cookbooks;
itemRoute = routes.cookbooksId; itemRoute = routes.cookbooksId;
async updateAll(payload: CookBook[]) { async updateAll(payload: UpdateCookBook[]) {
return await this.requests.put(this.baseRoute, payload); return await this.requests.put(this.baseRoute, payload);
} }
} }

View file

@ -0,0 +1,109 @@
<template>
<v-autocomplete
v-model="selected"
:items="items"
:value="value"
:label="label"
chips
deletable-chips
item-text="name"
multiple
:prepend-inner-icon="$globals.icons.tags"
return-object
v-bind="inputAttrs"
>
<template #selection="data">
<v-chip
:key="data.index"
class="ma-1"
:input-value="data.selected"
close
label
color="accent"
dark
@click:close="removeByIndex(data.index)"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
</v-autocomplete>
</template>
<script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { computed, onMounted } from "vue-demi";
import { RecipeCategory, RecipeTag } from "~/types/api-types/user";
import { RecipeTool } from "~/types/api-types/admin";
type OrganizerType = "tag" | "category" | "tool";
export default defineComponent({
props: {
value: {
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[] | undefined,
required: true,
},
/**
* The type of organizer to use.
*/
selectorType: {
type: String as () => OrganizerType,
required: true,
},
/**
* List of items that are available to be chosen from
*/
items: {
type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[],
required: true,
},
inputAttrs: {
type: Object as () => Record<string, any>,
default: () => ({}),
},
},
setup(props, context) {
const selected = computed({
get: () => props.value,
set: (val) => {
context.emit("input", val);
},
});
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const { i18n } = useContext();
const label = computed(() => {
switch (props.selectorType) {
case "tag":
return i18n.t("tag.tags");
case "category":
return i18n.t("category.categories");
case "tool":
return "Tools";
}
});
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
return {
label,
selected,
removeByIndex,
};
},
});
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="text-center">
<v-menu top offset-y left open-on-hover>
<template #activator="{ on, attrs }">
<v-btn icon v-bind="attrs" v-on="on" @click.stop>
<v-icon> {{ $globals.icons.help }} </v-icon>
</v-btn>
</template>
<v-card max-width="300px">
<v-card-text>
<slot></slot>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
});
</script>
<style scoped></style>

View file

@ -1,9 +1,9 @@
import { useAsync, ref, Ref } from "@nuxtjs/composition-api"; import { useAsync, ref, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils"; import { useAsyncKey } from "./use-utils";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { CookBook } from "~/api/class-interfaces/group-cookbooks"; import { ReadCookBook, RecipeCookBook, UpdateCookBook } from "~/types/api-types/cookbook";
let cookbookStore: Ref<CookBook[] | null> | null = null; let cookbookStore: Ref<ReadCookBook[] | null> | null = null;
export const useCookbook = function () { export const useCookbook = function () {
function getOne(id: string | number) { function getOne(id: string | number) {
@ -60,13 +60,13 @@ export const useCookbooks = function () {
loading.value = false; loading.value = false;
}, },
async updateOne(updateData: CookBook) { async updateOne(updateData: UpdateCookBook) {
if (!updateData.id) { if (!updateData.id) {
return; return;
} }
loading.value = true; loading.value = true;
const { data } = await api.cookbooks.updateOne(updateData.id, updateData); const { data } = await api.cookbooks.updateOne(updateData.id, updateData as RecipeCookBook);
if (data && cookbookStore?.value) { if (data && cookbookStore?.value) {
this.refreshAll(); this.refreshAll();
} }

View file

@ -41,7 +41,7 @@
<v-icon>{{ $globals.icons.translate }}</v-icon> <v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon> </v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-list-item-title>{{ $t('sidebar.language') }}</v-list-item-title> <v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" /> <LanguageDialog v-model="languageDialog" />
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
@ -103,7 +103,7 @@ export default defineComponent({
return { return {
icon: $globals.icons.pages, icon: $globals.icons.pages,
title: cookbook.name, title: cookbook.name,
to: `/cookbooks/${cookbook.slug}`, to: `/cookbooks/${cookbook.slug as string}`,
}; };
}); });
}); });

View file

@ -9,16 +9,10 @@
{{ book.description }} {{ book.description }}
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-tabs v-model="tab" show-arrows>
<v-tab v-for="(cat, index) in book.categories" :key="index"> <v-container class="pa-0">
{{ cat.name }} <RecipeCardSection class="mb-5 mx-1" :recipes="book.recipes" />
</v-tab> </v-container>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item v-for="(cat, idx) in book.categories" :key="`tabs` + idx">
<RecipeCardSection class="mb-5 mx-1" :recipes="cat.recipes" />
</v-tab-item>
</v-tabs-items>
</v-container> </v-container>
</template> </template>
@ -26,6 +20,7 @@
import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api"; import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook } from "~/composables/use-group-cookbooks"; import { useCookbook } from "~/composables/use-group-cookbooks";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
setup() { setup() {
@ -51,6 +46,3 @@ export default defineComponent({
head: {}, // Must include for useMeta head: {}, // Must include for useMeta
}); });
</script> </script>
<style scoped>
</style>

View file

@ -5,7 +5,9 @@
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img> <v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
</template> </template>
<template #title> Cookbooks </template> <template #title> Cookbooks </template>
Arrange and edit your cookbooks here. Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook
will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the
cookbook.
</BasePageTitle> </BasePageTitle>
<BaseButton create @click="actions.createOne()" /> <BaseButton create @click="actions.createOne()" />
@ -31,10 +33,24 @@
</template> </template>
</v-expansion-panel-header> </v-expansion-panel-header>
<v-expansion-panel-content> <v-expansion-panel-content>
<v-card-text> <v-card-text v-if="cookbooks">
<v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field> <v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field>
<v-textarea v-model="cookbooks[index].description" auto-grow :rows="2" label="Description"></v-textarea> <v-textarea v-model="cookbooks[index].description" auto-grow :rows="2" label="Description"></v-textarea>
<DomainRecipeCategoryTagSelector v-model="cookbooks[index].categories" /> <RecipeOrganizerSelector
v-model="cookbooks[index].categories"
:items="allCategories || []"
selector-type="category"
/>
<RecipeOrganizerSelector v-model="cookbooks[index].tags" :items="allTags || []" selector-type="tag" />
<RecipeOrganizerSelector v-model="cookbooks[index].tools" :items="tools || []" selector-type="tool" />
<v-switch v-model="cookbooks[index].public">
<template #label>
Public Cookbook
<HelpIcon class="ml-4">
Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.
</HelpIcon>
</template>
</v-switch>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@ -42,12 +58,12 @@
:buttons="[ :buttons="[
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: $t('general.delete'), text: $tc('general.delete'),
event: 'delete', event: 'delete',
}, },
{ {
icon: $globals.icons.save, icon: $globals.icons.save,
text: $t('general.save'), text: $tc('general.save'),
event: 'save', event: 'save',
}, },
]" ]"
@ -66,15 +82,27 @@
import { defineComponent } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import { useCookbooks } from "@/composables/use-group-cookbooks"; import { useCookbooks } from "@/composables/use-group-cookbooks";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useCategories, useTags, useTools } from "~/composables/recipes";
export default defineComponent({ export default defineComponent({
components: { draggable }, components: { draggable, RecipeOrganizerSelector },
setup() { setup() {
const { cookbooks, actions } = useCookbooks(); const { cookbooks, actions } = useCookbooks();
const { tools } = useTools();
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
const { allTags, useAsyncGetAll: getAllTags } = useTags();
getAllCategories();
getAllTags();
return { return {
allCategories,
allTags,
cookbooks, cookbooks,
actions, actions,
tools,
}; };
}, },
head() { head() {

View file

@ -124,6 +124,7 @@ export interface RecipeIngredient {
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; quantity?: number;
originalText?: string;
referenceId?: string; referenceId?: string;
} }
export interface IngredientUnit { export interface IngredientUnit {

View file

@ -15,22 +15,46 @@ export interface CreateCookBook {
description?: string; description?: string;
slug?: string; slug?: string;
position?: number; position?: number;
public?: boolean;
categories?: CategoryBase[]; categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
}
export interface TagBase {
name: string;
id: string;
slug: string;
}
export interface RecipeTool {
id: string;
name: string;
slug: string;
onHand?: boolean;
} }
export interface ReadCookBook { export interface ReadCookBook {
name: string; name: string;
description?: string; description?: string;
slug?: string; slug?: string;
position?: number; position?: number;
public?: boolean;
categories?: CategoryBase[]; categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
groupId: string; groupId: string;
id: string; id: string;
} }
export interface RecipeCategoryResponse { export interface RecipeCookBook {
name: string; name: string;
description?: string;
slug?: string;
position?: number;
public?: boolean;
categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
groupId: string;
id: string; id: string;
slug: string; recipes: RecipeSummary[];
recipes?: RecipeSummary[];
} }
export interface RecipeSummary { export interface RecipeSummary {
id?: string; id?: string;
@ -64,12 +88,6 @@ export interface RecipeTag {
name: string; name: string;
slug: string; slug: string;
} }
export interface RecipeTool {
id: string;
name: string;
slug: string;
onHand?: boolean;
}
export interface RecipeIngredient { export interface RecipeIngredient {
title?: string; title?: string;
note?: string; note?: string;
@ -77,6 +95,7 @@ export interface RecipeIngredient {
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; quantity?: number;
originalText?: string;
referenceId?: string; referenceId?: string;
} }
export interface IngredientUnit { export interface IngredientUnit {
@ -110,21 +129,15 @@ export interface CreateIngredientFood {
description?: string; description?: string;
labelId?: string; labelId?: string;
} }
export interface RecipeCookBook {
name: string;
description?: string;
slug?: string;
position?: number;
categories: RecipeCategoryResponse[];
groupId: string;
id: string;
}
export interface SaveCookBook { export interface SaveCookBook {
name: string; name: string;
description?: string; description?: string;
slug?: string; slug?: string;
position?: number; position?: number;
public?: boolean;
categories?: CategoryBase[]; categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
groupId: string; groupId: string;
} }
export interface UpdateCookBook { export interface UpdateCookBook {
@ -132,7 +145,10 @@ export interface UpdateCookBook {
description?: string; description?: string;
slug?: string; slug?: string;
position?: number; position?: number;
public?: boolean;
categories?: CategoryBase[]; categories?: CategoryBase[];
tags?: TagBase[];
tools?: RecipeTool[];
groupId: string; groupId: string;
id: string; id: string;
} }

View file

@ -279,6 +279,7 @@ export interface RecipeIngredient {
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; quantity?: number;
originalText?: string;
referenceId?: string; referenceId?: string;
} }
export interface CreateIngredientUnit { export interface CreateIngredientUnit {

View file

@ -140,6 +140,7 @@ export interface RecipeIngredient {
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; quantity?: number;
originalText?: string;
referenceId?: string; referenceId?: string;
} }
export interface IngredientUnit { export interface IngredientUnit {

View file

@ -153,6 +153,7 @@ export interface RecipeIngredient {
food?: IngredientFood | CreateIngredientFood; food?: IngredientFood | CreateIngredientFood;
disableAmount?: boolean; disableAmount?: boolean;
quantity?: number; quantity?: number;
originalText?: string;
referenceId?: string; referenceId?: string;
} }
export interface IngredientUnit { export interface IngredientUnit {

View file

@ -5,6 +5,7 @@ export interface Icon {
// General // General
chart: string; chart: string;
wrench: string; wrench: string;
help: string;
bowlMixOutline: string; bowlMixOutline: string;
foods: string; foods: string;
units: string; units: string;

View file

@ -107,6 +107,7 @@ import {
mdiBowlMixOutline, mdiBowlMixOutline,
mdiWrench, mdiWrench,
mdiChartLine, mdiChartLine,
mdiHelpCircleOutline,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -118,6 +119,7 @@ export const icons = {
// General // General
bowlMixOutline: mdiBowlMixOutline, bowlMixOutline: mdiBowlMixOutline,
help: mdiHelpCircleOutline,
foods: mdiFoodApple, foods: mdiFoodApple,
units: mdiBeakerOutline, units: mdiBeakerOutline,
alert: mdiAlert, alert: mdiAlert,

View file

@ -1,8 +1,10 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init, guid from .._model_utils import auto_init, guid
from ..recipe.category import Category, cookbooks_to_categories from ..recipe.category import Category, cookbooks_to_categories
from ..recipe.tag import Tag, cookbooks_to_tags
from ..recipe.tool import Tool, cookbooks_to_tools
class CookBook(SqlAlchemyBase, BaseMixins): class CookBook(SqlAlchemyBase, BaseMixins):
@ -10,14 +12,17 @@ class CookBook(SqlAlchemyBase, BaseMixins):
id = Column(guid.GUID, primary_key=True, default=guid.GUID.generate) id = Column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position = Column(Integer, nullable=False, default=1) position = Column(Integer, nullable=False, default=1)
group_id = Column(guid.GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="cookbooks")
name = Column(String, nullable=False) name = Column(String, nullable=False)
slug = Column(String, nullable=False) slug = Column(String, nullable=False)
description = Column(String, default="") description = Column(String, default="")
public = Column(Boolean, default=False)
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)
tags = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
group_id = Column(guid.GUID, ForeignKey("groups.id")) tools = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
group = orm.relationship("Group", back_populates="cookbooks")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View file

@ -23,6 +23,13 @@ plan_rules_to_tags = sa.Table(
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")), sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
) )
cookbooks_to_tags = sa.Table(
"cookbooks_to_tags",
SqlAlchemyBase.metadata,
sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id")),
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
)
class Tag(SqlAlchemyBase, BaseMixins): class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags" __tablename__ = "tags"

View file

@ -12,6 +12,13 @@ recipes_to_tools = Table(
Column("tool_id", GUID, ForeignKey("tools.id")), Column("tool_id", GUID, ForeignKey("tools.id")),
) )
cookbooks_to_tools = Table(
"cookbooks_to_tools",
SqlAlchemyBase.metadata,
Column("cookbook_id", GUID, ForeignKey("cookbooks.id")),
Column("tool_id", GUID, ForeignKey("tools.id")),
)
class Tool(SqlAlchemyBase, BaseMixins): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"

View file

@ -13,8 +13,10 @@ from mealie.db.models.recipe.ingredient import RecipeIngredient
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
@ -123,6 +125,40 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.all() .all()
] ]
def _category_tag_filters(
self,
categories: list[CategoryBase] | None = None,
tags: list[TagBase] | None = None,
tools: list[RecipeTool] | None = None,
) -> list:
fltr = [
RecipeModel.group_id == self.group_id,
]
if categories:
cat_ids = [x.id for x in categories]
fltr.extend(RecipeModel.recipe_category.any(Category.id.is_(cat_id)) for cat_id in cat_ids)
if tags:
tag_ids = [x.id for x in tags]
fltr.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids) # type:ignore
if tools:
tool_ids = [x.id for x in tools]
fltr.extend(RecipeModel.tools.any(Tool.id.is_(tool_id)) for tool_id in tool_ids)
return fltr
def by_category_and_tags(
self,
categories: list[CategoryBase] | None = None,
tags: list[TagBase] | None = None,
tools: list[RecipeTool] | None = None,
) -> list[Recipe]:
fltr = self._category_tag_filters(categories, tags, tools)
return [self.schema.from_orm(x) for x in self.session.query(RecipeModel).filter(*fltr).all()]
def get_random_by_categories_and_tags( def get_random_by_categories_and_tags(
self, categories: list[RecipeCategory], tags: list[RecipeTag] self, categories: list[RecipeCategory], tags: list[RecipeTag]
) -> list[Recipe]: ) -> list[Recipe]:
@ -135,17 +171,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
# See Also: # See Also:
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy # - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
filters = [ filters = self._category_tag_filters(categories, tags) # type: ignore
RecipeModel.group_id == self.group_id,
]
if categories:
cat_ids = [x.id for x in categories]
filters.extend(RecipeModel.recipe_category.any(Category.id.is_(cat_id)) for cat_id in cat_ids)
if tags:
tag_ids = [x.id for x in tags]
filters.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids)
return [ return [
self.schema.from_orm(x) self.schema.from_orm(x)

View file

@ -8,15 +8,10 @@ from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.mixins import CrudMixins from mealie.routes._base.mixins import CrudMixins
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.schema.recipe.recipe_category import RecipeCategoryResponse
router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"]) router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"])
class CookBookRecipeResponse(RecipeCookBook):
categories: list[RecipeCategoryResponse]
@controller(router) @controller(router)
class GroupCookbookController(BaseUserController): class GroupCookbookController(BaseUserController):
@cached_property @cached_property
@ -37,13 +32,13 @@ class GroupCookbookController(BaseUserController):
self.registered_exceptions, self.registered_exceptions,
) )
@router.get("", response_model=list[RecipeCookBook]) @router.get("", response_model=list[ReadCookBook])
def get_all(self): def get_all(self):
items = self.repo.get_all() items = self.repo.get_all()
items.sort(key=lambda x: x.position) items.sort(key=lambda x: x.position)
return items return items
@router.post("", response_model=RecipeCookBook, status_code=201) @router.post("", response_model=ReadCookBook, status_code=201)
def create_one(self, data: CreateCookBook): def create_one(self, data: CreateCookBook):
data = mapper.cast(data, SaveCookBook, group_id=self.group_id) data = mapper.cast(data, SaveCookBook, group_id=self.group_id)
return self.mixins.create_one(data) return self.mixins.create_one(data)
@ -58,20 +53,25 @@ class GroupCookbookController(BaseUserController):
return updated return updated
@router.get("/{item_id}", response_model=CookBookRecipeResponse) @router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str): def get_one(self, item_id: UUID4 | str):
match_attr = "slug" if isinstance(item_id, str) else "id" match_attr = "slug" if isinstance(item_id, str) else "id"
book = self.repo.get_one(item_id, match_attr, override_schema=CookBookRecipeResponse) cookbook = self.repo.get_one(item_id, match_attr)
if book is None: if cookbook is None:
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return book return cookbook.cast(
RecipeCookBook,
recipes=self.repos.recipes.by_group(self.group_id).by_category_and_tags(
cookbook.categories, cookbook.tags, cookbook.tools
),
)
@router.put("/{item_id}", response_model=RecipeCookBook) @router.put("/{item_id}", response_model=ReadCookBook)
def update_one(self, item_id: str, data: CreateCookBook): def update_one(self, item_id: str, data: CreateCookBook):
return self.mixins.update_one(data, item_id) return self.mixins.update_one(data, item_id) # type: ignore
@router.delete("/{item_id}", response_model=RecipeCookBook) @router.delete("/{item_id}", response_model=ReadCookBook)
def delete_one(self, item_id: str): def delete_one(self, item_id: str):
return self.mixins.delete_one(item_id) return self.mixins.delete_one(item_id)

View file

@ -2,19 +2,23 @@ from pydantic import UUID4, validator
from slugify import slugify from slugify import slugify
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
from ..recipe.recipe_category import CategoryBase, RecipeCategoryResponse from ..recipe.recipe_category import CategoryBase, TagBase
class CreateCookBook(MealieModel): class CreateCookBook(MealieModel):
name: str name: str
description: str = "" description: str = ""
slug: str = None slug: str | None = None
position: int = 1 position: int = 1
public: bool = False
categories: list[CategoryBase] = [] categories: list[CategoryBase] = []
tags: list[TagBase] = []
tools: list[RecipeTool] = []
@validator("slug", always=True, pre=True) @validator("slug", always=True, pre=True)
def validate_slug(slug: str, values): def validate_slug(slug: str, values): # type: ignore
name: str = values["name"] name: str = values["name"]
calc_slug: str = slugify(name) calc_slug: str = slugify(name)
@ -42,7 +46,7 @@ class ReadCookBook(UpdateCookBook):
class RecipeCookBook(ReadCookBook): class RecipeCookBook(ReadCookBook):
group_id: UUID4 group_id: UUID4
categories: list[RecipeCategoryResponse] recipes: list[RecipeSummary]
class Config: class Config:
orm_mode = True orm_mode = True

View file

@ -9,7 +9,6 @@ from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook
from tests import utils from tests import utils
from tests.utils.assertion_helpers import assert_ignore_keys
from tests.utils.factories import random_string from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -69,7 +68,13 @@ def test_read_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks:
sample = random.choice(cookbooks) sample = random.choice(cookbooks)
response = api_client.get(Routes.item(sample.id), headers=unique_user.token) response = api_client.get(Routes.item(sample.id), headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
assert_ignore_keys(response.json(), sample.data)
page_data = response.json()
assert page_data["id"] == str(sample.id)
assert page_data["slug"] == sample.slug
assert page_data["name"] == sample.name
assert page_data["groupId"] == str(unique_user.group_id)
def test_update_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]): def test_update_cookbook(api_client: TestClient, unique_user: TestUser, cookbooks: list[TestCookbook]):

View file

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [ ALEMBIC_VERSIONS = [
{"version_num": "f1a2dbee5fe9"}, {"version_num": "59eb59135381"},
] ]