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 { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
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 { 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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]):
|
||||||
|
|
|
@ -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"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue