mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
Feature/recipe instructions improvements (#785)
* feat(frontend): ✨ split paragraph by 1. 1) or 1: regex matches * feat(frontend): ✨ Update frontend to support ingredient To step refs * feat(backend): ✨ Update backend to support ingredient to step refs * fix title editor * move about info to site-settings * update change-log Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
parent
9f8c61a75a
commit
5cb4a1ade0
@ -15,14 +15,16 @@
|
||||
- Mealie has gone through a big redesign and has tried to standardize it's look a feel a bit more across the board.
|
||||
- User/Group settings are now completely separated from the Administration page.
|
||||
- All settings and configurations pages now have some sort of self-documenting help text. Additional text or descriptions are welcome from PRs
|
||||
- Site settings now has status on whether or not some ENV variables have been configured correctly.
|
||||
|
||||
|
||||
**Site Settings Page**
|
||||
- Site Settings has been completely revamped. All site-wide settings at defined on the server as ENV variables. The site settings page now only shows you the non-secret values for reference. It also has some helpers to let you know if something isn't configured correctly.
|
||||
- Server Side Bare URL will let you know if the BASE_URL env variable has been set
|
||||
- Secure Site let's you know if you're serving via HTTPS or accessing by localhost. accessing without a secure site will render some of the features unusable.
|
||||
- Email Configuration Status will let you know if all the email settings have been provided and offer a way to send test emails.
|
||||
|
||||
|
||||
### 👨👩👧👦 Users and Groups
|
||||
- Recipes are now only viewable by group members
|
||||
- All members of a group can generate invitation tokens for other users to join their group
|
||||
- Users now a have "Advanced" setting to enable/disable features like Webhooks and API tokens. This will also apply to future features that are deemed as advanced.
|
||||
- "Pages" have been dropped in favor of Cookbooks which are now group specific so each group can have it's own set of cookbooks
|
||||
@ -37,17 +39,34 @@
|
||||
- Add Recipes or Notes to a specific day
|
||||
|
||||
### 🥙 Recipes
|
||||
|
||||
**Recipe General**
|
||||
- Recipes are now only viewable by group members
|
||||
- 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)
|
||||
- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor.
|
||||
- Common Food and Units come pre-packaged with Mealie
|
||||
- Recipes can now scale when Food/Units are properly defined
|
||||
- Landscape and Portrait views is now available
|
||||
- Users with the advanced flag turned on will not be able to manage recipe data in bulk and perform the following actions:
|
||||
- Set Categories
|
||||
- Set Tags
|
||||
- Delete Recipes
|
||||
- Export Recipes
|
||||
- Recipes now have a `/cook` page for a simple view of the recipe where you can click through each step of a recipe and it's associated ingredients.
|
||||
- The Bulk Importer has received many additional upgrades.
|
||||
- Trim Whitespace: automatically removes leading and trailing whitespace
|
||||
- Trim Prefix: Removes the first character of each line. Useful for when you paste in a list of ingredients or instructions that have 1. or 2. in front of them.
|
||||
- Split By Numbered Line: Attempts to split a paragraph into multiple lines based on the patterns matching '1.', '1:' or '1)'.
|
||||
|
||||
**Recipe Ingredients**
|
||||
- Recipe ingredients can now be scaled when the food/unit is defined
|
||||
- Recipe ingredients can no be copied as markdown lists
|
||||
- example `- [ ] 1 cup of flour`
|
||||
- You can no use Natural Language Processing (NLP) to process ingredients and attempt to parse them into amounts, units, and foods. There additional is a "Brute Force" processor that can be used to use a pattern matching parser to try and determine ingredients. **Note** if you are processing a Non-English language you will have terrible results with the NLP and will likely need to use the Bruce Force processor.
|
||||
|
||||
**Recipe Instructions**
|
||||
- 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.
|
||||
|
||||
### ⚠️ 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.
|
||||
|
@ -6,7 +6,6 @@
|
||||
color="rgb(255, 0, 0, 0.0)"
|
||||
flat
|
||||
style="z-index: 2; position: sticky"
|
||||
:class="{ 'fixed-bar-mobile': $vuetify.breakpoint.xs }"
|
||||
>
|
||||
<BaseDialog
|
||||
ref="deleteRecipieConfirm"
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<v-dialog v-model="dialog" width="800">
|
||||
<template #activator="{ on, attrs }">
|
||||
<BaseButton v-bind="attrs" v-on="on" @click="inputText = ''">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
@ -20,7 +20,7 @@
|
||||
<v-textarea
|
||||
v-model="inputText"
|
||||
outlined
|
||||
rows="10"
|
||||
rows="12"
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
>
|
||||
</v-textarea>
|
||||
@ -41,6 +41,14 @@
|
||||
</template>
|
||||
<span> Trim first character from each line </span>
|
||||
</v-tooltip>
|
||||
<v-tooltip top>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="splitByNumberedLine" v-on="on">
|
||||
Split By Numbered Line
|
||||
</v-btn>
|
||||
</template>
|
||||
<span> Attempts to split a paragraph by matching 1) or 1. patterns </span>
|
||||
</v-tooltip>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
@ -74,6 +82,18 @@ export default defineComponent({
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const numberedLineRegex = /\d[.):] /gm;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = state.inputText.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
state.inputText = state.inputText.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
@ -93,6 +113,7 @@ export default defineComponent({
|
||||
splitText,
|
||||
trimAllLines,
|
||||
removeFirstCharacter,
|
||||
splitByNumberedLine,
|
||||
save,
|
||||
...toRefs(state),
|
||||
};
|
||||
|
@ -1,6 +1,54 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<section @keyup.ctrl.90="undoMerge">
|
||||
<!-- Ingredient Link Editor -->
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<v-card>
|
||||
<v-app-bar dark color="primary" class="mt-n1 mb-3">
|
||||
<v-icon large left>
|
||||
{{ $globals.icons.link }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> Ingredient Linker </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
|
||||
<v-card-text class="pt-4">
|
||||
<p>
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-checkbox
|
||||
v-for="ing in ingredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:label="ing.note"
|
||||
:value="ing.referenceId"
|
||||
class="mb-n2 mt-n2"
|
||||
></v-checkbox>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton color="info" @click="autoSetReferences">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
Auto
|
||||
</BaseButton>
|
||||
<BaseButton save @click="setIngredientIds"> </BaseButton>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
|
||||
<template #icon>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
Cook
|
||||
</BaseButton>
|
||||
</div>
|
||||
<draggable
|
||||
:disabled="!edit"
|
||||
:value="value"
|
||||
@ -35,23 +83,24 @@
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(value, index)"
|
||||
>
|
||||
<v-btn v-if="edit" fab x-small color="white" class="mr-2" elevation="0" @click="value.splice(index, 1)">
|
||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
||||
|
||||
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||
</v-btn>
|
||||
<div class="ml-auto">
|
||||
<BaseOverflowButton
|
||||
v-if="edit"
|
||||
small
|
||||
mode="event"
|
||||
:items="actionEvents || []"
|
||||
@merge-above="mergeAbove(index - 1, index)"
|
||||
@toggle-section="toggleShowTitle(index)"
|
||||
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
|
||||
>
|
||||
</BaseOverflowButton>
|
||||
</div>
|
||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-fade-transition>
|
||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||
@ -62,11 +111,17 @@
|
||||
<v-card-text v-if="edit">
|
||||
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
||||
</v-textarea>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
@ -77,81 +132,242 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
// @ts-ignore
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
export default {
|
||||
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
|
||||
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
source: number;
|
||||
targetText: string;
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
draggable,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
type: Array as () => RecipeStep[],
|
||||
required: true,
|
||||
},
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
ingredients: {
|
||||
type: Array as () => RecipeIngredient[],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
showTitleEditor: [],
|
||||
};
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
disabledSteps: [] as number[],
|
||||
});
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.disabledSteps = [];
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
const showTitleEditor = ref<boolean[]>([]);
|
||||
|
||||
const actionEvents = [
|
||||
{
|
||||
text: "Toggle Section",
|
||||
event: "toggle-section",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Link Ingredients",
|
||||
event: "link-ingredients",
|
||||
},
|
||||
{
|
||||
text: "Merge Above",
|
||||
event: "merge-above",
|
||||
},
|
||||
];
|
||||
|
||||
mounted() {
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeByIndex(list, index) {
|
||||
list.splice(index, 1);
|
||||
},
|
||||
validateTitle(title) {
|
||||
// ===============================================================
|
||||
// UI State Helpers
|
||||
function validateTitle(title: string | undefined) {
|
||||
return !(title === null || title === "");
|
||||
},
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.edit) return;
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
const index = this.disabledSteps.indexOf(stepIndex);
|
||||
}
|
||||
|
||||
watch(props.value, (v) => {
|
||||
state.disabledSteps = [];
|
||||
showTitleEditor.value = v.map((x) => validateTitle(x.title));
|
||||
});
|
||||
|
||||
// Eliminate state with an eager call to watcher?
|
||||
onMounted(() => {
|
||||
showTitleEditor.value = props.value.map((x) => validateTitle(x.title));
|
||||
});
|
||||
|
||||
function toggleDisabled(stepIndex: number) {
|
||||
if (props.edit) {
|
||||
return;
|
||||
}
|
||||
if (state.disabledSteps.includes(stepIndex)) {
|
||||
const index = state.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
state.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
state.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isChecked(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex) && !this.edit) {
|
||||
}
|
||||
function isChecked(stepIndex: number) {
|
||||
if (state.disabledSteps.includes(stepIndex) && !props.edit) {
|
||||
return "disabled-card";
|
||||
}
|
||||
},
|
||||
toggleShowTitle(index) {
|
||||
const newVal = !this.showTitleEditor[index];
|
||||
}
|
||||
function toggleShowTitle(index: number) {
|
||||
const newVal = !showTitleEditor.value[index];
|
||||
if (!newVal) {
|
||||
this.value[index].title = "";
|
||||
props.value[index].title = "";
|
||||
}
|
||||
this.$set(this.showTitleEditor, index, newVal);
|
||||
},
|
||||
updateIndex(data) {
|
||||
this.$emit("input", data);
|
||||
},
|
||||
|
||||
// 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;
|
||||
}
|
||||
function updateIndex(data: RecipeStep) {
|
||||
context.emit("input", data);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Ingredient Linker
|
||||
const activeRefs = ref<String[]>([]);
|
||||
const activeIndex = ref(0);
|
||||
const activeText = ref("");
|
||||
|
||||
function openDialog(idx: number, refs: IngredientToStepRef[], text: string) {
|
||||
activeText.value = text;
|
||||
activeIndex.value = idx;
|
||||
state.dialog = true;
|
||||
activeRefs.value = refs.map((ref) => ref.referenceId);
|
||||
}
|
||||
|
||||
function setIngredientIds() {
|
||||
const instruction = props.value[activeIndex.value];
|
||||
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
||||
return {
|
||||
referenceId: ref as string,
|
||||
};
|
||||
});
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
function autoSetReferences() {
|
||||
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
|
||||
// and only use the "notes" feature.
|
||||
const blackListedText = [
|
||||
"and",
|
||||
"or",
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"to",
|
||||
"for",
|
||||
"by",
|
||||
"with",
|
||||
"without",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
const blackListedRegexMatch = /\d/gm; // Match Any Number
|
||||
|
||||
// Check if any of the words in the active text match the ingredient text
|
||||
const instructionsByWord = activeText.value.toLowerCase().split(" ");
|
||||
|
||||
instructionsByWord.forEach((word) => {
|
||||
if (blackListedText.includes(word) || word.match(blackListedRegexMatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.ingredients.forEach((ingredient) => {
|
||||
if (
|
||||
ingredient.note.toLowerCase().includes(" " + word) &&
|
||||
!activeRefs.value.includes(ingredient.referenceId)
|
||||
) {
|
||||
console.info("Word Matched", `'${word}'`, ingredient.note);
|
||||
activeRefs.value.push(ingredient.referenceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getIngredientByRefId(refId: String) {
|
||||
return props.ingredients.find((ing) => ing.referenceId === refId) || "";
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Instruction Merger
|
||||
const mergeHistory = ref<MergerHistory[]>([]);
|
||||
|
||||
function mergeAbove(target: number, source: number) {
|
||||
if (target < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeHistory.value.push({
|
||||
target,
|
||||
source,
|
||||
targetText: props.value[target].text,
|
||||
sourceText: props.value[source].text,
|
||||
});
|
||||
|
||||
props.value[target].text += " " + props.value[source].text;
|
||||
props.value.splice(source, 1);
|
||||
}
|
||||
|
||||
function undoMerge(event: KeyboardEvent) {
|
||||
if (event.ctrlKey && event.code === "KeyZ") {
|
||||
if (!(mergeHistory.value?.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMerge = mergeHistory.value.pop();
|
||||
if (!lastMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
||||
props.value.splice(lastMerge.source, 0, {
|
||||
title: "",
|
||||
text: lastMerge.sourceText,
|
||||
ingredientReferences: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
actionEvents,
|
||||
activeRefs,
|
||||
activeText,
|
||||
getIngredientByRefId,
|
||||
showTitleEditor,
|
||||
mergeAbove,
|
||||
openDialog,
|
||||
setIngredientIds,
|
||||
undoMerge,
|
||||
toggleDisabled,
|
||||
isChecked,
|
||||
toggleShowTitle,
|
||||
updateIndex,
|
||||
autoSetReferences,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
v-on="$listeners"
|
||||
@click="download ? downloadFile() : undefined"
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon v-if="!iconRight" left>
|
||||
<slot name="icon">
|
||||
{{ btnAttrs.icon }}
|
||||
</slot>
|
||||
@ -20,6 +20,11 @@
|
||||
<slot name="default">
|
||||
{{ btnAttrs.text }}
|
||||
</slot>
|
||||
<v-icon v-if="iconRight" right>
|
||||
<slot name="icon">
|
||||
{{ btnAttrs.icon }}
|
||||
</slot>
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
@ -96,6 +101,10 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="primary" v-bind="attrs" :class="btnClass" :disabled="disabled" v-on="on">
|
||||
<v-btn color="primary" v-bind="{ ...attrs, ...$attrs }" :class="btnClass" :disabled="disabled" v-on="on">
|
||||
<v-icon v-if="activeObj.icon" left>
|
||||
{{ activeObj.icon }}
|
||||
</v-icon>
|
||||
|
@ -133,11 +133,6 @@ export default defineComponent({
|
||||
title: i18n.t("about.support"),
|
||||
href: "https://github.com/sponsors/hay-kot",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.information,
|
||||
title: i18n.t("about.about"),
|
||||
to: "/admin/about",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
|
@ -1,111 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-card class="mt-3">
|
||||
<v-card-title class="headline">
|
||||
{{ $t("about.about-mealie") }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<v-list-item-group color="primary">
|
||||
<v-list-item v-for="property in appInfo" :key="property.name">
|
||||
<v-list-item-icon>
|
||||
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pl-4 flex row justify-space-between">
|
||||
<div>{{ property.name }}</div>
|
||||
<div>{{ property.value }}</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-item-group>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useAsync, useContext } from "@nuxtjs/composition-api";
|
||||
import { useAdminApi } from "~/composables/use-api";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
|
||||
export default defineComponent({
|
||||
layout: "admin",
|
||||
setup() {
|
||||
const adminApi = useAdminApi();
|
||||
// @ts-ignore
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
function getAppInfo() {
|
||||
const statistics = useAsync(async () => {
|
||||
const { data } = await adminApi.about.about();
|
||||
|
||||
if (data) {
|
||||
const prettyInfo = [
|
||||
{
|
||||
name: i18n.t("about.version"),
|
||||
icon: $globals.icons.information,
|
||||
value: data.version,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.application-mode"),
|
||||
icon: $globals.icons.devTo,
|
||||
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.demo-status"),
|
||||
icon: $globals.icons.testTube,
|
||||
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-port"),
|
||||
icon: $globals.icons.api,
|
||||
value: data.apiPort,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-docs"),
|
||||
icon: $globals.icons.file,
|
||||
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-type"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbType,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-url"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbUrl,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.default-group"),
|
||||
icon: $globals.icons.group,
|
||||
value: data.defaultGroup,
|
||||
},
|
||||
];
|
||||
|
||||
return prettyInfo;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
const appInfo = getAppInfo();
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("about.about") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
@ -69,14 +69,42 @@
|
||||
</template>
|
||||
</v-card>
|
||||
</section>
|
||||
<section class="mt-4">
|
||||
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle>
|
||||
<v-card class="mb-4">
|
||||
<v-list-item v-for="property in appInfo" :key="property.name">
|
||||
<v-list-item-icon>
|
||||
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<div>{{ property.name }}</div>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-end">
|
||||
{{ property.value }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, reactive, toRefs, ref } from "@nuxtjs/composition-api";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
toRefs,
|
||||
ref,
|
||||
defineComponent,
|
||||
useAsync,
|
||||
useContext,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { CheckAppConfig } from "~/api/admin/admin-about";
|
||||
import { useAdminApi, useApiSingleton } from "~/composables/use-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
|
||||
interface SimpleCheck {
|
||||
status: boolean;
|
||||
@ -104,9 +132,9 @@ export default defineComponent({
|
||||
|
||||
const api = useApiSingleton();
|
||||
|
||||
const adminAPI = useAdminApi();
|
||||
const adminApi = useAdminApi();
|
||||
onMounted(async () => {
|
||||
const { data } = await adminAPI.about.checkApp();
|
||||
const { data } = await adminApi.about.checkApp();
|
||||
|
||||
if (data) {
|
||||
appConfig.value = data;
|
||||
@ -173,6 +201,70 @@ export default defineComponent({
|
||||
return booly ? "success" : "error";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// General About Info
|
||||
// @ts-ignore
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
function getAppInfo() {
|
||||
const statistics = useAsync(async () => {
|
||||
const { data } = await adminApi.about.about();
|
||||
|
||||
if (data) {
|
||||
const prettyInfo = [
|
||||
{
|
||||
name: i18n.t("about.version"),
|
||||
icon: $globals.icons.information,
|
||||
value: data.version,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.application-mode"),
|
||||
icon: $globals.icons.devTo,
|
||||
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.demo-status"),
|
||||
icon: $globals.icons.testTube,
|
||||
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-port"),
|
||||
icon: $globals.icons.api,
|
||||
value: data.apiPort,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-docs"),
|
||||
icon: $globals.icons.file,
|
||||
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-type"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbType,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-url"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbUrl,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.default-group"),
|
||||
icon: $globals.icons.group,
|
||||
value: data.defaultGroup,
|
||||
},
|
||||
];
|
||||
|
||||
return prettyInfo;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
const appInfo = getAppInfo();
|
||||
|
||||
return {
|
||||
simpleChecks,
|
||||
getColor,
|
||||
@ -182,6 +274,7 @@ export default defineComponent({
|
||||
validators,
|
||||
...toRefs(state),
|
||||
testEmail,
|
||||
appInfo,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
85
frontend/pages/recipe/_slug/cook.vue
Normal file
85
frontend/pages/recipe/_slug/cook.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<v-container
|
||||
v-if="recipe"
|
||||
:class="{
|
||||
'pa-0': $vuetify.breakpoint.smAndDown,
|
||||
}"
|
||||
>
|
||||
<v-card-title>
|
||||
<h1 class="headline">{{ recipe.name }}</h1>
|
||||
</v-card-title>
|
||||
<v-stepper v-model="activeStep" flat>
|
||||
<v-toolbar class="ma-1 elevation-2 rounded">
|
||||
<v-toolbar-title class="headline">
|
||||
Step {{ activeStep }} of {{ recipe.recipeInstructions.length }}</v-toolbar-title
|
||||
>
|
||||
</v-toolbar>
|
||||
<v-stepper-items>
|
||||
<template v-for="(step, index) in recipe.recipeInstructions">
|
||||
<v-stepper-content :key="index + 1 + '-content'" :step="index + 1" class="pa-0 mt-2 elevation-0">
|
||||
<v-card class="ma-2">
|
||||
<v-card-text>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
<v-divider></v-divider>
|
||||
<h2 class="mb-4 mt-4">{{ $t("recipe.ingredients") }}</h2>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card-actions class="justify-center">
|
||||
<BaseButton color="primary" :disabled="index == 0" @click="activeStep = activeStep - 1">
|
||||
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
|
||||
Back
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
icon-right
|
||||
:disabled="index + 1 == recipe.recipeInstructions.length"
|
||||
color="primary"
|
||||
@click="activeStep = activeStep + 1"
|
||||
>
|
||||
<template #icon> {{ $globals.icons.arrowRightBold }}</template>
|
||||
Next
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</v-stepper-content>
|
||||
</template>
|
||||
</v-stepper-items>
|
||||
</v-stepper>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
|
||||
// @ts-ignore
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { useRecipeContext } from "~/composables/use-recipe-context";
|
||||
export default defineComponent({
|
||||
components: { VueMarkdown },
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const slug = route.value.params.slug;
|
||||
const activeStep = ref(1);
|
||||
|
||||
const { getBySlug } = useRecipeContext();
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
|
||||
const recipe = getBySlug(slug);
|
||||
|
||||
function getIngredientByRefId(refId: String) {
|
||||
return recipe.value?.recipeIngredient.find((ing) => ing.referenceId === refId) || "";
|
||||
}
|
||||
|
||||
return {
|
||||
getIngredientByRefId,
|
||||
activeStep,
|
||||
slug,
|
||||
recipe,
|
||||
recipeImage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -112,7 +112,7 @@
|
||||
<draggable v-model="recipe.recipeIngredient" handle=".handle">
|
||||
<RecipeIngredientEditor
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.ref"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
@ -229,7 +229,11 @@
|
||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="8">
|
||||
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
||||
<RecipeInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
:edit="form"
|
||||
/>
|
||||
<div v-if="form" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
@ -431,12 +435,12 @@ export default defineComponent({
|
||||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { text: step, title: "" };
|
||||
return { text: step, title: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
} else {
|
||||
recipe.value.recipeInstructions.push({ text: "", title: "" });
|
||||
recipe.value.recipeInstructions.push({ text: "", title: "", ingredientReferences: [] });
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,7 +448,7 @@ export default defineComponent({
|
||||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
ref: uuid4(),
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: x,
|
||||
unit: null,
|
||||
@ -459,7 +463,7 @@ export default defineComponent({
|
||||
}
|
||||
} else {
|
||||
recipe?.value?.recipeIngredient?.push({
|
||||
ref: uuid4(),
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
unit: null,
|
||||
|
@ -9,7 +9,7 @@
|
||||
Select one of the various ways to create a recipe
|
||||
<template #content>
|
||||
<div class="ml-auto">
|
||||
<BaseOverflowButton v-model="tab" rounded outlined :items="tabs"> </BaseOverflowButton>
|
||||
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageTitle>
|
||||
|
@ -80,7 +80,7 @@ export interface Recipe {
|
||||
comments?: CommentOut[];
|
||||
}
|
||||
export interface RecipeIngredient {
|
||||
ref: string;
|
||||
referenceId: string;
|
||||
title: string;
|
||||
note: string;
|
||||
unit?: RecipeIngredientUnit | null;
|
||||
@ -96,9 +96,13 @@ export interface RecipeIngredientFood {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface IngredientToStepRef {
|
||||
referenceId: string;
|
||||
}
|
||||
export interface RecipeStep {
|
||||
title?: string;
|
||||
text: string;
|
||||
ingredientReferences: IngredientToStepRef[];
|
||||
}
|
||||
export interface RecipeSettings {
|
||||
public?: boolean;
|
||||
|
@ -100,6 +100,7 @@ import {
|
||||
mdiArrowRightBoldOutline,
|
||||
mdiTimerSand,
|
||||
mdiRefresh,
|
||||
mdiArrowRightBold,
|
||||
} from "@mdi/js";
|
||||
|
||||
export const icons = {
|
||||
@ -113,6 +114,7 @@ export const icons = {
|
||||
alertCircle: mdiAlertCircle,
|
||||
api: mdiApi,
|
||||
arrowLeftBold: mdiArrowLeftBold,
|
||||
arrowRightBold: mdiArrowRightBold,
|
||||
arrowUpDown: mdiDrag,
|
||||
backupRestore: mdiBackupRestore,
|
||||
bellAlert: mdiBellAlert,
|
||||
|
@ -92,7 +92,7 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
|
||||
|
||||
updated_elems.append(existing_elem)
|
||||
|
||||
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
|
||||
new_elems = [safe_call(relation_cls, elem, session=session) for elem in elems_to_create]
|
||||
return new_elems + updated_elems
|
||||
|
||||
|
||||
@ -159,7 +159,7 @@ def auto_init(): # sourcery no-metrics
|
||||
setattr(self, key, instances)
|
||||
|
||||
elif relation_dir == ONETOMANY:
|
||||
instance = safe_call(relation_cls, val)
|
||||
instance = safe_call(relation_cls, val, session=session)
|
||||
setattr(self, key, instance)
|
||||
|
||||
elif relation_dir == MANYTOONE and not use_list:
|
||||
|
39
mealie/db/models/_model_utils/guid.py
Normal file
39
mealie/db/models/_model_utils/guid.py
Normal file
@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.types import CHAR, TypeDecorator
|
||||
|
||||
|
||||
class GUID(TypeDecorator):
|
||||
"""Platform-independent GUID type.
|
||||
Uses PostgreSQL's UUID type, otherwise uses
|
||||
CHAR(32), storing as stringified hex values.
|
||||
"""
|
||||
|
||||
impl = CHAR
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(UUID())
|
||||
else:
|
||||
return dialect.type_descriptor(CHAR(32))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
elif dialect.name == "postgresql":
|
||||
return str(value)
|
||||
else:
|
||||
if not isinstance(value, uuid.UUID):
|
||||
return "%.32x" % uuid.UUID(value).int
|
||||
else:
|
||||
# hexstring
|
||||
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
|
@ -28,12 +28,16 @@ def get_valid_call(func: Callable, args_dict) -> dict:
|
||||
return {k: v for k, v in args_dict.items() if k in valid_args}
|
||||
|
||||
|
||||
def safe_call(func, dict) -> Any:
|
||||
def safe_call(func, dict_args, **kwargs) -> Any:
|
||||
"""
|
||||
Safely calls the supplied function with the supplied dictionary of arguments.
|
||||
by removing any invalid arguments.
|
||||
"""
|
||||
|
||||
if kwargs:
|
||||
dict_args.update(kwargs)
|
||||
|
||||
try:
|
||||
return func(**get_valid_call(func, dict))
|
||||
return func(**get_valid_call(func, dict_args))
|
||||
except TypeError:
|
||||
return func(**dict)
|
||||
return func(**dict_args)
|
||||
|
@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
|
||||
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
|
||||
@ -48,6 +49,8 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
|
||||
food = orm.relationship(IngredientFoodModel, uselist=False)
|
||||
quantity = Column(Integer)
|
||||
|
||||
reference_id = Column(GUID()) # Reference Links
|
||||
|
||||
# Extras
|
||||
|
||||
@auto_init()
|
||||
|
@ -1,6 +1,18 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
|
||||
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_ingredient_ref_link"
|
||||
instruction_id = Column(Integer, ForeignKey("recipe_instructions.id"))
|
||||
reference_id = Column(GUID())
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class RecipeInstruction(SqlAlchemyBase):
|
||||
@ -11,3 +23,9 @@ class RecipeInstruction(SqlAlchemyBase):
|
||||
type = Column(String, default="")
|
||||
title = Column(String)
|
||||
text = Column(String)
|
||||
|
||||
ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
@ -92,7 +92,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
"notes",
|
||||
"nutrition",
|
||||
"recipe_ingredient",
|
||||
"recipe_instructions",
|
||||
"settings",
|
||||
"tools",
|
||||
}
|
||||
@ -111,7 +110,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
notes: list[dict] = None,
|
||||
nutrition: dict = None,
|
||||
recipe_ingredient: list[str] = None,
|
||||
recipe_instructions: list[dict] = None,
|
||||
settings: dict = None,
|
||||
tools: list[str] = None,
|
||||
**_,
|
||||
@ -120,10 +118,10 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
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
|
||||
]
|
||||
# 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()
|
||||
|
@ -43,7 +43,7 @@ class RecipeIngredient(CamelModel):
|
||||
# Ref is used as a way to distinguish between an individual ingredient on the frontend
|
||||
# It is required for the reorder and section titles to function properly because of how
|
||||
# Vue handles reactivity. ref may serve another purpose in the future.
|
||||
ref: UUID = Field(default_factory=uuid4)
|
||||
reference_id: UUID = Field(default_factory=uuid4)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
@ -1,11 +1,24 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
class IngredientReferences(CamelModel):
|
||||
"""
|
||||
A list of ingredient references.
|
||||
"""
|
||||
|
||||
reference_id: UUID = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class RecipeStep(CamelModel):
|
||||
title: Optional[str] = ""
|
||||
text: str
|
||||
ingredient_references: list[IngredientReferences] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
Loading…
x
Reference in New Issue
Block a user