feat(backend): add rename tag, tool, category support (#875)

This commit is contained in:
Hayden 2021-12-10 19:48:06 -09:00 committed by GitHub
parent 8d77f4b31e
commit e109ac0f47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 573 additions and 163 deletions

View File

@ -1,4 +1,5 @@
import { BaseCRUDAPI } from "../_base"; import { BaseCRUDAPI } from "../_base";
import { Recipe } from "~/types/api-types/recipe";
const prefix = "/api"; const prefix = "/api";
@ -9,14 +10,24 @@ export interface CreateTool {
export interface Tool extends CreateTool { export interface Tool extends CreateTool {
id: number; id: number;
slug: string;
}
export interface RecipeToolResponse extends Tool {
recipes: Recipe[];
} }
const routes = { const routes = {
tools: `${prefix}/tools`, tools: `${prefix}/tools`,
toolsId: (id: string) => `${prefix}/tools/${id}`, toolsId: (id: string) => `${prefix}/tools/${id}`,
toolsSlug: (id: string) => `${prefix}/tools/slug/${id}`,
}; };
export class ToolsApi extends BaseCRUDAPI<Tool, CreateTool> { export class ToolsApi extends BaseCRUDAPI<Tool, CreateTool> {
baseRoute: string = routes.tools; baseRoute: string = routes.tools;
itemRoute = routes.toolsId; itemRoute = routes.toolsId;
async byslug(slug: string) {
return await this.requests.get<Tool>(routes.toolsSlug(slug));
}
} }

View File

@ -1,10 +1,12 @@
<template> <template>
<div> <div>
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded"> <v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded">
<v-icon v-if="title" large left> <slot name="title">
{{ displayTitleIcon }} <v-icon v-if="title" large left>
</v-icon> {{ displayTitleIcon }}
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
</slot>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom"> <v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
<v-icon :left="!$vuetify.breakpoint.xsOnly"> <v-icon :left="!$vuetify.breakpoint.xsOnly">

View File

@ -0,0 +1,106 @@
<template>
<div v-if="items">
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-icon large left>
{{ icon }}
</v-icon>
<v-toolbar-title class="headline"> {{ headline }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card-actions>
<v-icon>
{{ icon }}
</v-icon>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</section>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, computed } from "@nuxtjs/composition-api";
type ItemType = "tags" | "categories" | "tools";
const ItemTypes = {
tag: "tags",
category: "categories",
tool: "tools",
};
interface GenericItem {
name: string;
slug: string;
}
export default defineComponent({
props: {
itemType: {
type: String as () => ItemType,
required: true,
},
items: {
type: Array as () => GenericItem[],
required: true,
},
},
setup(props) {
// @ts-ignore
const { i18n, $globals } = useContext();
const state = reactive({
headline: "tags",
icon: $globals.icons.tags,
});
switch (props.itemType) {
case ItemTypes.tag:
state.headline = i18n.t("tag.tags") as string;
break;
case ItemTypes.category:
state.headline = i18n.t("recipe.categories") as string;
break;
case ItemTypes.tool:
state.headline = "Tools";
state.icon = $globals.icons.potSteam;
break;
default:
break;
}
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<any> } = {};
if (!props.items) return byLetter;
props.items.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!byLetter[letter]) {
byLetter[letter] = [];
}
byLetter[letter].push(item);
});
return byLetter;
});
return {
...toRefs(state),
itemsSorted,
};
},
head: {
title: "vbase-nuxt",
},
});
</script>

View File

@ -181,6 +181,11 @@ export default defineComponent({
to: "/recipes/tags", to: "/recipes/tags",
title: this.$t("sidebar.tags"), title: this.$t("sidebar.tags"),
}, },
{
icon: this.$globals.icons.potSteam,
to: "/recipes/tools",
title: "Tools",
},
], ],
}; };
}, },

View File

@ -317,7 +317,10 @@ export default {
// https://nuxtjs.org/docs/2.x/configuration-glossary/configuration-build // https://nuxtjs.org/docs/2.x/configuration-glossary/configuration-build
analyze: process.env.NODE_ENV !== "production", analyze: process.env.NODE_ENV !== "production",
babel: { babel: {
plugins: [["@babel/plugin-proposal-private-property-in-object", { loose: true }]], plugins: [
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
["@nuxtjs/composition-api/dist/babel-plugin"],
],
}, },
transpile: process.env.NODE_ENV !== "production" ? [/@vue[\\/]composition-api/] : null, transpile: process.env.NODE_ENV !== "production" ? [/@vue[\\/]composition-api/] : null,
}, },

View File

@ -27,7 +27,7 @@
</section> </section>
<section> <section>
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration" /> <BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration" />
<v-alert :key="idx" border="left" colored-border :type="getColor(appConfig.emailReady)" elevation="2"> <v-alert border="left" colored-border :type="getColor(appConfig.emailReady)" elevation="2">
<div class="font-weight-medium">Email Configuration Status</div> <div class="font-weight-medium">Email Configuration Status</div>
<div> <div>
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Environmental Variables" }} {{ appConfig.emailReady ? "Ready" : "Not Ready - Check Environmental Variables" }}

View File

@ -6,12 +6,54 @@
:title="category.name" :title="category.name"
:recipes="category.recipes" :recipes="category.recipes"
@sort="assignSorted" @sort="assignSorted"
></RecipeCardSection> >
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.tags }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="category.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateCategory"
>
</v-text-field>
<v-btn icon @click="updateCategory">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ category.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useAsync, useRoute } from "@nuxtjs/composition-api"; import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe } from "~/types/api-types/recipe";
@ -21,13 +63,49 @@ export default defineComponent({
setup() { setup() {
const api = useUserApi(); const api = useUserApi();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug; const slug = route.value.params.slug;
const state = reactive({
initialValue: "",
edit: false,
});
const category = useAsync(async () => { const category = useAsync(async () => {
const { data } = await api.categories.getOne(slug); const { data } = await api.categories.getOne(slug);
if (data) {
state.initialValue = data.name;
}
return data; return data;
}, slug); }, slug);
return { category };
function reset() {
state.edit = false;
if (category.value) {
category.value.name = state.initialValue;
}
}
async function updateCategory() {
state.edit = false;
if (!category.value) {
return;
}
const { data } = await api.categories.updateOne(category.value.slug, category.value);
if (data) {
router.push("/recipes/categories/" + data.slug);
}
}
return {
category,
reset,
...toRefs(state),
updateCategory,
};
}, },
head() { head() {
return { return {
@ -44,6 +122,4 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style scoped>
</style>

View File

@ -1,71 +1,35 @@
<template> <template>
<v-container v-if="categories"> <v-container>
<v-app-bar color="transparent" flat class="mt-n1 rounded"> <RecipeCategoryTagToolPage v-if="categories" :items="categories" item-type="categories" />
<v-icon large left>
{{ $globals.icons.tags }}
</v-icon>
<v-toolbar-title class="headline"> {{ $t("recipe.categories") }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<section v-for="(items, key, idx) in categoriesByLetter" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in items" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card hover :to="`/recipes/categories/${item.slug}`">
<v-card-actions>
<v-icon>
{{ $globals.icons.tags }}
</v-icon>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</section>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useAsync } from "@nuxtjs/composition-api"; import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({ export default defineComponent({
components: {
RecipeCategoryTagToolPage,
},
setup() { setup() {
const api = useUserApi(); const userApi = useUserApi();
const categories = useAsync(async () => { const categories = useAsync(async () => {
const { data } = await api.categories.getAll(); const { data } = await userApi.categories.getAll();
return data;
}, useAsyncKey());
const categoriesByLetter: any = computed(() => { if (data) {
const catsByLetter: { [key: string]: Array<any> } = {}; return data;
}
if (!categories.value) return catsByLetter;
categories.value.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!catsByLetter[letter]) {
catsByLetter[letter] = [];
}
catsByLetter[letter].push(item);
});
return catsByLetter;
}); });
return { categories, api, categoriesByLetter };
},
head() {
return { return {
title: this.$t("sidebar.categories") as string, categories,
}; };
}, },
// head: {
// // @ts-ignore
// title: this.$t("sidebar.categories") as string,
// },
}); });
</script> </script>
<style scoped>
</style>

View File

@ -1,49 +1,125 @@
<template> <template>
<v-container> <v-container>
<RecipeCardSection <RecipeCardSection
v-if="tag" v-if="tags"
:icon="$globals.icons.tags" :icon="$globals.icons.tags"
:title="tag.name" :title="tags.name"
:recipes="tag.recipes" :recipes="tags.recipes"
@sort="assignSorted" @sort="assignSorted"
></RecipeCardSection> >
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.tags }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="tags.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateTags"
>
</v-text-field>
<v-btn icon @click="updateTags">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ tags.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useAsync, useRoute } from "@nuxtjs/composition-api"; import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { Recipe } from "~/types/api-types/admin"; import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
setup() { setup() {
const api = useUserApi(); const api = useUserApi();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug; const slug = route.value.params.slug;
const tag = useAsync(async () => { const state = reactive({
initialValue: "",
edit: false,
});
const tags = useAsync(async () => {
const { data } = await api.tags.getOne(slug); const { data } = await api.tags.getOne(slug);
if (data) {
state.initialValue = data.name;
}
return data; return data;
}, slug); }, slug);
return { tag };
function reset() {
state.edit = false;
if (tags.value) {
tags.value.name = state.initialValue;
}
}
async function updateTags() {
state.edit = false;
if (!tags.value) {
return;
}
const { data } = await api.tags.updateOne(tags.value.slug, tags.value);
if (data) {
router.push("/recipes/tags/" + data.slug);
}
}
return {
tags,
reset,
...toRefs(state),
updateTags,
};
}, },
head() { head() {
return { return {
title: this.$t("sidebar.tags") as string, title: this.$t("sidebar.categories") as string,
}; };
}, },
methods: { methods: {
assignSorted(val: Array<Recipe>) { assignSorted(val: Array<Recipe>) {
if (this.tag) { if (this.tags) {
// @ts-ignore // @ts-ignore
this.tag.recipes = val; this.tags.recipes = val;
} }
}, },
}, },
}); });
</script> </script>
<style scoped>
</style>

View File

@ -1,71 +1,34 @@
<template> <template>
<v-container v-if="tags"> <v-container>
<v-app-bar color="transparent" flat class="mt-n1 rounded"> <RecipeCategoryTagToolPage v-if="tools" :items="tools" item-type="tags" />
<v-icon large left>
{{ $globals.icons.tags }}
</v-icon>
<v-toolbar-title class="headline"> {{ $t("tag.tags") }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<section v-for="(items, key, idx) in tagsByLetter" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in items" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card hover :to="`/recipes/tags/${item.slug}`">
<v-card-actions>
<v-icon>
{{ $globals.icons.tags }}
</v-icon>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</section>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useAsync, computed } from "@nuxtjs/composition-api"; import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({ export default defineComponent({
setup() { components: {
const api = useUserApi(); RecipeCategoryTagToolPage,
const tags = useAsync(async () => {
const { data } = await api.tags.getAll();
return data;
}, useAsyncKey());
const tagsByLetter: any = computed(() => {
const tagsByLetter: { [key: string]: Array<any> } = {};
if (!tags.value) return tagsByLetter;
tags.value.forEach((item) => {
const letter = item.name[0].toUpperCase();
if (!tagsByLetter[letter]) {
tagsByLetter[letter] = [];
}
tagsByLetter[letter].push(item);
});
return tagsByLetter;
});
return { tags, api, tagsByLetter };
}, },
head() { setup() {
const userApi = useUserApi();
const tools = useAsync(async () => {
const { data } = await userApi.tags.getAll();
if (data) {
return data;
}
});
return { return {
title: this.$t("sidebar.tags") as string, tools,
}; };
}, },
// head: {
// // @ts-ignore
// title: this.$t("sidebar.tags") as string,
// },
}); });
</script> </script>
<style scoped>
</style>

View File

@ -0,0 +1,119 @@
<template>
<v-container>
<RecipeCardSection v-if="tools" :title="tools.name" :recipes="tools.recipes" @sort="assignSorted">
<template #title>
<v-btn icon class="mr-1">
<v-icon dark large @click="reset">
{{ $globals.icons.potSteam }}
</v-icon>
</v-btn>
<template v-if="edit">
<v-text-field
v-model="tools.name"
autofocus
single-line
dense
hide-details
class="headline"
@keyup.enter="updateTools"
>
</v-text-field>
<v-btn icon @click="updateTools">
<v-icon size="28">
{{ $globals.icons.save }}
</v-icon>
</v-btn>
<v-btn icon class="mr-1" @click="reset">
<v-icon size="28">
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</template>
<template v-else>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-toolbar-title v-bind="attrs" style="cursor: pointer" class="headline" v-on="on" @click="edit = true">
{{ tools.name }}
</v-toolbar-title>
</template>
<span> Click to Edit </span>
</v-tooltip>
</template>
</template>
</RecipeCardSection>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, useRoute, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useUserApi } from "~/composables/api";
import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const state = reactive({
initialValue: "",
edit: false,
});
const tools = useAsync(async () => {
const { data } = await api.tools.byslug(slug);
if (data) {
state.initialValue = data.name;
}
return data;
}, slug);
function reset() {
state.edit = false;
if (tools.value) {
tools.value.name = state.initialValue;
}
}
async function updateTools() {
state.edit = false;
if (!tools.value) {
return;
}
const { data } = await api.tools.updateOne(tools.value.id, tools.value);
if (data) {
router.push("/recipes/tools/" + data.slug);
}
}
return {
tools,
reset,
...toRefs(state),
updateTools,
};
},
head() {
return {
title: this.$t("sidebar.categories") as string,
};
},
methods: {
assignSorted(val: Array<Recipe>) {
if (this.tools) {
// @ts-ignore
this.tools.recipes = val;
}
},
},
});
</script>

View File

@ -0,0 +1,33 @@
<template>
<v-container>
<RecipeCategoryTagToolPage v-if="tools" :items="tools" item-type="tools" />
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync } from "@nuxtjs/composition-api";
import RecipeCategoryTagToolPage from "~/components/Domain/Recipe/RecipeCategoryTagToolPage.vue";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: {
RecipeCategoryTagToolPage,
},
setup() {
const userApi = useUserApi();
const tools = useAsync(async () => {
const { data } = await userApi.tools.getAll();
if (data) {
return data;
}
});
return {
tools,
};
},
head: {
title: "Tools",
},
});
</script>

View File

