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**
- 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.

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 { 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 });

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 { 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);

View file

@ -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>

View file

@ -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";

View file

@ -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>

View file

@ -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>

View file

@ -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: [],

View file

@ -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>

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: {
type: String,
default: "primary",
default: "",
},
icon: {
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 { parseIngredientText } from "./use-recipe-ingredients";
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">
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({

View file

@ -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: {},

View file

@ -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";

View file

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

View file

@ -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,

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.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:

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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="")

View file

@ -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()

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"
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

View file

@ -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)

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, 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})

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 .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},
}

View file

@ -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,
}

View file

@ -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] = []

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 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()

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["tools"] = ["one tool", "two tool"]
test_categories = [
{"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