Feature/restore-recipe-functionality (#810)

* feat(frontend):  add back support for assets

* feat(backend):  add back support for assets

* feat(frontend):  add support for recipe tools

* feat(backend):  add support for recipe tools

* feat(frontend):  add onHand support for recipe toosl

* feat(backend):  add onHand support for backend

* refactor(frontend): ♻️ move items to recipe folder and break apart types

* feat(frontend):  add support for recipe comments

* feat(backend):  Add support for recipe comments

* fix(backend): 💥 disable comments import

* fix(frontend): 🐛 fix rendering issue with titles when moving steps

* add tools to changelog

* fix type errors

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-22 20:10:48 -09:00 committed by GitHub
parent 912cc6d956
commit 7afdd5b577
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1221 additions and 423 deletions

View file

@ -47,6 +47,8 @@
**Recipe General** **Recipe General**
- Recipes are now only viewable by group members - Recipes are now only viewable by group members
- Recipes now have a `tools` attributes that contains a list of required tools/equipment for the recipe. Tools can be set with a state to determine if you have that tool or not. If it's marked as on hand it will show checked by default.
- Recipe Extras now only show when advanced mode it toggled on
- You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish. - You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish.
- Foods/Units for Ingredients are now supported (toggle inside your recipe settings) - Foods/Units for Ingredients are now supported (toggle inside your recipe settings)
- Common Food and Units come pre-packaged with Mealie - Common Food and Units come pre-packaged with Mealie
@ -72,6 +74,7 @@
- Can now be merged with the above step automatically through the action menu - Can now be merged with the above step automatically through the action menu
- Recipe Ingredients can be linked directly to recipe instructions for improved display - Recipe Ingredients can be linked directly to recipe instructions for improved display
- There is an option in the linking dialog to automatically link ingredients. This works by using a key-word matching algorithm to find the ingredients. It's not perfect so you'll need to verify the links after use, additionally you will find that it doesn't work for non-english languages. - There is an option in the linking dialog to automatically link ingredients. This works by using a key-word matching algorithm to find the ingredients. It's not perfect so you'll need to verify the links after use, additionally you will find that it doesn't work for non-english languages.
- Recipe Instructions now have a preview tab to show the rendered markdown before saving.
### ⚠️ Other things to know... ### ⚠️ Other things to know...
- Themes have been depreciated for specific users. You can still set specific themes for your site through ENV variables. This approach should yield much better results for performance and some weirdness users have experienced. - Themes have been depreciated for specific users. You can still set specific themes for your site through ENV variables. This approach should yield much better results for performance and some weirdness users have experienced.

View file

@ -0,0 +1 @@
export { RecipeAPI } from "./recipe";

View file

@ -0,0 +1,19 @@
import { RecipeComment, RecipeCommentCreate } from "./types";
import { BaseCRUDAPI } from "~/api/_base";
const prefix = "/api";
const routes = {
comment: `${prefix}/comments`,
byRecipe: (id: string) => `${prefix}/recipes/${id}/comments`,
commentsId: (id: string) => `${prefix}/comments/${id}`,
};
export class CommentsApi extends BaseCRUDAPI<RecipeComment, RecipeCommentCreate> {
baseRoute: string = routes.comment;
itemRoute = routes.commentsId;
async byRecipe(slug: string) {
return await this.requests.get<RecipeComment[]>(routes.byRecipe(slug));
}
}

View file

@ -1,7 +1,9 @@
import { BaseCRUDAPI } from "../_base"; import { CreateAsset, ParsedIngredient, Parser, RecipeZipToken, BulkCreatePayload } from "./types";
import { Category } from "./categories"; import { CommentsApi } from "./recipe-comments";
import { Tag } from "./tags"; import { BaseCRUDAPI } from "~/api/_base";
import { Recipe, CreateRecipe } from "~/types/api-types/recipe"; import { Recipe, CreateRecipe } from "~/types/api-types/recipe";
import { ApiRequestInstance } from "~/types/api";
const prefix = "/api"; const prefix = "/api";
@ -26,68 +28,35 @@ const routes = {
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`, recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
}; };
export type Parser = "nlp" | "brute";
export interface Confidence {
average?: number;
comment?: number;
name?: number;
unit?: number;
quantity?: number;
food?: number;
}
export interface Unit {
name: string;
description: string;
fraction: boolean;
abbreviation: string;
}
export interface Food {
name: string;
description?: string;
}
export interface Ingredient {
referenceId: string;
title: string;
note: string;
unit: Unit | null;
food: Food | null;
disableAmount: boolean;
quantity: number;
}
export interface ParsedIngredient {
confidence: Confidence;
ingredient: Ingredient;
}
export interface BulkCreateRecipe {
url: string;
categories: Category[];
tags: Tag[];
}
export interface BulkCreatePayload {
imports: BulkCreateRecipe[];
}
export interface RecipeZipToken {
token: string;
}
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase; baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug; itemRoute = routes.recipesRecipeSlug;
public comments: CommentsApi;
constructor(requests: ApiRequestInstance) {
super(requests);
this.comments = new CommentsApi(requests);
}
async getAllByCategory(categories: string[]) { async getAllByCategory(categories: string[]) {
return await this.requests.get<Recipe[]>(routes.recipesCategory, { return await this.requests.get<Recipe[]>(routes.recipesCategory, {
categories, categories,
}); });
} }
async createAsset(recipeSlug: string, payload: CreateAsset) {
const formData = new FormData();
// @ts-ignore
formData.append("file", payload.file);
formData.append("name", payload.name);
formData.append("extension", payload.extension);
formData.append("icon", payload.icon);
return await this.requests.post(routes.recipesRecipeSlugAssets(recipeSlug), formData);
}
updateImage(slug: string, fileObject: File) { updateImage(slug: string, fileObject: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("image", fileObject); formData.append("image", fileObject);
@ -113,8 +82,6 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
return await this.requests.post(routes.recipesCreateUrlBulk, payload); return await this.requests.post(routes.recipesCreateUrlBulk, payload);
} }
// Recipe Comments
// Methods to Generate reference urls for assets/images * // Methods to Generate reference urls for assets/images *
recipeImage(recipeSlug: string, version = null, key = null) { recipeImage(recipeSlug: string, version = null, key = null) {
return `/api/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`; return `/api/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`;
@ -132,22 +99,6 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
return `/api/media/recipes/${recipeSlug}/assets/${assetName}`; return `/api/media/recipes/${recipeSlug}/assets/${assetName}`;
} }
async createComment(slug: string, payload: Object) {
return await this.requests.post(routes.recipesSlugComments(slug), payload);
}
/** Update comment in the Database
*/
async updateComment(slug: string, id: number, payload: Object) {
return await this.requests.put(routes.recipesSlugCommentsId(slug, id), payload);
}
/** Delete comment from the Database
*/
async deleteComment(slug: string, id: number) {
return await this.requests.delete(routes.recipesSlugCommentsId(slug, id));
}
async parseIngredients(parser: Parser, ingredients: Array<string>) { async parseIngredients(parser: Parser, ingredients: Array<string>) {
parser = parser || "nlp"; parser = parser || "nlp";
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients }); return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });

View file

@ -0,0 +1,83 @@
import { Category } from "../categories";
import { Tag } from "../tags";
export type Parser = "nlp" | "brute";
export interface Confidence {
average?: number;
comment?: number;
name?: number;
unit?: number;
quantity?: number;
food?: number;
}
export interface Unit {
name: string;
description: string;
fraction: boolean;
abbreviation: string;
}
export interface Food {
name: string;
description?: string;
}
export interface Ingredient {
referenceId: string;
title: string;
note: string;
unit: Unit | null;
food: Food | null;
disableAmount: boolean;
quantity: number;
}
export interface ParsedIngredient {
confidence: Confidence;
ingredient: Ingredient;
}
export interface BulkCreateRecipe {
url: string;
categories: Category[];
tags: Tag[];
}
export interface BulkCreatePayload {
imports: BulkCreateRecipe[];
}
export interface RecipeZipToken {
token: string;
}
export interface CreateAsset {
name: string;
icon: string;
extension: string;
file?: File;
}
export interface RecipeCommentCreate {
recipeId: number;
text: string;
}
export interface RecipeCommentUpdate extends RecipeCommentCreate {
id: string;
}
interface RecipeCommentUser {
id: string;
username: string;
admin: boolean;
}
export interface RecipeComment extends RecipeCommentUpdate {
createdAt: any;
updatedAt: any;
userId: number;
user: RecipeCommentUser;
}

View file

@ -0,0 +1,22 @@
import { BaseCRUDAPI } from "../_base";
const prefix = "/api";
export interface CreateTool {
name: string;
onHand: boolean;
}
export interface Tool extends CreateTool {
id: number;
}
const routes = {
tools: `${prefix}/tools`,
toolsId: (id: string) => `${prefix}/tools/${id}`,
};
export class ToolsApi extends BaseCRUDAPI<Tool, CreateTool> {
baseRoute: string = routes.tools;
itemRoute = routes.toolsId;
}

View file

@ -18,6 +18,7 @@ import { EmailAPI } from "./class-interfaces/email";
import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions"; import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions";
import { GroupServerTaskAPI } from "./class-interfaces/group-tasks"; import { GroupServerTaskAPI } from "./class-interfaces/group-tasks";
import { AdminAPI } from "./admin-api"; import { AdminAPI } from "./admin-api";
import { ToolsApi } from "./class-interfaces/tools";
import { ApiRequestInstance } from "~/types/api"; import { ApiRequestInstance } from "~/types/api";
class Api { class Api {
@ -40,7 +41,7 @@ class Api {
public email: EmailAPI; public email: EmailAPI;
public bulk: BulkActionsAPI; public bulk: BulkActionsAPI;
public grouperServerTasks: GroupServerTaskAPI; public grouperServerTasks: GroupServerTaskAPI;
public tools: ToolsApi;
// Utils // Utils
public upload: UploadFile; public upload: UploadFile;
@ -55,6 +56,7 @@ class Api {
this.tags = new TagsAPI(requests); this.tags = new TagsAPI(requests);
this.units = new UnitAPI(requests); this.units = new UnitAPI(requests);
this.foods = new FoodAPI(requests); this.foods = new FoodAPI(requests);
this.tools = new ToolsApi(requests);
// Users // Users
this.users = new UserApi(requests); this.users = new UserApi(requests);

View file

@ -23,10 +23,10 @@
<v-icon> {{ $globals.icons.download }} </v-icon> <v-icon> {{ $globals.icons.download }} </v-icon>
</v-btn> </v-btn>
<div v-else> <div v-else>
<v-btn color="error" icon top @click="deleteAsset(i)"> <v-btn color="error" icon top @click="value.splice(i, 1)">
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
<AppCopyButton :copy-text="copyLink(item.fileName)" /> <AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
</div> </div>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
@ -35,12 +35,10 @@
<div class="d-flex ml-auto mt-2"> <div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset"> <BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset">
<template #open="{ open }"> <template #activator="{ open }">
<v-btn v-if="edit" color="secondary" dark @click="open"> <BaseButton v-if="edit" small create @click="open" />
<v-icon>{{ $globals.icons.create }}</v-icon>
</v-btn>
</template> </template>
<v-card-text class="pt-2"> <v-card-text class="pt-4">
<v-text-field v-model="newAsset.name" dense :label="$t('general.name')"></v-text-field> <v-text-field v-model="newAsset.name" dense :label="$t('general.name')"></v-text-field>
<div class="d-flex justify-space-between"> <div class="d-flex justify-space-between">
<v-select <v-select
@ -70,9 +68,14 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export default { import { alert } from "~/composables/use-toast";
const BASE_URL = window.location.origin;
export default defineComponent({
props: { props: {
slug: { slug: {
type: String, type: String,
@ -87,82 +90,95 @@ export default {
default: true, default: true,
}, },
}, },
setup() { setup(props, context) {
const api = useUserApi(); const api = useUserApi();
return { api }; const state = reactive({
}, fileObject: {} as File,
data() {
return {
fileObject: {},
newAsset: { newAsset: {
name: "", name: "",
icon: "mdi-file", icon: "mdi-file",
}, },
});
// @ts-ignore
const { $globals, i18n } = useContext();
const iconOptions = [
{
name: "mdi-file",
title: i18n.t("asset.file"),
icon: $globals.icons.file,
},
{
name: "mdi-file-pdf-box",
title: i18n.t("asset.pdf"),
icon: $globals.icons.filePDF,
},
{
name: "mdi-file-image",
title: i18n.t("asset.image"),
icon: $globals.icons.fileImage,
},
{
name: "mdi-code-json",
title: i18n.t("asset.code"),
icon: $globals.icons.codeJson,
},
{
name: "mdi-silverware-fork-knife",
title: i18n.t("asset.recipe"),
icon: $globals.icons.primary,
},
];
function getIconDefinition(icon: string) {
return iconOptions.find((item) => item.name === icon) || iconOptions[0];
}
function assetURL(assetName: string) {
return api.recipes.recipeAssetPath(props.slug, assetName);
}
function assetEmbed(name: string) {
return `<img src="${BASE_URL}${assetURL(name)}" height="100%" width="100%"> </img>`;
}
function setFileObject(fileObject: any) {
state.fileObject = fileObject;
}
function validFields() {
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
}
async function addAsset() {
if (!validFields()) {
alert.error("Error Submitting Form");
return;
}
const { data } = await api.recipes.createAsset(props.slug, {
name: state.newAsset.name,
icon: state.newAsset.icon,
file: state.fileObject,
extension: state.fileObject.name.split(".").pop() || "",
});
context.emit("input", [...props.value, data]);
state.newAsset = { name: "", icon: "mdi-file" };
state.fileObject = {} as File;
}
return {
...toRefs(state),
addAsset,
assetURL,
assetEmbed,
getIconDefinition,
iconOptions,
setFileObject,
}; };
}, },
computed: { });
baseURL() {
return window.location.origin;
},
iconOptions() {
return [
{
name: "mdi-file",
title: this.$i18n.t("asset.file"),
icon: this.$globals.icons.file,
},
{
name: "mdi-file-pdf-box",
title: this.$i18n.t("asset.pdf"),
icon: this.$globals.icons.filePDF,
},
{
name: "mdi-file-image",
title: this.$i18n.t("asset.image"),
icon: this.$globals.icons.fileImage,
},
{
name: "mdi-code-json",
title: this.$i18n.t("asset.code"),
icon: this.$globals.icons.codeJson,
},
{
name: "mdi-silverware-fork-knife",
title: this.$i18n.t("asset.recipe"),
icon: this.$globals.icons.primary,
},
];
},
},
methods: {
getIconDefinition(val) {
return this.iconOptions.find(({ name }) => name === val);
},
assetURL(assetName) {
return api.recipes.recipeAssetPath(this.slug, assetName);
},
setFileObject(obj) {
this.fileObject = obj;
},
async addAsset() {
const serverAsset = await api.recipes.createAsset(
this.slug,
this.fileObject,
this.newAsset.name,
this.newAsset.icon
);
this.value.push(serverAsset.data);
this.newAsset = { name: "", icon: "mdi-file" };
},
deleteAsset(index) {
this.value.splice(index, 1);
},
copyLink(fileName) {
const assetLink = api.recipes.recipeAssetPath(this.slug, fileName);
return `<img src="${this.baseURL}${assetLink}" height="100%" width="100%"> </img>`;
},
},
};
</script> </script>

View file

@ -43,7 +43,7 @@
</template> </template>
<script> <script>
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog"; import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useTags, useCategories } from "~/composables/recipes"; import { useTags, useCategories } from "~/composables/recipes";
const MOUNTED_EVENT = "mounted"; const MOUNTED_EVENT = "mounted";

View file

@ -1,125 +1,117 @@
<template> <template>
<v-card> <div>
<v-card-title class="headline"> <v-card-title class="headline pb-3">
<v-icon large class="mr-2"> <v-icon class="mr-2">
{{ $globals.icons.commentTextMultipleOutline }} {{ $globals.icons.commentTextMultipleOutline }}
</v-icon> </v-icon>
{{ $t("recipe.comments") }} {{ $t("recipe.comments") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-card v-for="(comment, index) in comments" :key="comment.id" class="ma-2"> <div class="d-flex flex-column">
<v-list-item two-line> <div class="d-flex mt-3" style="gap: 10px">
<v-list-item-avatar color="accent" class="white--text"> <v-avatar size="40">
<img :src="getProfileImage(comment.user.id)" /> <img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
</v-list-item-avatar> </v-avatar>
<v-list-item-content> <v-textarea
<v-list-item-title> {{ comment.user.username }}</v-list-item-title> v-model="comment"
<v-list-item-subtitle> {{ $d(new Date(comment.dateAdded), "short") }} </v-list-item-subtitle> hide-details=""
</v-list-item-content> dense
<v-card-actions v-if="loggedIn"> single-line
<TheButton outlined
v-if="!editKeys[comment.id] && (user.admin || comment.user.id === user.id)" auto-grow
small rows="2"
minor placeholder="Join the Conversation"
delete >
@click="deleteComment(comment.id)" </v-textarea>
/> </div>
<TheButton <div class="ml-auto mt-1">
v-if="!editKeys[comment.id] && comment.user.id === user.id" <BaseButton small :disabled="!comment" @click="submitComment">
small <template #icon>{{ $globals.icons.check }}</template>
edit {{ $t("general.submit") }}
@click="editComment(comment.id)" </BaseButton>
/> </div>
<TheButton v-else-if="editKeys[comment.id]" small update @click="updateComment(comment.id, index)" /> </div>
</v-card-actions> <div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
</v-list-item> <v-avatar size="40">
<div> <img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
<v-card-text> </v-avatar>
{{ !editKeys[comment.id] ? comment.text : null }} <v-card outlined class="flex-grow-1">
<v-textarea v-if="editKeys[comment.id]" v-model="comment.text"> </v-textarea> <v-card-text class="pa-3 pb-0">
<p class="">{{ comment.user.username }} {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
{{ comment.text }}
</v-card-text> </v-card-text>
</div> <v-card-actions class="justify-end mt-0 pt-0">
</v-card> <v-btn
<v-card-text v-if="loggedIn"> v-if="$auth.user.id == comment.user.id || $auth.user.admin"
<v-textarea v-model="newComment" auto-grow row-height="1" outlined> </v-textarea> color="error"
<div class="d-flex"> text
<TheButton class="ml-auto" create @click="createNewComment"> {{ $t("recipe.comment-action") }} </TheButton> x-small
</div> @click="deleteComment(comment.id)"
</v-card-text> >
</v-card> Delete
</v-btn>
</v-card-actions>
</v-card>
</div>
</div>
</template> </template>
<script> <script lang="ts">
import { defineComponent, ref, toRefs } from "@nuxtjs/composition-api";
import { onMounted, reactive } from "vue-demi";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const NEW_COMMENT_EVENT = "new-comment"; import { RecipeComment } from "~/api/class-interfaces/recipes/types";
const UPDATE_COMMENT_EVENT = "update-comment";
export default { export default defineComponent({
props: { props: {
comments: {
type: Array,
default: () => [],
},
slug: { slug: {
type: String, type: String,
required: true, required: true,
}, },
recipeId: {
type: Number,
required: true,
},
}, },
setup() { setup(props) {
const api = useUserApi(); const api = useUserApi();
return { api }; const comments = ref<RecipeComment[]>([]);
},
data() { const state = reactive({
return { comment: "",
newComment: "", });
editKeys: {},
}; onMounted(async () => {
}, const { data } = await api.recipes.comments.byRecipe(props.slug);
computed: {
user() { if (data) {
return this.$store.getters.getUserData; comments.value = data;
},
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
watch: {
comments() {
for (const comment of this.comments) {
this.$set(this.editKeys, comment.id, false);
} }
}, });
},
methods: {
resetImage() {
this.hideImage = false;
},
getProfileImage() {
// TODO Actually get Profile Image
return null;
},
editComment(id) {
this.$set(this.editKeys, id, true);
},
async updateComment(id, index) {
this.$set(this.editKeys, id, false);
await this.api.recipes.updateComment(this.slug, id, this.comments[index]); async function submitComment() {
this.$emit(UPDATE_COMMENT_EVENT); const { data } = await api.recipes.comments.createOne({
}, recipeId: props.recipeId,
async createNewComment() { text: state.comment,
await this.api.recipes.createComment(this.slug, { text: this.newComment }); });
this.$emit(NEW_COMMENT_EVENT);
this.newComment = ""; if (data) {
}, comments.value.push(data);
async deleteComment(id) { }
await this.api.recipes.deleteComment(this.slug, id);
this.$emit(UPDATE_COMMENT_EVENT); state.comment = "";
}, }
async function deleteComment(id: string) {
const { response } = await api.recipes.comments.deleteOne(id);
if (response?.status === 200) {
comments.value = comments.value.filter((comment) => comment.id !== id);
}
}
return { api, comments, ...toRefs(state), submitComment, deleteComment };
}, },
}; });
</script> </script>
<style lang="scss" scoped>
</style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div v-if="value && value.length > 0"> <div v-if="value && value.length > 0">
<div class="d-flex justify-start"> <div class="d-flex justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.ingredients") }}</h2> <h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" /> <AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
</div> </div>
<div> <div>

View file

@ -75,8 +75,8 @@
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
> >
<div v-for="(step, index) in value" :key="index"> <div v-for="(step, index) in value" :key="step.id">
<v-app-bar v-if="showTitleEditor[index]" class="primary mx-1 mt-6" dark dense rounded> <v-app-bar v-if="showTitleEditor[step.id]" class="primary mx-1 mt-6" dark dense rounded>
<v-toolbar-title v-if="!edit" class="headline"> <v-toolbar-title v-if="!edit" class="headline">
<v-app-bar-title v-text="step.title"> </v-app-bar-title> <v-app-bar-title v-text="step.title"> </v-app-bar-title>
</v-toolbar-title> </v-toolbar-title>
@ -114,7 +114,7 @@
mode="event" mode="event"
:items="actionEvents || []" :items="actionEvents || []"
@merge-above="mergeAbove(index - 1, index)" @merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(index)" @toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)" @link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
> >
</BaseOverflowButton> </BaseOverflowButton>
@ -155,6 +155,7 @@ import VueMarkdown from "@adapttive/vue-markdown";
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api"; import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe"; import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import { uuid4 } from "~/composables/use-utils";
interface MergerHistory { interface MergerHistory {
target: number; target: number;
@ -195,7 +196,7 @@ export default defineComponent({
usedIngredients: [] as RecipeIngredient[], usedIngredients: [] as RecipeIngredient[],
}); });
const showTitleEditor = ref<boolean[]>([]); const showTitleEditor = ref<{ [key: string]: boolean }>({});
const actionEvents = [ const actionEvents = [
{ {
@ -220,12 +221,17 @@ export default defineComponent({
watch(props.value, (v) => { watch(props.value, (v) => {
state.disabledSteps = []; state.disabledSteps = [];
showTitleEditor.value = v.map((x) => validateTitle(x.title));
v.forEach((element) => {
showTitleEditor.value[element.id] = validateTitle(element.title);
});
}); });
// Eliminate state with an eager call to watcher? // Eliminate state with an eager call to watcher?
onMounted(() => { onMounted(() => {
showTitleEditor.value = props.value.map((x) => validateTitle(x.title)); props.value.forEach((element) => {
showTitleEditor.value[element.id] = validateTitle(element.title);
});
}); });
function toggleDisabled(stepIndex: number) { function toggleDisabled(stepIndex: number) {
@ -246,16 +252,11 @@ export default defineComponent({
return "disabled-card"; return "disabled-card";
} }
} }
function toggleShowTitle(index: number) { function toggleShowTitle(id: string) {
const newVal = !showTitleEditor.value[index]; showTitleEditor.value[id] = !showTitleEditor.value[id];
if (!newVal) {
props.value[index].title = "";
}
// Must create a new temporary list due to vue-composition-api backport limitations (I think...) const temp = { ...showTitleEditor.value };
const tempList = [...showTitleEditor.value]; showTitleEditor.value = temp;
tempList[index] = newVal;
showTitleEditor.value = tempList;
} }
function updateIndex(data: RecipeStep) { function updateIndex(data: RecipeStep) {
context.emit("input", data); context.emit("input", data);
@ -387,6 +388,7 @@ export default defineComponent({
props.value[lastMerge.target].text = lastMerge.targetText; props.value[lastMerge.target].text = lastMerge.targetText;
props.value.splice(lastMerge.source, 0, { props.value.splice(lastMerge.source, 0, {
id: uuid4(),
title: "", title: "",
text: lastMerge.sourceText, text: lastMerge.sourceText,
ingredientReferences: [], ingredientReferences: [],

View file

@ -1,10 +1,10 @@
<template> <template>
<div v-if="valueNotNull || edit"> <div v-if="valueNotNull || edit">
<v-card class="mt-2"> <v-card class="mt-2">
<v-card-title class="py-2"> <v-card-title class="pt-2 pb-0">
{{ $t("recipe.nutrition") }} {{ $t("recipe.nutrition") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2 my-1"></v-divider>
<v-card-text v-if="edit"> <v-card-text v-if="edit">
<div v-for="(item, key, index) in value" :key="index"> <div v-for="(item, key, index) in value" :key="index">
<v-text-field <v-text-field
@ -19,9 +19,9 @@
</div> </div>
</v-card-text> </v-card-text>
<v-list v-if="showViewer" dense class="mt-0 pt-0"> <v-list v-if="showViewer" dense class="mt-0 pt-0">
<v-list-item v-for="(item, key, index) in labels" :key="index"> <v-list-item v-for="(item, key, index) in labels" :key="index" style="min-height: 25px" dense>
<v-list-item-content> <v-list-item-content>
<v-list-item-title class="pl-4 text-subtitle-1 flex row"> <v-list-item-title class="pl-4 caption flex row">
<div>{{ item.label }}</div> <div>{{ item.label }}</div>
<div class="ml-auto mr-1">{{ value[key] }}</div> <div class="ml-auto mr-1">{{ value[key] }}</div>
<div>{{ item.suffix }}</div> <div>{{ item.suffix }}</div>

View file

@ -0,0 +1,88 @@
<template>
<div v-if="edit || (value && value.length > 0)">
<template v-if="edit">
<v-autocomplete
v-if="tools"
v-model="recipeTools"
:items="tools"
item-text="name"
multiple
return-object
deletable-chips
:prepend-icon="$globals.icons.potSteam"
chips
>
<template #selection="data">
<v-chip
:key="data.index"
small
class="ma-1"
:input-value="data.selected"
close
label
color="accent"
dark
@click:close="recipeTools.splice(data.index, 1)"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
<template #append-outer="">
<BaseDialog title="Create New Tool" @submit="actions.createOne()">
<template #activator="{ open }">
<v-btn icon @click="open">
<v-icon> {{ $globals.icons.create }}</v-icon>
</v-btn>
</template>
<v-card-text>
<v-text-field v-model="workingToolData.name" label="Tool Name"></v-text-field>
<v-checkbox v-model="workingToolData.onHand" label="Show as On Hand (Checked)"></v-checkbox>
</v-card-text>
</BaseDialog>
</template>
</v-autocomplete>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { computed } from "vue-demi";
import { Tool } from "~/api/class-interfaces/tools";
import { useTools } from "~/composables/recipes";
export default defineComponent({
props: {
value: {
type: Array as () => Tool[],
required: true,
},
edit: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { tools, actions, workingToolData } = useTools();
const recipeTools = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
return {
workingToolData,
actions,
tools,
recipeTools,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View file

@ -43,7 +43,7 @@ export default {
}, },
color: { color: {
type: String, type: String,
default: "primary", default: "",
}, },
icon: { icon: {
type: Boolean, type: Boolean,

View file

@ -6,3 +6,4 @@ export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories"; export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
export { parseIngredientText } from "./use-recipe-ingredients"; export { parseIngredientText } from "./use-recipe-ingredients";
export { useRecipeSearch } from "./use-recipe-search"; export { useRecipeSearch } from "./use-recipe-search";
export { useTools } from "./use-recipe-tools";

View file

@ -0,0 +1,93 @@
import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
export const useTools = function (eager = true) {
const workingToolData = reactive({
id: 0,
name: "",
onHand: false,
});
const api = useUserApi();
const loading = ref(false);
const validForm = ref(false);
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const { data } = await api.tools.getAll();
return data;
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.tools.getAll();
if (data) {
tools.value = data;
}
loading.value = false;
},
async createOne(domForm: VForm | null = null) {
if (domForm && !domForm.validate()) {
validForm.value = false;
}
loading.value = true;
const { data } = await api.tools.createOne(workingToolData);
if (data) {
tools.value?.push(data);
}
domForm?.reset();
this.reset();
},
async updateOne() {
loading.value = true;
const { data } = await api.tools.updateOne(workingToolData.id, workingToolData);
if (data) {
tools.value?.push(data);
}
this.reset();
},
async deleteOne(id: number) {
loading.value = true;
await api.tools.deleteOne(id);
this.reset();
},
reset() {
workingToolData.name = "";
workingToolData.id = 0;
loading.value = false;
validForm.value = true;
},
};
const tools = (() => {
if (eager) {
return actions.getAll();
} else {
return ref([]);
}
})();
return {
tools,
actions,
workingToolData,
loading,
};
};

View file

@ -68,7 +68,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api"; import { defineComponent, reactive, ref, toRefs } from "@nuxtjs/composition-api";
import { Confidence, Parser } from "~/api/class-interfaces/recipes"; import { Confidence, Parser } from "~/api/class-interfaces/recipes/types";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export default defineComponent({ export default defineComponent({

View file

@ -200,8 +200,26 @@
:disable-amount="recipe.settings.disableAmount" :disable-amount="recipe.settings.disableAmount"
/> />
<!-- Recipe Categories --> <!-- Recipe Tools Display -->
<div v-if="!form && recipe.tools && recipe.tools.length > 0">
<h2 class="mb-2 mt-4">Required Tools</h2>
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
<v-checkbox
v-model="recipe.tools[index].onHand"
hide-details
class="pt-0 my-auto py-auto"
color="secondary"
@change="updateTool(recipe.tools[index])"
>
</v-checkbox>
<v-list-item-content>
{{ tool.name }}
</v-list-item-content>
</v-list-item>
</div>
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5"> <div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2"> <v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2"> <v-card-title class="py-2">
{{ $t("recipe.categories") }} {{ $t("recipe.categories") }}
@ -238,9 +256,23 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<RecipeNutrition v-if="true || form" v-model="recipe.nutrition" class="mt-10" :edit="form" /> <!-- Recipe Tools Edit -->
<v-card v-if="form" class="mt-2">
<v-card-title class="py-2"> Required Tools </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeTools v-model="recipe.tools" :edit="form" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<RecipeAssets <RecipeAssets
v-if="recipe.settings.showAssets || form" v-if="recipe.settings.showAssets"
v-model="recipe.assets" v-model="recipe.assets"
:edit="form" :edit="form"
:slug="recipe.slug" :slug="recipe.slug"
@ -260,6 +292,69 @@
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" /> <RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton> <BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div> </div>
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
<div v-if="!$vuetify.breakpoint.mdAndUp" class="mt-5">
<!-- Recipe Tools Edit -->
<v-card v-if="form">
<v-card-title class="py-2"> Required Tools</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text class="pt-0">
<RecipeTools v-model="recipe.tools" :edit="form" />
</v-card-text>
</v-card>
<!-- Recipe Categories -->
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
v-if="form"
v-model="recipe.recipeCategory"
:return-object="true"
:show-add="true"
:show-label="false"
/>
<RecipeChips v-else :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<!-- Recipe Tags -->
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeCategoryTagSelector
v-if="form"
v-model="recipe.tags"
:return-object="true"
:show-add="true"
:tag-selector="true"
:show-label="false"
/>
<RecipeChips v-else :items="recipe.tags" :is-category="false" />
</v-card-text>
</v-card>
<RecipeNutrition
v-if="recipe.settings.showNutrition"
v-model="recipe.nutrition"
class="mt-10"
:edit="form"
/>
<RecipeAssets
v-if="recipe.settings.showAssets"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
/>
</div>
<RecipeNotes v-model="recipe.notes" :edit="form" /> <RecipeNotes v-model="recipe.notes" :edit="form" />
</v-col> </v-col>
</v-row> </v-row>
@ -289,7 +384,40 @@
</v-card-actions> </v-card-actions>
</v-card-text> </v-card-text>
</div> </div>
<v-card v-if="form && $auth.user.advanced" flat class="ma-2 mb-2">
<v-card-title> API Extras </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
Recipes extras are a key feature of the Mealie API. They allow you to create custom json key/value pairs
within a recipe to reference from 3rd part applications. You can use these keys to contain information to
trigger automation or custom messages to relay to your desired device.
<v-row v-for="(value, key) in recipe.extras" :key="key" class="mt-1">
<v-col cols="8">
<v-text-field v-model="recipe.extras[key]" dense :label="key">
<template #prepend>
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
<v-icon> {{ $globals.icons.delete }} </v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="d-flex">
<div style="max-width: 200px">
<v-text-field v-model="apiNewKey" label="Message Key"></v-text-field>
</div>
<BaseButton create small class="ml-5" @click="createApiExtra" />
</v-card-actions>
</v-card>
</v-card> </v-card>
<RecipeComments
v-if="recipe && !recipe.settings.disableComments && !form"
v-model="recipe.comments"
:slug="recipe.slug"
:recipe-id="recipe.id"
class="mt-4"
/>
<RecipePrintView v-if="recipe" :recipe="recipe" /> <RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container> </v-container>
</template> </template>
@ -328,8 +456,11 @@ import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBt
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue"; import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import RecipeTools from "~/components/Domain/Recipe/RecipeTools.vue";
import RecipeComments from "~/components/Domain/Recipe/RecipeComments.vue";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils"; import { uuid4, deepCopy } from "~/composables/use-utils";
import { Tool } from "~/api/class-interfaces/tools";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -338,6 +469,7 @@ export default defineComponent({
RecipeAssets, RecipeAssets,
RecipeCategoryTagSelector, RecipeCategoryTagSelector,
RecipeChips, RecipeChips,
RecipeComments,
RecipeDialogBulkAdd, RecipeDialogBulkAdd,
RecipeImageUploadBtn, RecipeImageUploadBtn,
RecipeIngredientEditor, RecipeIngredientEditor,
@ -349,6 +481,7 @@ export default defineComponent({
RecipeRating, RecipeRating,
RecipeSettingsMenu, RecipeSettingsMenu,
RecipeTimeCard, RecipeTimeCard,
RecipeTools,
VueMarkdown, VueMarkdown,
}, },
async beforeRouteLeave(_to, _from, next) { async beforeRouteLeave(_to, _from, next) {
@ -484,12 +617,12 @@ export default defineComponent({
if (steps) { if (steps) {
const cleanedSteps = steps.map((step) => { const cleanedSteps = steps.map((step) => {
return { text: step, title: "", ingredientReferences: [] }; return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
}); });
recipe.value.recipeInstructions.push(...cleanedSteps); recipe.value.recipeInstructions.push(...cleanedSteps);
} else { } else {
recipe.value.recipeInstructions.push({ text: "", title: "", ingredientReferences: [] }); recipe.value.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
} }
} }
@ -523,6 +656,54 @@ export default defineComponent({
} }
} }
// ===============================================================
// Recipe Tools
async function updateTool(tool: Tool) {
const { response } = await api.tools.updateOne(tool.id, tool);
if (response?.status === 200) {
console.log("Update Successful");
}
}
// ===============================================================
// Recipe API Extras
const apiNewKey = ref("");
function createApiExtra() {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
recipe.value.extras = {};
}
// check for duplicate keys
if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
return;
}
recipe.value.extras[apiNewKey.value] = "";
apiNewKey.value = "";
}
function removeApiExtra(key: string) {
if (!recipe.value) {
return;
}
if (!recipe.value.extras) {
return;
}
delete recipe.value.extras[key];
recipe.value.extras = { ...recipe.value.extras };
}
// =============================================================== // ===============================================================
// Metadata // Metadata
@ -553,6 +734,8 @@ export default defineComponent({
}); });
return { return {
createApiExtra,
apiNewKey,
originalRecipe, originalRecipe,
domSaveChangesDialog, domSaveChangesDialog,
enableLandscape, enableLandscape,
@ -565,11 +748,13 @@ export default defineComponent({
addStep, addStep,
deleteRecipe, deleteRecipe,
closeEditor, closeEditor,
updateTool,
updateRecipe, updateRecipe,
uploadImage, uploadImage,
validators, validators,
recipeImage, recipeImage,
addIngredient, addIngredient,
removeApiExtra,
}; };
}, },
head: {}, head: {},

View file

@ -84,7 +84,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api"; import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
import { until, invoke } from "@vueuse/core"; import { until, invoke } from "@vueuse/core";
import { Food, ParsedIngredient, Parser } from "~/api/class-interfaces/recipes"; import { Food, ParsedIngredient, Parser } from "~/api/class-interfaces/recipes/types";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useRecipe, useFoods, useUnits } from "~/composables/recipes"; import { useRecipe, useFoods, useUnits } from "~/composables/recipes";

View file

@ -101,6 +101,7 @@ export interface IngredientToStepRef {
referenceId: string; referenceId: string;
} }
export interface RecipeStep { export interface RecipeStep {
id: string;
title?: string; title?: string;
text: string; text: string;
ingredientReferences: IngredientToStepRef[]; ingredientReferences: IngredientToStepRef[];

View file

@ -15,6 +15,7 @@ import {
mdiSlotMachine, mdiSlotMachine,
mdiHome, mdiHome,
mdiMagnify, mdiMagnify,
mdiPotSteam,
mdiTranslate, mdiTranslate,
mdiClockTimeFourOutline, mdiClockTimeFourOutline,
mdiImport, mdiImport,
@ -181,6 +182,7 @@ export const icons = {
star: mdiStar, star: mdiStar,
testTube: mdiTestTube, testTube: mdiTestTube,
tools: mdiTools, tools: mdiTools,
potSteam: mdiPotSteam,
translate: mdiTranslate, translate: mdiTranslate,
upload: mdiCloudUpload, upload: mdiCloudUpload,
viewDashboard: mdiViewDashboard, viewDashboard: mdiViewDashboard,

View file

@ -13,6 +13,7 @@ from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
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.db.models.server.task import ServerTaskModel from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.sign_up import SignUp from mealie.db.models.sign_up import SignUp
from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users import LongLiveToken, User
@ -24,8 +25,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 CommentOut, Recipe, RecipeCategoryResponse, RecipeTagResponse from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_tool import RecipeTool
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
from mealie.schema.user.user_passwords import PrivatePasswordResetToken from mealie.schema.user.user_passwords import PrivatePasswordResetToken
@ -78,8 +80,12 @@ class Database:
return AccessModel(self.session, pk_id, IngredientUnitModel, IngredientUnit) return AccessModel(self.session, pk_id, IngredientUnitModel, IngredientUnit)
@cached_property @cached_property
def comments(self) -> AccessModel[CommentOut, RecipeComment]: def tools(self) -> AccessModel[RecipeTool, Tool]:
return AccessModel(self.session, pk_id, RecipeComment, CommentOut) return AccessModel(self.session, pk_id, Tool, RecipeTool)
@cached_property
def comments(self) -> AccessModel[RecipeCommentOut, RecipeComment]:
return AccessModel(self.session, pk_id, RecipeComment, RecipeCommentOut)
@cached_property @cached_property
def categories(self) -> CategoryDataAccessModel: def categories(self) -> CategoryDataAccessModel:

View file

@ -11,6 +11,7 @@ class GUID(TypeDecorator):
""" """
impl = CHAR impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect): def load_dialect_impl(self, dialect):
if dialect.name == "postgresql": if dialect.name == "postgresql":
@ -31,9 +32,6 @@ class GUID(TypeDecorator):
return "%.32x" % value.int return "%.32x" % value.int
def process_result_value(self, value, dialect): def process_result_value(self, value, dialect):
if value is None: if value is not None and not isinstance(value, uuid.UUID):
return value value = uuid.UUID(value)
else: return value
if not isinstance(value, uuid.UUID):
value = uuid.UUID(value)
return value

View file

@ -7,7 +7,7 @@ class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras" __tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String, unique=True) key_name = sa.Column(sa.String)
value = sa.Column(sa.String) value = sa.Column(sa.String)
def __init__(self, key, value) -> None: def __init__(self, key, value) -> None:

View file

@ -1,37 +1,28 @@
from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, orm from sqlalchemy import Column, ForeignKey, Integer, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models._model_utils import auto_init
from mealie.db.models.users import User from mealie.db.models._model_utils.guid import GUID
def generate_uuid():
return str(uuid4())
class RecipeComment(SqlAlchemyBase, BaseMixins): class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments" __tablename__ = "recipe_comments"
id = Column(Integer, primary_key=True) id = Column(GUID(), primary_key=True, default=uuid4)
uuid = Column(String, unique=True, nullable=False, default=generate_uuid)
parent_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments")
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
date_added = Column(DateTime, default=datetime.now)
text = Column(String) text = Column(String)
def __init__(self, recipe_slug, user, text, session, date_added=None, **_) -> None: # Recipe Link
self.text = text recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
self.recipe = RecipeModel.get_ref(session, recipe_slug, "slug") recipe = orm.relationship("RecipeModel", back_populates="comments")
self.date_added = date_added or datetime.now()
if isinstance(user, dict): # User Link
user = user.get("id") user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id])
self.user = User.get_ref(session, user) @auto_init()
def __init__(self, **_) -> None:
pass
def update(self, text, **_) -> None: def update(self, text, **_) -> None:
self.text = text self.text = text

View file

@ -1,3 +1,5 @@
from uuid import uuid4
from sqlalchemy import Column, ForeignKey, Integer, String, orm from sqlalchemy import Column, ForeignKey, Integer, String, orm
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
@ -17,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase): class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions" __tablename__ = "recipe_instructions"
id = Column(Integer, primary_key=True) id = Column(GUID(), primary_key=True, default=uuid4)
parent_id = Column(Integer, ForeignKey("recipes.id")) parent_id = Column(Integer, ForeignKey("recipes.id"))
position = Column(Integer) position = Column(Integer)
type = Column(String, default="") type = Column(String, default="")

View file

@ -18,7 +18,22 @@ from .note import Note
from .nutrition import Nutrition from .nutrition import Nutrition
from .settings import RecipeSettings from .settings import RecipeSettings
from .tag import Tag, recipes2tags from .tag import Tag, recipes2tags
from .tool import Tool from .tool import recipes_to_tools
# Decorator function to unpack the extras into a dict
def recipe_extras(func):
def wrapper(*args, **kwargs):
extras = kwargs.pop("extras")
if extras is None:
extras = []
extras = [{"key": key, "value": value} for key, value in extras.items()]
return func(*args, extras=extras, **kwargs)
return wrapper
class RecipeModel(SqlAlchemyBase, BaseMixins): class RecipeModel(SqlAlchemyBase, BaseMixins):
@ -52,10 +67,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
recipe_yield = sa.Column(sa.String) recipe_yield = sa.Column(sa.String)
recipeCuisine = sa.Column(sa.String) recipeCuisine = sa.Column(sa.String)
tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan") assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes") recipe_category: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship( recipe_ingredient: list[RecipeIngredient] = orm.relationship(
"RecipeIngredient", "RecipeIngredient",
@ -88,45 +103,36 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
get_attr = "slug" get_attr = "slug"
exclude = { exclude = {
"assets", "assets",
"extras",
"notes", "notes",
"nutrition", "nutrition",
"recipe_ingredient", "recipe_ingredient",
"settings", "settings",
"tools",
} }
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, _, name):
assert name != "" assert name != ""
return name return name
@recipe_extras
@auto_init() @auto_init()
def __init__( def __init__(
self, self,
session, session,
assets: list = None, assets: list = None,
extras: dict = None,
notes: list[dict] = None, notes: list[dict] = None,
nutrition: dict = None, nutrition: dict = None,
recipe_ingredient: list[str] = None, recipe_ingredient: list[str] = None,
settings: dict = None, settings: dict = None,
tools: list[str] = None,
**_, **_,
) -> None: ) -> None:
self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient] self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient]
self.assets = [RecipeAsset(**a) for a in assets] self.assets = [RecipeAsset(**a) for a in assets]
# self.recipe_instructions = [
# RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None))
# for instruc in recipe_instructions
# ]
# Mealie Specific # Mealie Specific
self.settings = RecipeSettings(**settings) if settings else RecipeSettings() self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
self.notes = [Note(**note) for note in notes] self.notes = [Note(**note) for note in notes]
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
# Time Stampes # Time Stampes
self.date_updated = datetime.datetime.now() self.date_updated = datetime.datetime.now()

View file

@ -1,13 +1,23 @@
import sqlalchemy as sa from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
recipes_to_tools = Table(
"recipes_to_tools",
SqlAlchemyBase.metadata,
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("tool_id", Integer, ForeignKey("tools.id")),
)
class Tool(SqlAlchemyBase): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"
id = sa.Column(sa.Integer, primary_key=True) name = Column(String, index=True, unique=True, nullable=False)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id")) on_hand = Column(Boolean, default=False)
tool = sa.Column(sa.String) recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools")
def __init__(self, tool) -> None: @auto_init()
self.tool = tool def __init__(self, name, on_hand, **_) -> None:
self.on_hand = on_hand
self.name = name

View file

@ -1,6 +1,20 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import admin, app, auth, categories, groups, parser, recipe, shopping_lists, tags, unit_and_foods, users from . import (
admin,
app,
auth,
categories,
comments,
groups,
parser,
recipe,
shopping_lists,
tags,
tools,
unit_and_foods,
users,
)
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@ -9,8 +23,10 @@ router.include_router(auth.router)
router.include_router(users.router) router.include_router(users.router)
router.include_router(groups.router) router.include_router(groups.router)
router.include_router(recipe.router) router.include_router(recipe.router)
router.include_router(comments.router)
router.include_router(parser.router) router.include_router(parser.router)
router.include_router(unit_and_foods.router) router.include_router(unit_and_foods.router)
router.include_router(tools.router)
router.include_router(categories.router) router.include_router(categories.router)
router.include_router(tags.router) router.include_router(tags.router)
router.include_router(shopping_lists.router) router.include_router(shopping_lists.router)

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_comments_service import RecipeCommentsService
router = APIRouter()
router.include_router(RouterFactory(RecipeCommentsService, prefix="/comments", tags=["Recipe: Comments"]))

View file

@ -1,58 +1,20 @@
from http.client import HTTPException from fastapi import Depends
from fastapi import Depends, status
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.dependencies import get_current_user
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 UserAPIRouter from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CommentOut, CreateComment, SaveComment from mealie.schema.recipe.recipe_comments import RecipeCommentOut
from mealie.schema.user import PrivateUser
router = UserAPIRouter() router = UserAPIRouter()
@router.post("/{slug}/comments") @router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
async def create_comment( async def get_recipe_comments(
slug: str, slug: str,
new_comment: CreateComment,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
): ):
"""Create comment in the Database""" """Get all comments for a recipe"""
db = get_database(session) db = get_database(session)
recipe = db.recipes.get_one(slug)
new_comment = SaveComment(user=current_user.id, text=new_comment.text, recipe_slug=slug) return db.comments.multi_query({"recipe_id": recipe.id})
return db.comments.create(new_comment)
@router.put("/{slug}/comments/{id}")
async def update_comment(
id: int,
new_comment: CreateComment,
session: Session = Depends(generate_session),
current_user: PrivateUser = Depends(get_current_user),
):
"""Update comment in the Database"""
db = get_database(session)
old_comment: CommentOut = db.comments.get(id)
if current_user.id != old_comment.user.id:
raise HTTPException(status.HTTP_403_FORBIDDEN)
return db.comments.update(id, new_comment)
@router.delete("/{slug}/comments/{id}")
async def delete_comment(
id: int, session: Session = Depends(generate_session), current_user: PrivateUser = Depends(get_current_user)
):
"""Delete comment from the Database"""
db = get_database(session)
comment: CommentOut = db.comments.get(id)
if current_user.id == comment.user.id or current_user.admin:
db.comments.delete(id)
return
raise HTTPException(status.HTTP_403_FORBIDDEN)

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from mealie.services._base_http_service.router_factory import RouterFactory
from mealie.services.recipe.recipe_tool_service import RecipeToolService
router = APIRouter()
router.include_router(RouterFactory(RecipeToolService, prefix="/tools", tags=["Recipes: Tools"]))

View file

@ -11,12 +11,13 @@ from mealie.core.config import get_app_dirs
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from .recipe_asset import RecipeAsset from .recipe_asset import RecipeAsset
from .recipe_comments import CommentOut from .recipe_comments import RecipeCommentOut
from .recipe_ingredient import RecipeIngredient from .recipe_ingredient import RecipeIngredient
from .recipe_notes import RecipeNote 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()
@ -101,7 +102,7 @@ class Recipe(RecipeSummary):
recipe_ingredient: Optional[list[RecipeIngredient]] = [] recipe_ingredient: Optional[list[RecipeIngredient]] = []
recipe_instructions: Optional[list[RecipeStep]] = [] recipe_instructions: Optional[list[RecipeStep]] = []
nutrition: Optional[Nutrition] nutrition: Optional[Nutrition]
tools: Optional[list[str]] = [] tools: list[RecipeTool] = []
# Mealie Specific # Mealie Specific
settings: Optional[RecipeSettings] = RecipeSettings() settings: Optional[RecipeSettings] = RecipeSettings()
@ -109,7 +110,7 @@ class Recipe(RecipeSummary):
notes: Optional[list[RecipeNote]] = [] notes: Optional[list[RecipeNote]] = []
extras: Optional[dict] = {} extras: Optional[dict] = {}
comments: Optional[list[CommentOut]] = [] comments: Optional[list[RecipeCommentOut]] = []
@staticmethod @staticmethod
def directory_from_slug(slug) -> Path: def directory_from_slug(slug) -> Path:
@ -143,7 +144,6 @@ class Recipe(RecipeSummary):
# "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient], # "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient],
# "recipe_category": [x.name for x in name_orm.recipe_category], # "recipe_category": [x.name for x in name_orm.recipe_category],
# "tags": [x.name for x in name_orm.tags], # "tags": [x.name for x in name_orm.tags],
"tools": [x.tool for x in name_orm.tools],
"extras": {x.key_name: x.value for x in name_orm.extras}, "extras": {x.key_name: x.value for x in name_orm.extras},
} }

View file

@ -1,8 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from uuid import UUID
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic.utils import GetterDict
class UserBase(CamelModel): class UserBase(CamelModel):
@ -14,31 +14,27 @@ class UserBase(CamelModel):
orm_mode = True orm_mode = True
class CreateComment(CamelModel): class RecipeCommentCreate(CamelModel):
recipe_id: int
text: str text: str
class SaveComment(CreateComment): class RecipeCommentSave(RecipeCommentCreate):
recipe_slug: str user_id: int
user: int
class Config:
orm_mode = True
class CommentOut(CreateComment): class RecipeCommentUpdate(CamelModel):
id: int id: UUID
uuid: str text: str
recipe_slug: str
date_added: datetime
class RecipeCommentOut(RecipeCommentCreate):
id: UUID
recipe_id: int
created_at: datetime
update_at: datetime
user_id: int
user: UserBase user: UserBase
class Config: class Config:
orm_mode = True orm_mode = True
@classmethod
def getter_dict(_cls, name_orm):
return {
**GetterDict(name_orm),
"recipe_slug": name_orm.recipe.slug,
}

View file

@ -1,7 +1,8 @@
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import Field
class IngredientReferences(CamelModel): class IngredientReferences(CamelModel):
@ -16,6 +17,7 @@ class IngredientReferences(CamelModel):
class RecipeStep(CamelModel): class RecipeStep(CamelModel):
id: Optional[UUID] = Field(default_factory=uuid4)
title: Optional[str] = "" title: Optional[str] = ""
text: str text: str
ingredient_references: list[IngredientReferences] = [] ingredient_references: list[IngredientReferences] = []

View file

@ -0,0 +1,13 @@
from fastapi_camelcase import CamelModel
class RecipeToolCreate(CamelModel):
name: str
on_hand: bool = False
class RecipeTool(RecipeToolCreate):
id: int
class Config:
orm_mode = True

View file

@ -8,15 +8,15 @@ from pydantic.main import BaseModel
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_dirs from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
from mealie.schema.events import EventNotificationIn from mealie.schema.events import EventNotificationIn
from mealie.schema.recipe import CommentOut, Recipe from mealie.schema.recipe import Recipe, RecipeCommentOut
from mealie.schema.user import PrivateUser, UpdateGroup from mealie.schema.user import PrivateUser, UpdateGroup
from mealie.services.image import minify from mealie.services.image import minify
app_dirs = get_app_dirs()
class ImportDatabase: class ImportDatabase:
def __init__( def __init__(
@ -57,7 +57,10 @@ class ImportDatabase:
successful_imports = {} successful_imports = {}
recipes = ImportDatabase.read_models_file( recipes = ImportDatabase.read_models_file(
file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration file_path=recipe_dir,
model=Recipe,
single_file=False,
migrate=ImportDatabase._recipe_migration,
) )
for recipe in recipes: for recipe in recipes:
@ -76,7 +79,7 @@ class ImportDatabase:
) )
if import_status.status: if import_status.status:
successful_imports.update({recipe.slug: recipe}) successful_imports[recipe.slug] = recipe
imports.append(import_status) imports.append(import_status)
@ -90,10 +93,10 @@ class ImportDatabase:
if not comment_dir.exists(): if not comment_dir.exists():
return return
comments = ImportDatabase.read_models_file(file_path=comment_dir, model=CommentOut) comments = ImportDatabase.read_models_file(file_path=comment_dir, model=RecipeCommentOut)
for comment in comments: for comment in comments:
comment: CommentOut comment: RecipeCommentOut
self.import_model( self.import_model(
db_table=self.db.comments, db_table=self.db.comments,
@ -130,6 +133,8 @@ class ImportDatabase:
if type(recipe_dict["extras"]) == list: if type(recipe_dict["extras"]) == list:
recipe_dict["extras"] = {} recipe_dict["extras"] = {}
recipe_dict["comments"] = []
return recipe_dict return recipe_dict
def _import_images(self, successful_imports: list[Recipe]): def _import_images(self, successful_imports: list[Recipe]):
@ -328,8 +333,8 @@ def import_database(
if import_notifications: if import_notifications:
notification_report = import_session.import_notifications() notification_report = import_session.import_notifications()
if import_recipes: # if import_recipes:
import_session.import_comments() # import_session.import_comments()
import_session.clean_up() import_session.clean_up()

View file

@ -0,0 +1,52 @@
from __future__ import annotations
from functools import cached_property
from uuid import UUID
from fastapi import HTTPException
from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate,
RecipeCommentOut,
RecipeCommentSave,
RecipeCommentUpdate,
)
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeCommentsService(
CrudHttpMixins[RecipeCommentOut, RecipeCommentCreate, RecipeCommentCreate],
UserHttpService[UUID, RecipeCommentOut],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeCommentOut
@cached_property
def dal(self):
return self.db.comments
def _check_comment_belongs_to_user(self) -> None:
if self.item.user_id != self.user.id and not self.user.admin:
raise HTTPException(detail="Comment does not belong to user")
def populate_item(self, id: UUID) -> RecipeCommentOut:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[RecipeCommentOut]:
return self.dal.get_all()
def create_one(self, data: RecipeCommentCreate) -> RecipeCommentOut:
save_data = RecipeCommentSave(text=data.text, user_id=self.user.id, recipe_id=data.recipe_id)
return self._create_one(save_data)
def update_one(self, data: RecipeCommentUpdate, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._update_one(data, item_id)
def delete_one(self, item_id: UUID = None) -> RecipeCommentOut:
self._check_comment_belongs_to_user()
return self._delete_one(item_id)

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from functools import cached_property
from mealie.schema.recipe.recipe_tool import RecipeTool, RecipeToolCreate
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
class RecipeToolService(
CrudHttpMixins[RecipeTool, RecipeToolCreate, RecipeToolCreate],
UserHttpService[int, RecipeTool],
):
event_func = create_recipe_event
_restrict_by_group = False
_schema = RecipeTool
@cached_property
def dal(self):
return self.db.tools
def populate_item(self, id: int) -> RecipeTool:
self.item = self.dal.get_one(id)
return self.item
def get_all(self) -> list[RecipeTool]:
return self.dal.get_all()
def create_one(self, data: RecipeToolCreate) -> RecipeTool:
return self._create_one(data)
def update_one(self, data: RecipeTool, item_id: int = None) -> RecipeTool:
return self._update_one(data, item_id)
def delete_one(self, id: int = None) -> RecipeTool:
return self._delete_one(id)

View file

@ -0,0 +1,122 @@
import pytest
from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe import Recipe
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/comments"
recipes = "/api/recipes"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe(recipe_id: int) -> str:
return f"{Routes.recipes}/{recipe_id}"
def recipe_comments(recipe_slug: str) -> str:
return f"{Routes.recipe(recipe_slug)}/comments"
@pytest.fixture(scope="function")
def unique_recipe(api_client: TestClient, unique_user: TestUser):
payload = {"name": random_string(length=20)}
response = api_client.post(Routes.recipes, json=payload, headers=unique_user.token)
assert response.status_code == 201
response_data = response.json()
recipe_response = api_client.get(Routes.recipe(response_data), headers=unique_user.token)
return Recipe(**recipe_response.json())
def random_comment(recipe_id: int) -> dict:
if recipe_id is None:
raise ValueError("recipe_id is required")
return {
"recipeId": recipe_id,
"text": random_string(length=50),
}
def test_create_comment(api_client: TestClient, unique_recipe: Recipe, unique_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(Routes.base, json=create_data, headers=unique_user.token)
assert response.status_code == 201
response_data = response.json()
assert response_data["recipeId"] == unique_recipe.id
assert response_data["text"] == create_data["text"]
assert response_data["userId"] == unique_user.user_id
# Check for Proper Association
response = api_client.get(Routes.recipe_comments(unique_recipe.slug), headers=unique_user.token)
assert response.status_code == 200
response_data = response.json()
assert len(response_data) == 1
assert response_data[0]["recipeId"] == unique_recipe.id
assert response_data[0]["text"] == create_data["text"]
assert response_data[0]["userId"] == unique_user.user_id
def test_update_comment(api_client: TestClient, unique_recipe: Recipe, unique_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(Routes.base, json=create_data, headers=unique_user.token)
assert response.status_code == 201
comment_id = response.json()["id"]
# Update Comment
update_data = random_comment(unique_recipe.id)
update_data["id"] = comment_id
response = api_client.put(Routes.item(comment_id), json=update_data, headers=unique_user.token)
assert response.status_code == 200
response_data = response.json()
assert response_data["recipeId"] == unique_recipe.id
assert response_data["text"] == update_data["text"]
assert response_data["userId"] == unique_user.user_id
def test_delete_comment(api_client: TestClient, unique_recipe: Recipe, unique_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(Routes.base, json=create_data, headers=unique_user.token)
assert response.status_code == 201
# Delete Comment
comment_id = response.json()["id"]
response = api_client.delete(Routes.item(comment_id), headers=unique_user.token)
assert response.status_code == 200
# Validate Deletion
response = api_client.get(Routes.item(comment_id), headers=unique_user.token)
assert response.status_code == 404
def test_admin_can_delete(api_client: TestClient, unique_recipe: Recipe, unique_user: TestUser, admin_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(Routes.base, json=create_data, headers=unique_user.token)
assert response.status_code == 201
# Delete Comment
comment_id = response.json()["id"]
response = api_client.delete(Routes.item(comment_id), headers=admin_user.token)
assert response.status_code == 200
# Validate Deletion
response = api_client.get(Routes.item(comment_id), headers=admin_user.token)
assert response.status_code == 404

View file

@ -39,7 +39,6 @@ def test_read_update(
] ]
recipe["notes"] = test_notes recipe["notes"] = test_notes
recipe["tools"] = ["one tool", "two tool"]
test_categories = [ test_categories = [
{"name": "one", "slug": "one"}, {"name": "one", "slug": "one"},

View file

@ -0,0 +1,104 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/tools"
recipes = "/api/recipes"
def item(item_id: int) -> str:
return f"{Routes.base}/{item_id}"
def recipe(recipe_id: int) -> str:
return f"{Routes.recipes}/{recipe_id}"
@dataclass
class TestRecipeTool:
id: int
name: str
@pytest.fixture(scope="function")
def tool(api_client: TestClient, unique_user: TestUser) -> TestRecipeTool:
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
yield TestRecipeTool(id=response.json()["id"], name=data["name"])
try:
response = api_client.delete(Routes.item(response.json()["id"]), headers=unique_user.token)
except Exception:
pass
def test_create_tool(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(10)}
response = api_client.post(Routes.base, json=data, headers=unique_user.token)
assert response.status_code == 201
def test_read_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
response = api_client.get(Routes.item(tool.id), headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tool.id
assert as_json["name"] == tool.name
def test_update_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
update_data = {"id": tool.id, "name": random_string(10)}
response = api_client.put(Routes.item(tool.id), json=update_data, headers=unique_user.token)
assert response.status_code == 200
as_json = response.json()
assert as_json["id"] == tool.id
assert as_json["name"] == update_data["name"]
def test_delete_tool(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
response = api_client.delete(Routes.item(tool.id), headers=unique_user.token)
assert response.status_code == 200
def test_recipe_tool_association(api_client: TestClient, tool: TestRecipeTool, unique_user: TestUser):
# Setup Recipe
recipe_data = {"name": random_string(10)}
response = api_client.post(Routes.recipes, json=recipe_data, headers=unique_user.token)
slug = response.json()
assert response.status_code == 201
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
as_json["tools"] = [{"id": tool.id, "name": tool.name}]
# Update Recipe
response = api_client.put(Routes.recipe(slug), json=as_json, headers=unique_user.token)
assert response.status_code == 200
# Get Recipe Data
response = api_client.get(Routes.recipe(slug), headers=unique_user.token)
as_json = response.json()
assert as_json["tools"][0]["id"] == tool.id