@ -2,7 +2,6 @@ import { useDark } from "@vueuse/core";
export default ({ $vuetify }: any) => { export default ({ $vuetify }: any) => {
const isDark = useDark(); const isDark = useDark();
console.log("isDark Plugin", isDark);
if (isDark.value) { if (isDark.value) {
$vuetify.theme.dark = true; $vuetify.theme.dark = true;

View File

@ -28,10 +28,9 @@ from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.invite_token import ReadInviteToken from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan.new_meal import ReadPlanEntry from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken from mealie.schema.recipe.recipe_share_token import RecipeShareToken
from mealie.schema.recipe.recipe_tool import RecipeTool
from mealie.schema.reports.reports import ReportEntryOut, ReportOut from mealie.schema.reports.reports import ReportEntryOut, ReportOut
from mealie.schema.server import ServerTask from mealie.schema.server import ServerTask
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut

View File

@ -1,3 +1,4 @@
from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
@ -14,10 +15,10 @@ recipes_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"
name = Column(String, index=True, unique=True, nullable=False) name = Column(String, index=True, unique=True, nullable=False)
slug = Column(String, index=True, unique=True, nullable=False)
on_hand = Column(Boolean, default=False) on_hand = Column(Boolean, default=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools") recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools")
@auto_init() @auto_init()
def __init__(self, name, on_hand, **_) -> None: def __init__(self, name, **_) -> None:
self.on_hand = on_hand self.slug = slugify(name)
self.name = name

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.dependencies import is_logged_in from mealie.core.dependencies import is_logged_in
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.routes.routers import AdminAPIRouter, UserAPIRouter
@ -10,6 +11,7 @@ from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
public_router = APIRouter() public_router = APIRouter()
user_router = UserAPIRouter() user_router = UserAPIRouter()
admin_router = AdminAPIRouter() admin_router = AdminAPIRouter()
logger = get_logger()
@public_router.get("") @public_router.get("")
@ -61,6 +63,7 @@ async def update_recipe_category(category: str, new_category: CategoryIn, sessio
try: try:
return db.categories.update(category, new_category.dict()) return db.categories.update(category, new_category.dict())
except Exception: except Exception:
logger.exception("Failed to update category")
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)

View File

@ -1,8 +1,18 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends
from mealie.schema.recipe.recipe_tool import RecipeToolResponse
from mealie.services._base_http_service.router_factory import RouterFactory from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_tool_service import RecipeToolService from mealie.services.recipe.recipe_tool_service import RecipeToolService
router = APIRouter() router = APIRouter()
router.include_router(RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"])) tools_router = RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"])
@tools_router.get("/slug/{slug}")
async def Func(slug: str, tools_service: RecipeToolService = Depends(RecipeToolService.private)):
"""Returns a recipe by slug."""
return tools_service.db.tools.get_one(slug, "slug", override_schema=RecipeToolResponse)
router.include_router(tools_router)

View File

@ -3,4 +3,5 @@ from .recipe_category import *
from .recipe_comments import * from .recipe_comments import *
from .recipe_image_types import * from .recipe_image_types import *
from .recipe_ingredient import * from .recipe_ingredient import *
from .recipe_tool import *
from .request_helpers import * from .request_helpers import *

View File

@ -18,7 +18,6 @@ from .recipe_notes import RecipeNote
from .recipe_nutrition import Nutrition from .recipe_nutrition import Nutrition
from .recipe_settings import RecipeSettings from .recipe_settings import RecipeSettings
from .recipe_step import RecipeStep from .recipe_step import RecipeStep
from .recipe_tool import RecipeTool
app_dirs = get_app_dirs() app_dirs = get_app_dirs()
@ -35,6 +34,11 @@ class RecipeCategory(RecipeTag):
pass pass
class RecipeTool(RecipeTag):
id: int = 0
on_hand: bool = False
class CreateRecipeByUrl(BaseModel): class CreateRecipeByUrl(BaseModel):
url: str url: str

View File

@ -42,7 +42,7 @@ class RecipeTagResponse(RecipeCategoryResponse):
pass pass
from .recipe import Recipe from . import Recipe
RecipeCategoryResponse.update_forward_refs() RecipeCategoryResponse.update_forward_refs()
RecipeTagResponse.update_forward_refs() RecipeTagResponse.update_forward_refs()

View File

@ -1,3 +1,5 @@
from typing import List
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
@ -8,6 +10,19 @@ class RecipeToolCreate(CamelModel):
class RecipeTool(RecipeToolCreate): class RecipeTool(RecipeToolCreate):
id: int id: int
slug: str
class Config: class Config:
orm_mode = True orm_mode = True
class RecipeToolResponse(RecipeTool):
recipes: List["Recipe"] = []
class Config:
orm_mode = True
from .recipe import Recipe
RecipeToolResponse.update_forward_refs()

View File

@ -63,7 +63,7 @@ class MealieAlphaMigrator(BaseMigrator):
recipe_lookup: dict[str, Path] = {} recipe_lookup: dict[str, Path] = {}
recipes_as_dicts = [] recipes_as_dicts = []
for x in temp_path.rglob("**/[!.]*.json"): for x in temp_path.rglob("**/recipes/**/[!.]*.json"):
if (y := MigrationReaders.json(x)) is not None: if (y := MigrationReaders.json(x)) is not None:
recipes_as_dicts.append(y) recipes_as_dicts.append(y)
slug = y["slug"] slug = y["slug"]
@ -76,12 +76,16 @@ class MealieAlphaMigrator(BaseMigrator):
recipe_model_lookup = {x.slug: x for x in recipes} recipe_model_lookup = {x.slug: x for x in recipes}
for slug, status in results: for slug, status in results:
if status: if not status:
model = recipe_model_lookup.get(slug) continue
dest_dir = model.directory
source_dir = recipe_lookup.get(slug)
if dest_dir.exists(): model = recipe_model_lookup.get(slug)
shutil.rmtree(dest_dir) dest_dir = model.directory
source_dir = recipe_lookup.get(slug)
shutil.copytree(source_dir, dest_dir) if dest_dir.exists():
shutil.rmtree(dest_dir)
for dir in source_dir.iterdir():
if dir.is_dir():
shutil.copytree(dir, dest_dir / dir.name)

View File

@ -1,12 +1,12 @@
import base64 import base64
import io import io
import json import json
import re
import tempfile import tempfile
import zipfile import zipfile
from gzip import GzipFile from gzip import GzipFile
from pathlib import Path from pathlib import Path
import regex as re
from slugify import slugify from slugify import slugify
from mealie.schema.recipe import RecipeNote from mealie.schema.recipe import RecipeNote

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from functools import cached_property from functools import cached_property
from mealie.schema.recipe.recipe_tool import RecipeTool, RecipeToolCreate from mealie.schema.recipe import RecipeTool, RecipeToolCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event from mealie.services.events import create_recipe_event

View File

@ -22,6 +22,9 @@ class Routes:
class TestRecipeTool: class TestRecipeTool:
id: int id: int
name: str name: str
slug: str
on_hand: bool
recipes: list
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -32,7 +35,15 @@ def tool(api_client: TestClient, unique_user: TestUser) -> TestRecipeTool:
assert response.status_code == 201 assert response.status_code == 201
yield TestRecipeTool(id=response.json()["id"], name=data["name"]) as_json = response.json()
yield TestRecipeTool(
id=as_json["id"],
name=data["name"],
slug=as_json["slug"],
on_hand=as_json["onHand"],
recipes=[],
)
try: try:
response = api_client.delete(Routes.item(response.json()["id"]), headers=unique_user.token) response = api_client.delete(Routes.item(response.json()["id"]), headers=unique_user.token)
@ -58,7 +69,12 @@ def test_read_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: Te
def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser): def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
update_data = {"id": tool.id, "name": random_string(10)} update_data = {
"id": tool.id,
"name": random_string(10),
"slug": tool.slug,
"on_hand": True,
}
response = api_client.put(Routes.item(tool.id), json=update_data, headers=unique_user.token) response = api_client.put(Routes.item(tool.id), json=update_data, headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
@ -89,7 +105,7 @@ def test_recipe_tool_association(api_client: TestClient, tool: TestRecipeTool, u
as_json = response.json() as_json = response.json()
as_json["tools"] = [{"id": tool.id, "name": tool.name}] as_json["tools"] = [{"id": tool.id, "name": tool.name, "slug": tool.slug}]
# Update Recipe # Update Recipe
response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token) response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token)