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:
parent
1092e0ce7c
commit
cfaac2e060
23 changed files with 374 additions and 97 deletions
|
@ -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 ###
|
|
@ -1,32 +1,18 @@
|
|||
import { BaseCRUDAPI } from "../_base";
|
||||
import { CategoryBase } from "~/types/api-types/recipe";
|
||||
import { RecipeCategory } from "~/types/api-types/user";
|
||||
import { CreateCookBook, RecipeCookBook, UpdateCookBook } from "~/types/api-types/cookbook";
|
||||
|
||||
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 = {
|
||||
cookbooks: `${prefix}/groups/cookbooks`,
|
||||
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;
|
||||
itemRoute = routes.cookbooksId;
|
||||
|
||||
async updateAll(payload: CookBook[]) {
|
||||
async updateAll(payload: UpdateCookBook[]) {
|
||||
return await this.requests.put(this.baseRoute, payload);
|
||||
}
|
||||
}
|
||||
|
|
109
frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue
Normal file
109
frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue
Normal 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>
|
28
frontend/components/global/HelpIcon.vue
Normal file
28
frontend/components/global/HelpIcon.vue
Normal 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>
|
|
@ -1,9 +1,9 @@
|
|||
import { useAsync, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { useAsyncKey } from "./use-utils";
|
||||
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 () {
|
||||
function getOne(id: string | number) {
|
||||
|
@ -60,13 +60,13 @@ export const useCookbooks = function () {
|
|||
|
||||
loading.value = false;
|
||||
},
|
||||
async updateOne(updateData: CookBook) {
|
||||
async updateOne(updateData: UpdateCookBook) {
|
||||
if (!updateData.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.refreshAll();
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<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" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
@ -103,7 +103,7 @@ export default defineComponent({
|
|||
return {
|
||||
icon: $globals.icons.pages,
|
||||
title: cookbook.name,
|
||||
to: `/cookbooks/${cookbook.slug}`,
|
||||
to: `/cookbooks/${cookbook.slug as string}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,16 +9,10 @@
|
|||
{{ book.description }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-tabs v-model="tab" show-arrows>
|
||||
<v-tab v-for="(cat, index) in book.categories" :key="index">
|
||||
{{ cat.name }}
|
||||
</v-tab>
|
||||
</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 class="pa-0">
|
||||
<RecipeCardSection class="mb-5 mx-1" :recipes="book.recipes" />
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
@ -26,6 +20,7 @@
|
|||
import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbook } from "~/composables/use-group-cookbooks";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeCardSection },
|
||||
setup() {
|
||||
|
@ -51,6 +46,3 @@ export default defineComponent({
|
|||
head: {}, // Must include for useMeta
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -5,7 +5,9 @@
|
|||
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
|
||||
</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>
|
||||
|
||||
<BaseButton create @click="actions.createOne()" />
|
||||
|
@ -31,10 +33,24 @@
|
|||
</template>
|
||||
</v-expansion-panel-header>
|
||||
<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-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-actions>
|
||||
<v-spacer></v-spacer>
|
||||
|
@ -42,12 +58,12 @@
|
|||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $t('general.save'),
|
||||
text: $tc('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
|
@ -66,15 +82,27 @@
|
|||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import draggable from "vuedraggable";
|
||||
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({
|
||||
components: { draggable },
|
||||
components: { draggable, RecipeOrganizerSelector },
|
||||
setup() {
|
||||
const { cookbooks, actions } = useCookbooks();
|
||||
|
||||
const { tools } = useTools();
|
||||
const { allCategories, useAsyncGetAll: getAllCategories } = useCategories();
|
||||
const { allTags, useAsyncGetAll: getAllTags } = useTags();
|
||||
|
||||
getAllCategories();
|
||||
getAllTags();
|
||||
|
||||
return {
|
||||
allCategories,
|
||||
allTags,
|
||||
cookbooks,
|
||||
actions,
|
||||
tools,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
|
|
@ -124,6 +124,7 @@ export interface RecipeIngredient {
|
|||
food?: IngredientFood | CreateIngredientFood;
|
||||
disableAmount?: boolean;
|
||||
quantity?: number;
|
||||
originalText?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
|
|
|
@ -15,22 +15,46 @@ export interface CreateCookBook {
|
|||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
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 {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface RecipeCategoryResponse {
|
||||
export interface RecipeCookBook {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
groupId: string;
|
||||
id: string;
|
||||
slug: string;
|
||||
recipes?: RecipeSummary[];
|
||||
recipes: RecipeSummary[];
|
||||
}
|
||||
export interface RecipeSummary {
|
||||
id?: string;
|
||||
|
@ -64,12 +88,6 @@ export interface RecipeTag {
|
|||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface RecipeTool {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
}
|
||||
export interface RecipeIngredient {
|
||||
title?: string;
|
||||
note?: string;
|
||||
|
@ -77,6 +95,7 @@ export interface RecipeIngredient {
|
|||
food?: IngredientFood | CreateIngredientFood;
|
||||
disableAmount?: boolean;
|
||||
quantity?: number;
|
||||
originalText?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
|
@ -110,21 +129,15 @@ export interface CreateIngredientFood {
|
|||
description?: string;
|
||||
labelId?: string;
|
||||
}
|
||||
export interface RecipeCookBook {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
categories: RecipeCategoryResponse[];
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface SaveCookBook {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
groupId: string;
|
||||
}
|
||||
export interface UpdateCookBook {
|
||||
|
@ -132,7 +145,10 @@ export interface UpdateCookBook {
|
|||
description?: string;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
public?: boolean;
|
||||
categories?: CategoryBase[];
|
||||
tags?: TagBase[];
|
||||
tools?: RecipeTool[];
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
|
|
|
@ -279,6 +279,7 @@ export interface RecipeIngredient {
|
|||
food?: IngredientFood | CreateIngredientFood;
|
||||
disableAmount?: boolean;
|
||||
quantity?: number;
|
||||
originalText?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
export interface CreateIngredientUnit {
|
||||
|
|
|
@ -140,6 +140,7 @@ export interface RecipeIngredient {
|
|||
food?: IngredientFood | CreateIngredientFood;
|
||||
disableAmount?: boolean;
|
||||
quantity?: number;
|
||||
originalText?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
|
|
|
@ -153,6 +153,7 @@ export interface RecipeIngredient {
|
|||
food?: IngredientFood | CreateIngredientFood;
|
||||
disableAmount?: boolean;
|
||||
quantity?: number;
|
||||
originalText?: string;
|
||||
referenceId?: string;
|
||||
}
|
||||
export interface IngredientUnit {
|
||||
|
|
|
@ -5,6 +5,7 @@ export interface Icon {
|
|||
// General
|
||||
chart: string;
|
||||
wrench: string;
|
||||
help: string;
|
||||
bowlMixOutline: string;
|
||||
foods: string;
|
||||
units: string;
|
||||
|
|
|
@ -107,6 +107,7 @@ import {
|
|||
mdiBowlMixOutline,
|
||||
mdiWrench,
|
||||
mdiChartLine,
|
||||
mdiHelpCircleOutline,
|
||||
} from "@mdi/js";
|
||||
|
||||
export const icons = {
|
||||
|
@ -118,6 +119,7 @@ export const icons = {
|
|||
|
||||
// General
|
||||
bowlMixOutline: mdiBowlMixOutline,
|
||||
help: mdiHelpCircleOutline,
|
||||
foods: mdiFoodApple,
|
||||
units: mdiBeakerOutline,
|
||||
alert: mdiAlert,
|
||||
|
|
|
@ -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_utils import auto_init, guid
|
||||
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):
|
||||
|
@ -10,14 +12,17 @@ class CookBook(SqlAlchemyBase, BaseMixins):
|
|||
id = Column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
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)
|
||||
slug = Column(String, nullable=False)
|
||||
description = Column(String, default="")
|
||||
public = Column(Boolean, default=False)
|
||||
|
||||
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True)
|
||||
|
||||
group_id = Column(guid.GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="cookbooks")
|
||||
tags = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
|
||||
tools = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
|
|
|
@ -23,6 +23,13 @@ plan_rules_to_tags = sa.Table(
|
|||
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):
|
||||
__tablename__ = "tags"
|
||||
|
|
|
@ -12,6 +12,13 @@ recipes_to_tools = Table(
|
|||
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):
|
||||
__tablename__ = "tools"
|
||||
|
|
|
@ -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.settings import RecipeSettings
|
||||
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.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
|
||||
|
||||
|
@ -123,6 +125,40 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||
.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(
|
||||
self, categories: list[RecipeCategory], tags: list[RecipeTag]
|
||||
) -> list[Recipe]:
|
||||
|
@ -135,17 +171,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||
# See Also:
|
||||
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
|
||||
|
||||
filters = [
|
||||
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)
|
||||
filters = self._category_tag_filters(categories, tags) # type: ignore
|
||||
|
||||
return [
|
||||
self.schema.from_orm(x)
|
||||
|
|
|
@ -8,15 +8,10 @@ from mealie.routes._base import BaseUserController, controller
|
|||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
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"])
|
||||
|
||||
|
||||
class CookBookRecipeResponse(RecipeCookBook):
|
||||
categories: list[RecipeCategoryResponse]
|
||||
|
||||
|
||||
@controller(router)
|
||||
class GroupCookbookController(BaseUserController):
|
||||
@cached_property
|
||||
|
@ -37,13 +32,13 @@ class GroupCookbookController(BaseUserController):
|
|||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
@router.get("", response_model=list[RecipeCookBook])
|
||||
@router.get("", response_model=list[ReadCookBook])
|
||||
def get_all(self):
|
||||
items = self.repo.get_all()
|
||||
items.sort(key=lambda x: x.position)
|
||||
return items
|
||||
|
||||
@router.post("", response_model=RecipeCookBook, status_code=201)
|
||||
@router.post("", response_model=ReadCookBook, status_code=201)
|
||||
def create_one(self, data: CreateCookBook):
|
||||
data = mapper.cast(data, SaveCookBook, group_id=self.group_id)
|
||||
return self.mixins.create_one(data)
|
||||
|
@ -58,20 +53,25 @@ class GroupCookbookController(BaseUserController):
|
|||
|
||||
return updated
|
||||
|
||||
@router.get("/{item_id}", response_model=CookBookRecipeResponse)
|
||||
@router.get("/{item_id}", response_model=RecipeCookBook)
|
||||
def get_one(self, item_id: UUID4 | str):
|
||||
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)
|
||||
|
||||
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):
|
||||
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):
|
||||
return self.mixins.delete_one(item_id)
|
||||
|
|
|
@ -2,19 +2,23 @@ from pydantic import UUID4, validator
|
|||
from slugify import slugify
|
||||
|
||||
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):
|
||||
name: str
|
||||
description: str = ""
|
||||
slug: str = None
|
||||
slug: str | None = None
|
||||
position: int = 1
|
||||
public: bool = False
|
||||
categories: list[CategoryBase] = []
|
||||
tags: list[TagBase] = []
|
||||
tools: list[RecipeTool] = []
|
||||
|
||||
@validator("slug", always=True, pre=True)
|
||||
def validate_slug(slug: str, values):
|
||||
def validate_slug(slug: str, values): # type: ignore
|
||||
name: str = values["name"]
|
||||
calc_slug: str = slugify(name)
|
||||
|
||||
|
@ -42,7 +46,7 @@ class ReadCookBook(UpdateCookBook):
|
|||
|
||||
class RecipeCookBook(ReadCookBook):
|
||||
group_id: UUID4
|
||||
categories: list[RecipeCategoryResponse]
|
||||
recipes: list[RecipeSummary]
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
|
@ -9,7 +9,6 @@ from pydantic import UUID4
|
|||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook, SaveCookBook
|
||||
from tests import utils
|
||||
from tests.utils.assertion_helpers import assert_ignore_keys
|
||||
from tests.utils.factories import random_string
|
||||
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)
|
||||
response = api_client.get(Routes.item(sample.id), headers=unique_user.token)
|
||||
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]):
|
||||
|
|
|
@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
|
|||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||
|
||||
ALEMBIC_VERSIONS = [
|
||||
{"version_num": "f1a2dbee5fe9"},
|
||||
{"version_num": "59eb59135381"},
|
||||
]
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue