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:
parent
912cc6d956
commit
7afdd5b577
43 changed files with 1221 additions and 423 deletions
|
@ -47,6 +47,8 @@
|
|||
|
||||
**Recipe General**
|
||||
- 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.
|
||||
- Foods/Units for Ingredients are now supported (toggle inside your recipe settings)
|
||||
- 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
|
||||
- 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.
|
||||
- Recipe Instructions now have a preview tab to show the rendered markdown before saving.
|
||||
|
||||
### ⚠️ 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.
|
||||
|
|
1
frontend/api/class-interfaces/recipes/index.ts
Normal file
1
frontend/api/class-interfaces/recipes/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { RecipeAPI } from "./recipe";
|
19
frontend/api/class-interfaces/recipes/recipe-comments.ts
Normal file
19
frontend/api/class-interfaces/recipes/recipe-comments.ts
Normal 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));
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import { BaseCRUDAPI } from "../_base";
|
||||
import { Category } from "./categories";
|
||||
import { Tag } from "./tags";
|
||||
import { CreateAsset, ParsedIngredient, Parser, RecipeZipToken, BulkCreatePayload } from "./types";
|
||||
import { CommentsApi } from "./recipe-comments";
|
||||
import { BaseCRUDAPI } from "~/api/_base";
|
||||
|
||||
import { Recipe, CreateRecipe } from "~/types/api-types/recipe";
|
||||
import { ApiRequestInstance } from "~/types/api";
|
||||
|
||||
const prefix = "/api";
|
||||
|
||||
|
@ -26,68 +28,35 @@ const routes = {
|
|||
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> {
|
||||
baseRoute: string = routes.recipesBase;
|
||||
itemRoute = routes.recipesRecipeSlug;
|
||||
|
||||
public comments: CommentsApi;
|
||||
|
||||
constructor(requests: ApiRequestInstance) {
|
||||
super(requests);
|
||||
|
||||
this.comments = new CommentsApi(requests);
|
||||
}
|
||||
|
||||
async getAllByCategory(categories: string[]) {
|
||||
return await this.requests.get<Recipe[]>(routes.recipesCategory, {
|
||||
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) {
|
||||
const formData = new FormData();
|
||||
formData.append("image", fileObject);
|
||||
|
@ -113,8 +82,6 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
|
|||
return await this.requests.post(routes.recipesCreateUrlBulk, payload);
|
||||
}
|
||||
|
||||
// Recipe Comments
|
||||
|
||||
// Methods to Generate reference urls for assets/images *
|
||||
recipeImage(recipeSlug: string, version = null, key = null) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
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>) {
|
||||
parser = parser || "nlp";
|
||||
return await this.requests.post<ParsedIngredient[]>(routes.recipesParseIngredients, { parser, ingredients });
|
83
frontend/api/class-interfaces/recipes/types.ts
Normal file
83
frontend/api/class-interfaces/recipes/types.ts
Normal 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;
|
||||
}
|
22
frontend/api/class-interfaces/tools.ts
Normal file
22
frontend/api/class-interfaces/tools.ts
Normal 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;
|
||||
}
|
|
@ -18,6 +18,7 @@ import { EmailAPI } from "./class-interfaces/email";
|
|||
import { BulkActionsAPI } from "./class-interfaces/recipe-bulk-actions";
|
||||
import { GroupServerTaskAPI } from "./class-interfaces/group-tasks";
|
||||
import { AdminAPI } from "./admin-api";
|
||||
import { ToolsApi } from "./class-interfaces/tools";
|
||||
import { ApiRequestInstance } from "~/types/api";
|
||||
|
||||
class Api {
|
||||
|
@ -40,7 +41,7 @@ class Api {
|
|||
public email: EmailAPI;
|
||||
public bulk: BulkActionsAPI;
|
||||
public grouperServerTasks: GroupServerTaskAPI;
|
||||
|
||||
public tools: ToolsApi;
|
||||
// Utils
|
||||
public upload: UploadFile;
|
||||
|
||||
|
@ -55,6 +56,7 @@ class Api {
|
|||
this.tags = new TagsAPI(requests);
|
||||
this.units = new UnitAPI(requests);
|
||||
this.foods = new FoodAPI(requests);
|
||||
this.tools = new ToolsApi(requests);
|
||||
|
||||
// Users
|
||||
this.users = new UserApi(requests);
|
||||
|
|
|
@ -23,10 +23,10 @@
|
|||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<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-btn>
|
||||
<AppCopyButton :copy-text="copyLink(item.fileName)" />
|
||||
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
|
||||
</div>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
|
@ -35,12 +35,10 @@
|
|||
<div class="d-flex ml-auto mt-2">
|
||||
<v-spacer></v-spacer>
|
||||
<BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset">
|
||||
<template #open="{ open }">
|
||||
<v-btn v-if="edit" color="secondary" dark @click="open">
|
||||
<v-icon>{{ $globals.icons.create }}</v-icon>
|
||||
</v-btn>
|
||||
<template #activator="{ open }">
|
||||
<BaseButton v-if="edit" small create @click="open" />
|
||||
</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>
|
||||
<div class="d-flex justify-space-between">
|
||||
<v-select
|
||||
|
@ -70,9 +68,14 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
export default {
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
const BASE_URL = window.location.origin;
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
|
@ -87,82 +90,95 @@ export default {
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
return { api };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fileObject: {},
|
||||
const state = reactive({
|
||||
fileObject: {} as File,
|
||||
newAsset: {
|
||||
name: "",
|
||||
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>
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog";
|
||||
import RecipeCategoryTagDialog from "./RecipeCategoryTagDialog.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useTags, useCategories } from "~/composables/recipes";
|
||||
const MOUNTED_EVENT = "mounted";
|
||||
|
|
|
@ -1,125 +1,117 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
<v-icon large class="mr-2">
|
||||
<div>
|
||||
<v-card-title class="headline pb-3">
|
||||
<v-icon class="mr-2">
|
||||
{{ $globals.icons.commentTextMultipleOutline }}
|
||||
</v-icon>
|
||||
{{ $t("recipe.comments") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card v-for="(comment, index) in comments" :key="comment.id" class="ma-2">
|
||||
<v-list-item two-line>
|
||||
<v-list-item-avatar color="accent" class="white--text">
|
||||
<img :src="getProfileImage(comment.user.id)" />
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title> {{ comment.user.username }}</v-list-item-title>
|
||||
<v-list-item-subtitle> {{ $d(new Date(comment.dateAdded), "short") }} </v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-card-actions v-if="loggedIn">
|
||||
<TheButton
|
||||
v-if="!editKeys[comment.id] && (user.admin || comment.user.id === user.id)"
|
||||
small
|
||||
minor
|
||||
delete
|
||||
@click="deleteComment(comment.id)"
|
||||
/>
|
||||
<TheButton
|
||||
v-if="!editKeys[comment.id] && comment.user.id === user.id"
|
||||
small
|
||||
edit
|
||||
@click="editComment(comment.id)"
|
||||
/>
|
||||
<TheButton v-else-if="editKeys[comment.id]" small update @click="updateComment(comment.id, index)" />
|
||||
</v-card-actions>
|
||||
</v-list-item>
|
||||
<div>
|
||||
<v-card-text>
|
||||
{{ !editKeys[comment.id] ? comment.text : null }}
|
||||
<v-textarea v-if="editKeys[comment.id]" v-model="comment.text"> </v-textarea>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex mt-3" style="gap: 10px">
|
||||
<v-avatar size="40">
|
||||
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
|
||||
</v-avatar>
|
||||
<v-textarea
|
||||
v-model="comment"
|
||||
hide-details=""
|
||||
dense
|
||||
single-line
|
||||
outlined
|
||||
auto-grow
|
||||
rows="2"
|
||||
placeholder="Join the Conversation"
|
||||
>
|
||||
</v-textarea>
|
||||
</div>
|
||||
<div class="ml-auto mt-1">
|
||||
<BaseButton small :disabled="!comment" @click="submitComment">
|
||||
<template #icon>{{ $globals.icons.check }}</template>
|
||||
{{ $t("general.submit") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="comment in comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
|
||||
<v-avatar size="40">
|
||||
<img alt="user" src="https://cdn.pixabay.com/photo/2020/06/24/19/12/cabbage-5337431_1280.jpg" />
|
||||
</v-avatar>
|
||||
<v-card outlined class="flex-grow-1">
|
||||
<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>
|
||||
</div>
|
||||
</v-card>
|
||||
<v-card-text v-if="loggedIn">
|
||||
<v-textarea v-model="newComment" auto-grow row-height="1" outlined> </v-textarea>
|
||||
<div class="d-flex">
|
||||
<TheButton class="ml-auto" create @click="createNewComment"> {{ $t("recipe.comment-action") }} </TheButton>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card-actions class="justify-end mt-0 pt-0">
|
||||
<v-btn
|
||||
v-if="$auth.user.id == comment.user.id || $auth.user.admin"
|
||||
color="error"
|
||||
text
|
||||
x-small
|
||||
@click="deleteComment(comment.id)"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, toRefs } from "@nuxtjs/composition-api";
|
||||
import { onMounted, reactive } from "vue-demi";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
const NEW_COMMENT_EVENT = "new-comment";
|
||||
const UPDATE_COMMENT_EVENT = "update-comment";
|
||||
export default {
|
||||
import { RecipeComment } from "~/api/class-interfaces/recipes/types";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
comments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
recipeId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
|
||||
return { api };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newComment: "",
|
||||
editKeys: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.getters.getUserData;
|
||||
},
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
comments() {
|
||||
for (const comment of this.comments) {
|
||||
this.$set(this.editKeys, comment.id, false);
|
||||
const comments = ref<RecipeComment[]>([]);
|
||||
|
||||
const state = reactive({
|
||||
comment: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.recipes.comments.byRecipe(props.slug);
|
||||
|
||||
if (data) {
|
||||
comments.value = data;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function submitComment() {
|
||||
const { data } = await api.recipes.comments.createOne({
|
||||
recipeId: props.recipeId,
|
||||
text: state.comment,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
comments.value.push(data);
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
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]);
|
||||
this.$emit(UPDATE_COMMENT_EVENT);
|
||||
},
|
||||
async createNewComment() {
|
||||
await this.api.recipes.createComment(this.slug, { text: this.newComment });
|
||||
this.$emit(NEW_COMMENT_EVENT);
|
||||
|
||||
this.newComment = "";
|
||||
},
|
||||
async deleteComment(id) {
|
||||
await this.api.recipes.deleteComment(this.slug, id);
|
||||
this.$emit(UPDATE_COMMENT_EVENT);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
});
|
||||
</script>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div v-if="value && value.length > 0">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
@ -75,8 +75,8 @@
|
|||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<div v-for="(step, index) in value" :key="index">
|
||||
<v-app-bar v-if="showTitleEditor[index]" class="primary mx-1 mt-6" dark dense rounded>
|
||||
<div v-for="(step, index) in value" :key="step.id">
|
||||
<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-app-bar-title v-text="step.title"> </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
|
@ -114,7 +114,7 @@
|
|||
mode="event"
|
||||
:items="actionEvents || []"
|
||||
@merge-above="mergeAbove(index - 1, index)"
|
||||
@toggle-section="toggleShowTitle(index)"
|
||||
@toggle-section="toggleShowTitle(step.id)"
|
||||
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
|
||||
>
|
||||
</BaseOverflowButton>
|
||||
|
@ -155,6 +155,7 @@ import VueMarkdown from "@adapttive/vue-markdown";
|
|||
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
|
||||
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
|
@ -195,7 +196,7 @@ export default defineComponent({
|
|||
usedIngredients: [] as RecipeIngredient[],
|
||||
});
|
||||
|
||||
const showTitleEditor = ref<boolean[]>([]);
|
||||
const showTitleEditor = ref<{ [key: string]: boolean }>({});
|
||||
|
||||
const actionEvents = [
|
||||
{
|
||||
|
@ -220,12 +221,17 @@ export default defineComponent({
|
|||
|
||||
watch(props.value, (v) => {
|
||||
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?
|
||||
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) {
|
||||
|
@ -246,16 +252,11 @@ export default defineComponent({
|
|||
return "disabled-card";
|
||||
}
|
||||
}
|
||||
function toggleShowTitle(index: number) {
|
||||
const newVal = !showTitleEditor.value[index];
|
||||
if (!newVal) {
|
||||
props.value[index].title = "";
|
||||
}
|
||||
function toggleShowTitle(id: string) {
|
||||
showTitleEditor.value[id] = !showTitleEditor.value[id];
|
||||
|
||||
// Must create a new temporary list due to vue-composition-api backport limitations (I think...)
|
||||
const tempList = [...showTitleEditor.value];
|
||||
tempList[index] = newVal;
|
||||
showTitleEditor.value = tempList;
|
||||
const temp = { ...showTitleEditor.value };
|
||||
showTitleEditor.value = temp;
|
||||
}
|
||||
function updateIndex(data: RecipeStep) {
|
||||
context.emit("input", data);
|
||||
|
@ -387,6 +388,7 @@ export default defineComponent({
|
|||
|
||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
||||
props.value.splice(lastMerge.source, 0, {
|
||||
id: uuid4(),
|
||||
title: "",
|
||||
text: lastMerge.sourceText,
|
||||
ingredientReferences: [],
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<div v-if="valueNotNull || edit">
|
||||
<v-card class="mt-2">
|
||||
<v-card-title class="py-2">
|
||||
<v-card-title class="pt-2 pb-0">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</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">
|
||||
<div v-for="(item, key, index) in value" :key="index">
|
||||
<v-text-field
|
||||
|
@ -19,9 +19,9 @@
|
|||
</div>
|
||||
</v-card-text>
|
||||
<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-title class="pl-4 text-subtitle-1 flex row">
|
||||
<v-list-item-title class="pl-4 caption flex row">
|
||||
<div>{{ item.label }}</div>
|
||||
<div class="ml-auto mr-1">{{ value[key] }}</div>
|
||||
<div>{{ item.suffix }}</div>
|
||||
|
|
88
frontend/components/Domain/Recipe/RecipeTools.vue
Normal file
88
frontend/components/Domain/Recipe/RecipeTools.vue
Normal 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>
|
|
@ -43,7 +43,7 @@ export default {
|
|||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
|
|
|
@ -6,3 +6,4 @@ export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from
|
|||
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
|
||||
export { parseIngredientText } from "./use-recipe-ingredients";
|
||||
export { useRecipeSearch } from "./use-recipe-search";
|
||||
export { useTools } from "./use-recipe-tools";
|
||||
|
|
93
frontend/composables/recipes/use-recipe-tools.ts
Normal file
93
frontend/composables/recipes/use-recipe-tools.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -68,7 +68,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
|
@ -200,8 +200,26 @@
|
|||
: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">
|
||||
<!-- Recipe Categories -->
|
||||
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("recipe.categories") }}
|
||||
|
@ -238,9 +256,23 @@
|
|||
</v-card-text>
|
||||
</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
|
||||
v-if="recipe.settings.showAssets || form"
|
||||
v-if="recipe.settings.showAssets"
|
||||
v-model="recipe.assets"
|
||||
:edit="form"
|
||||
:slug="recipe.slug"
|
||||
|
@ -260,6 +292,69 @@
|
|||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
</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" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
@ -289,7 +384,40 @@
|
|||
</v-card-actions>
|
||||
</v-card-text>
|
||||
</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>
|
||||
<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" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
@ -328,8 +456,11 @@ import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBt
|
|||
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
|
||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.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 { uuid4, deepCopy } from "~/composables/use-utils";
|
||||
import { Tool } from "~/api/class-interfaces/tools";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
|
@ -338,6 +469,7 @@ export default defineComponent({
|
|||
RecipeAssets,
|
||||
RecipeCategoryTagSelector,
|
||||
RecipeChips,
|
||||
RecipeComments,
|
||||
RecipeDialogBulkAdd,
|
||||
RecipeImageUploadBtn,
|
||||
RecipeIngredientEditor,
|
||||
|
@ -349,6 +481,7 @@ export default defineComponent({
|
|||
RecipeRating,
|
||||
RecipeSettingsMenu,
|
||||
RecipeTimeCard,
|
||||
RecipeTools,
|
||||
VueMarkdown,
|
||||
},
|
||||
async beforeRouteLeave(_to, _from, next) {
|
||||
|
@ -484,12 +617,12 @@ export default defineComponent({
|
|||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { text: step, title: "", ingredientReferences: [] };
|
||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
} 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
|
||||
|
||||
|
@ -553,6 +734,8 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
return {
|
||||
createApiExtra,
|
||||
apiNewKey,
|
||||
originalRecipe,
|
||||
domSaveChangesDialog,
|
||||
enableLandscape,
|
||||
|
@ -565,11 +748,13 @@ export default defineComponent({
|
|||
addStep,
|
||||
deleteRecipe,
|
||||
closeEditor,
|
||||
updateTool,
|
||||
updateRecipe,
|
||||
uploadImage,
|
||||
validators,
|
||||
recipeImage,
|
||||
addIngredient,
|
||||
removeApiExtra,
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
|
||||
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 { useUserApi } from "~/composables/api";
|
||||
import { useRecipe, useFoods, useUnits } from "~/composables/recipes";
|
||||
|
|
|
@ -101,6 +101,7 @@ export interface IngredientToStepRef {
|
|||
referenceId: string;
|
||||
}
|
||||
export interface RecipeStep {
|
||||
id: string;
|
||||
title?: string;
|
||||
text: string;
|
||||
ingredientReferences: IngredientToStepRef[];
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
mdiSlotMachine,
|
||||
mdiHome,
|
||||
mdiMagnify,
|
||||
mdiPotSteam,
|
||||
mdiTranslate,
|
||||
mdiClockTimeFourOutline,
|
||||
mdiImport,
|
||||
|
@ -181,6 +182,7 @@ export const icons = {
|
|||
star: mdiStar,
|
||||
testTube: mdiTestTube,
|
||||
tools: mdiTools,
|
||||
potSteam: mdiPotSteam,
|
||||
translate: mdiTranslate,
|
||||
upload: mdiCloudUpload,
|
||||
viewDashboard: mdiViewDashboard,
|
||||
|
|
|
@ -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.recipe import RecipeModel
|
||||
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.sign_up import SignUp
|
||||
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.webhook import ReadWebhook
|
||||
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_tool import RecipeTool
|
||||
from mealie.schema.server import ServerTask
|
||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser, SignUpOut
|
||||
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
|
||||
|
@ -78,8 +80,12 @@ class Database:
|
|||
return AccessModel(self.session, pk_id, IngredientUnitModel, IngredientUnit)
|
||||
|
||||
@cached_property
|
||||
def comments(self) -> AccessModel[CommentOut, RecipeComment]:
|
||||
return AccessModel(self.session, pk_id, RecipeComment, CommentOut)
|
||||
def tools(self) -> AccessModel[RecipeTool, Tool]:
|
||||
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
|
||||
def categories(self) -> CategoryDataAccessModel:
|
||||
|
|
|
@ -11,6 +11,7 @@ class GUID(TypeDecorator):
|
|||
"""
|
||||
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
|
@ -31,9 +32,6 @@ class GUID(TypeDecorator):
|
|||
return "%.32x" % value.int
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
else:
|
||||
if not isinstance(value, uuid.UUID):
|
||||
value = uuid.UUID(value)
|
||||
return value
|
||||
if value is not None and not isinstance(value, uuid.UUID):
|
||||
value = uuid.UUID(value)
|
||||
return value
|
||||
|
|
|
@ -7,7 +7,7 @@ class ApiExtras(SqlAlchemyBase):
|
|||
__tablename__ = "api_extras"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
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)
|
||||
|
||||
def __init__(self, key, value) -> None:
|
||||
|
|
|
@ -1,37 +1,28 @@
|
|||
from datetime import datetime
|
||||
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.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.users import User
|
||||
|
||||
|
||||
def generate_uuid():
|
||||
return str(uuid4())
|
||||
from mealie.db.models._model_utils import auto_init
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
|
||||
class RecipeComment(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_comments"
|
||||
id = Column(Integer, primary_key=True)
|
||||
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)
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
text = Column(String)
|
||||
|
||||
def __init__(self, recipe_slug, user, text, session, date_added=None, **_) -> None:
|
||||
self.text = text
|
||||
self.recipe = RecipeModel.get_ref(session, recipe_slug, "slug")
|
||||
self.date_added = date_added or datetime.now()
|
||||
# Recipe Link
|
||||
recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
|
||||
recipe = orm.relationship("RecipeModel", back_populates="comments")
|
||||
|
||||
if isinstance(user, dict):
|
||||
user = user.get("id")
|
||||
# User Link
|
||||
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:
|
||||
self.text = text
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
@ -17,7 +19,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
|||
|
||||
class RecipeInstruction(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_instructions"
|
||||
id = Column(Integer, primary_key=True)
|
||||
id = Column(GUID(), primary_key=True, default=uuid4)
|
||||
parent_id = Column(Integer, ForeignKey("recipes.id"))
|
||||
position = Column(Integer)
|
||||
type = Column(String, default="")
|
||||
|
|
|
@ -18,7 +18,22 @@ from .note import Note
|
|||
from .nutrition import Nutrition
|
||||
from .settings import RecipeSettings
|
||||
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):
|
||||
|
@ -52,10 +67,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
recipe_yield = sa.Column(sa.String)
|
||||
recipeCuisine = sa.Column(sa.String)
|
||||
|
||||
tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan")
|
||||
assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
|
||||
assets = orm.relationship("RecipeAsset", 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")
|
||||
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
|
||||
|
||||
recipe_ingredient: list[RecipeIngredient] = orm.relationship(
|
||||
"RecipeIngredient",
|
||||
|
@ -88,45 +103,36 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
|||
get_attr = "slug"
|
||||
exclude = {
|
||||
"assets",
|
||||
"extras",
|
||||
"notes",
|
||||
"nutrition",
|
||||
"recipe_ingredient",
|
||||
"settings",
|
||||
"tools",
|
||||
}
|
||||
|
||||
@validates("name")
|
||||
def validate_name(self, key, name):
|
||||
def validate_name(self, _, name):
|
||||
assert name != ""
|
||||
return name
|
||||
|
||||
@recipe_extras
|
||||
@auto_init()
|
||||
def __init__(
|
||||
self,
|
||||
session,
|
||||
assets: list = None,
|
||||
extras: dict = None,
|
||||
notes: list[dict] = None,
|
||||
nutrition: dict = None,
|
||||
recipe_ingredient: list[str] = None,
|
||||
settings: dict = None,
|
||||
tools: list[str] = None,
|
||||
**_,
|
||||
) -> None:
|
||||
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.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
|
||||
self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
|
||||
self.notes = [Note(**note) for note in notes]
|
||||
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
|
||||
|
||||
# Time Stampes
|
||||
self.date_updated = datetime.datetime.now()
|
||||
|
|
|
@ -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"
|
||||
id = sa.Column(sa.Integer, primary_key=True)
|
||||
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
|
||||
tool = sa.Column(sa.String)
|
||||
name = Column(String, index=True, unique=True, nullable=False)
|
||||
on_hand = Column(Boolean, default=False)
|
||||
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools")
|
||||
|
||||
def __init__(self, tool) -> None:
|
||||
self.tool = tool
|
||||
@auto_init()
|
||||
def __init__(self, name, on_hand, **_) -> None:
|
||||
self.on_hand = on_hand
|
||||
self.name = name
|
||||
|
|
|
@ -1,6 +1,20 @@
|
|||
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")
|
||||
|
||||
|
@ -9,8 +23,10 @@ router.include_router(auth.router)
|
|||
router.include_router(users.router)
|
||||
router.include_router(groups.router)
|
||||
router.include_router(recipe.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(parser.router)
|
||||
router.include_router(unit_and_foods.router)
|
||||
router.include_router(tools.router)
|
||||
router.include_router(categories.router)
|
||||
router.include_router(tags.router)
|
||||
router.include_router(shopping_lists.router)
|
||||
|
|
8
mealie/routes/comments/__init__.py
Normal file
8
mealie/routes/comments/__init__.py
Normal 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"]))
|
|
@ -1,58 +1,20 @@
|
|||
from http.client import HTTPException
|
||||
|
||||
from fastapi import Depends, status
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.routers import UserAPIRouter
|
||||
from mealie.schema.recipe import CommentOut, CreateComment, SaveComment
|
||||
from mealie.schema.user import PrivateUser
|
||||
from mealie.schema.recipe.recipe_comments import RecipeCommentOut
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
@router.post("/{slug}/comments")
|
||||
async def create_comment(
|
||||
@router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
|
||||
async def get_recipe_comments(
|
||||
slug: str,
|
||||
new_comment: CreateComment,
|
||||
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)
|
||||
|
||||
new_comment = SaveComment(user=current_user.id, text=new_comment.text, recipe_slug=slug)
|
||||
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)
|
||||
recipe = db.recipes.get_one(slug)
|
||||
return db.comments.multi_query({"recipe_id": recipe.id})
|
||||
|
|
8
mealie/routes/tools/__init__.py
Normal file
8
mealie/routes/tools/__init__.py
Normal 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"]))
|
|
@ -11,12 +11,13 @@ from mealie.core.config import get_app_dirs
|
|||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
from .recipe_asset import RecipeAsset
|
||||
from .recipe_comments import CommentOut
|
||||
from .recipe_comments import RecipeCommentOut
|
||||
from .recipe_ingredient import RecipeIngredient
|
||||
from .recipe_notes import RecipeNote
|
||||
from .recipe_nutrition import Nutrition
|
||||
from .recipe_settings import RecipeSettings
|
||||
from .recipe_step import RecipeStep
|
||||
from .recipe_tool import RecipeTool
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
|
||||
|
@ -101,7 +102,7 @@ class Recipe(RecipeSummary):
|
|||
recipe_ingredient: Optional[list[RecipeIngredient]] = []
|
||||
recipe_instructions: Optional[list[RecipeStep]] = []
|
||||
nutrition: Optional[Nutrition]
|
||||
tools: Optional[list[str]] = []
|
||||
tools: list[RecipeTool] = []
|
||||
|
||||
# Mealie Specific
|
||||
settings: Optional[RecipeSettings] = RecipeSettings()
|
||||
|
@ -109,7 +110,7 @@ class Recipe(RecipeSummary):
|
|||
notes: Optional[list[RecipeNote]] = []
|
||||
extras: Optional[dict] = {}
|
||||
|
||||
comments: Optional[list[CommentOut]] = []
|
||||
comments: Optional[list[RecipeCommentOut]] = []
|
||||
|
||||
@staticmethod
|
||||
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_category": [x.name for x in name_orm.recipe_category],
|
||||
# "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},
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic.utils import GetterDict
|
||||
|
||||
|
||||
class UserBase(CamelModel):
|
||||
|
@ -14,31 +14,27 @@ class UserBase(CamelModel):
|
|||
orm_mode = True
|
||||
|
||||
|
||||
class CreateComment(CamelModel):
|
||||
class RecipeCommentCreate(CamelModel):
|
||||
recipe_id: int
|
||||
text: str
|
||||
|
||||
|
||||
class SaveComment(CreateComment):
|
||||
recipe_slug: str
|
||||
user: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
class RecipeCommentSave(RecipeCommentCreate):
|
||||
user_id: int
|
||||
|
||||
|
||||
class CommentOut(CreateComment):
|
||||
id: int
|
||||
uuid: str
|
||||
recipe_slug: str
|
||||
date_added: datetime
|
||||
class RecipeCommentUpdate(CamelModel):
|
||||
id: UUID
|
||||
text: str
|
||||
|
||||
|
||||
class RecipeCommentOut(RecipeCommentCreate):
|
||||
id: UUID
|
||||
recipe_id: int
|
||||
created_at: datetime
|
||||
update_at: datetime
|
||||
user_id: int
|
||||
user: UserBase
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@classmethod
|
||||
def getter_dict(_cls, name_orm):
|
||||
return {
|
||||
**GetterDict(name_orm),
|
||||
"recipe_slug": name_orm.recipe.slug,
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class IngredientReferences(CamelModel):
|
||||
|
@ -16,6 +17,7 @@ class IngredientReferences(CamelModel):
|
|||
|
||||
|
||||
class RecipeStep(CamelModel):
|
||||
id: Optional[UUID] = Field(default_factory=uuid4)
|
||||
title: Optional[str] = ""
|
||||
text: str
|
||||
ingredient_references: list[IngredientReferences] = []
|
||||
|
|
13
mealie/schema/recipe/recipe_tool.py
Normal file
13
mealie/schema/recipe/recipe_tool.py
Normal 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
|
|
@ -8,15 +8,15 @@ from pydantic.main import BaseModel
|
|||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_dirs
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
from mealie.db.database import get_database
|
||||
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
|
||||
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.services.image import minify
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
|
||||
|
||||
class ImportDatabase:
|
||||
def __init__(
|
||||
|
@ -57,7 +57,10 @@ class ImportDatabase:
|
|||
successful_imports = {}
|
||||
|
||||
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:
|
||||
|
@ -76,7 +79,7 @@ class ImportDatabase:
|
|||
)
|
||||
|
||||
if import_status.status:
|
||||
successful_imports.update({recipe.slug: recipe})
|
||||
successful_imports[recipe.slug] = recipe
|
||||
|
||||
imports.append(import_status)
|
||||
|
||||
|
@ -90,10 +93,10 @@ class ImportDatabase:
|
|||
if not comment_dir.exists():
|
||||
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:
|
||||
comment: CommentOut
|
||||
comment: RecipeCommentOut
|
||||
|
||||
self.import_model(
|
||||
db_table=self.db.comments,
|
||||
|
@ -130,6 +133,8 @@ class ImportDatabase:
|
|||
if type(recipe_dict["extras"]) == list:
|
||||
recipe_dict["extras"] = {}
|
||||
|
||||
recipe_dict["comments"] = []
|
||||
|
||||
return recipe_dict
|
||||
|
||||
def _import_images(self, successful_imports: list[Recipe]):
|
||||
|
@ -328,8 +333,8 @@ def import_database(
|
|||
if import_notifications:
|
||||
notification_report = import_session.import_notifications()
|
||||
|
||||
if import_recipes:
|
||||
import_session.import_comments()
|
||||
# if import_recipes:
|
||||
# import_session.import_comments()
|
||||
|
||||
import_session.clean_up()
|
||||
|
||||
|
|
52
mealie/services/recipe/recipe_comments_service.py
Normal file
52
mealie/services/recipe/recipe_comments_service.py
Normal 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)
|
37
mealie/services/recipe/recipe_tool_service.py
Normal file
37
mealie/services/recipe/recipe_tool_service.py
Normal 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)
|
|
@ -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
|
|
@ -39,7 +39,6 @@ def test_read_update(
|
|||
]
|
||||
|
||||
recipe["notes"] = test_notes
|
||||
recipe["tools"] = ["one tool", "two tool"]
|
||||
|
||||
test_categories = [
|
||||
{"name": "one", "slug": "one"},
|
||||
|
|
104
tests/integration_tests/user_recipe_tests/test_recipe_tools.py
Normal file
104
tests/integration_tests/user_recipe_tests/test_recipe_tools.py
Normal 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
|
Loading…
Reference in a new issue