feat: merge food into another (#1143)
* setup food repository * add merge route and payloads * remove type checking * generate types * implement merge dialog * food repo tests * split install from workflow * bum dependencies * revert changes * update copy * refactor URLs to avoid incorrect template being used * stick advanced items under developer mode * use utility component for advanced feature
This commit is contained in:
parent
10784b6e24
commit
b93dae109e
21 changed files with 319 additions and 175 deletions
2
.github/workflows/backend-tests.yml
vendored
2
.github/workflows/backend-tests.yml
vendored
|
@ -32,7 +32,6 @@ jobs:
|
|||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
# Steps
|
||||
steps:
|
||||
#----------------------------------------------
|
||||
|
@ -70,6 +69,7 @@ jobs:
|
|||
poetry install
|
||||
poetry add "psycopg2-binary==2.8.6"
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
|
||||
#----------------------------------------------
|
||||
# run test suite
|
||||
#----------------------------------------------
|
||||
|
|
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
|
@ -5,11 +5,7 @@
|
|||
"backend",
|
||||
"code-generation"
|
||||
],
|
||||
"cSpell.enableFiletypes": [
|
||||
"!javascript",
|
||||
"!python",
|
||||
"!yaml"
|
||||
],
|
||||
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
|
||||
"cSpell.words": [
|
||||
"chowdown",
|
||||
"compression",
|
||||
|
@ -24,9 +20,7 @@
|
|||
"source.organizeImports": false
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.workingDirectories": [
|
||||
"./frontend"
|
||||
],
|
||||
"eslint.workingDirectories": ["./frontend"],
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/.DS_Store": true,
|
||||
|
@ -35,9 +29,7 @@
|
|||
"**/.svn": true,
|
||||
"**/CVS": true
|
||||
},
|
||||
"i18n-ally.enabledFrameworks": [
|
||||
"vue"
|
||||
],
|
||||
"i18n-ally.enabledFrameworks": ["vue"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": "frontend/lang/messages",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
|
@ -45,26 +37,14 @@
|
|||
"python.linting.enabled": true,
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.linting.pylintArgs": [
|
||||
"--rcfile=${workspaceFolder}/.pylintrc"
|
||||
],
|
||||
"python.linting.pylintArgs": ["--rcfile=${workspaceFolder}/.pylintrc"],
|
||||
"python.testing.autoTestDiscoverOnSaveEnabled": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.pytestArgs": ["tests"],
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"python.linting.mypyEnabled": true,
|
||||
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort",
|
||||
"search.mode": "reuseEditor",
|
||||
"vetur.validation.template": false,
|
||||
"coverage-gutters.lcovname": "${workspaceFolder}/.coverage",
|
||||
"python.testing.unittestArgs": [
|
||||
"-v",
|
||||
"-s",
|
||||
"./tests",
|
||||
"-p",
|
||||
"test_*.py"
|
||||
]
|
||||
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test_*.py"]
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ In your instance of Mealie prior to v1, perform an export of your data in the Ad
|
|||
|
||||
## Step 3: Using the Migration Tool
|
||||
|
||||
In your new v1 instance, navigate to `/group/data/migrations` and select "Mealie" from the dropdown selector. Upload the exported data from your pre-v1 instance. Optionally, you can tag all the recipes you've imported with the `mealie_alpha` tag to help you identify them. Once the upload has finished, submit the form and your migration will begin. This may take some time, but when it's complete you'll be provided a new entry in hte "Previous Migrations" table below. Be sure to review the migration report to make sure everything was successful. There may be instances where some of the recipes were not imported, but the migration will still import all the successful recipes.
|
||||
In your new v1 instance, navigate to `/group/migrations` and select "Mealie" from the dropdown selector. Upload the exported data from your pre-v1 instance. Optionally, you can tag all the recipes you've imported with the `mealie_alpha` tag to help you identify them. Once the upload has finished, submit the form and your migration will begin. This may take some time, but when it's complete you'll be provided a new entry in hte "Previous Migrations" table below. Be sure to review the migration report to make sure everything was successful. There may be instances where some of the recipes were not imported, but the migration will still import all the successful recipes.
|
||||
|
||||
In most cases, it's faster to manually migrate the recipes that didn't take instead of trying to identify why the recipes failed to import. If you're experiencing issues with the migration tool, please open an issue on GitHub.
|
||||
|
||||
|
|
|
@ -6,9 +6,15 @@ const prefix = "/api";
|
|||
const routes = {
|
||||
food: `${prefix}/foods`,
|
||||
foodsFood: (tag: string) => `${prefix}/foods/${tag}`,
|
||||
merge: `${prefix}/foods/merge`,
|
||||
};
|
||||
|
||||
export class FoodAPI extends BaseCRUDAPI<IngredientFood, CreateIngredientFood> {
|
||||
baseRoute: string = routes.food;
|
||||
itemRoute = routes.foodsFood;
|
||||
|
||||
merge(fromId: string, toId: string) {
|
||||
// @ts-ignore TODO: fix this
|
||||
return this.requests.put<IngredientFood>(routes.merge, { fromFood: fromId, toFood: toId });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ export default defineComponent({
|
|||
];
|
||||
|
||||
function handleRowClick(item: ReportSummary) {
|
||||
router.push("/group/data/reports/" + item.id);
|
||||
router.push("/group/reports/" + item.id);
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
|
@ -69,5 +69,4 @@ export default defineComponent({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
:top-link="topLinks"
|
||||
:bottom-links="bottomLinks"
|
||||
:user="{ data: true }"
|
||||
:secondary-header="$t('user.admin')"
|
||||
secondary-header="Developer"
|
||||
:secondary-links="developerLinks"
|
||||
/>
|
||||
|
||||
<TheSnackbar />
|
||||
|
@ -49,11 +50,7 @@ export default defineComponent({
|
|||
to: "/admin/site-settings",
|
||||
title: i18n.t("sidebar.site-settings"),
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.wrench,
|
||||
to: "/admin/maintenance",
|
||||
title: "Maintenance",
|
||||
},
|
||||
|
||||
// {
|
||||
// icon: $globals.icons.chart,
|
||||
// to: "/admin/analytics",
|
||||
|
@ -74,6 +71,14 @@ export default defineComponent({
|
|||
to: "/admin/backups",
|
||||
title: i18n.t("sidebar.backups"),
|
||||
},
|
||||
];
|
||||
|
||||
const developerLinks: SidebarLinks = [
|
||||
{
|
||||
icon: $globals.icons.wrench,
|
||||
to: "/admin/maintenance",
|
||||
title: "Maintenance",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.check,
|
||||
to: "/admin/background-tasks",
|
||||
|
@ -98,6 +103,7 @@ export default defineComponent({
|
|||
sidebar,
|
||||
topLinks,
|
||||
bottomLinks,
|
||||
developerLinks,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
</section>
|
||||
</section>
|
||||
<v-container class="mt-4 d-flex justify-end">
|
||||
<v-btn outlined rounded to="/group/data/migrations"> Looking For Migrations? </v-btn>
|
||||
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,20 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Merge Dialog -->
|
||||
<BaseDialog v-model="mergeDialog" :icon="$globals.icons.foods" title="Combine Food" @confirm="mergeFoods">
|
||||
<v-card-text>
|
||||
Combining the selected foods will merge the Source Food and Target Food into a single food. The
|
||||
<strong> Source Food will be deleted </strong> and all of the references to the Source Food will be updated to
|
||||
point to the Target Food.
|
||||
<v-autocomplete v-model="fromFood" return-object :items="foods" item-text="name" label="Source Food" />
|
||||
<v-autocomplete v-model="toFood" return-object :items="foods" item-text="name" label="Target Food" />
|
||||
|
||||
<template v-if="canMerge && fromFood && toFood">
|
||||
<div class="text-center">Merging {{ fromFood.name }} into {{ toFood.name }}</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="editDialog"
|
||||
|
@ -48,7 +63,7 @@
|
|||
@edit-one="editEventHandler"
|
||||
>
|
||||
<template #button-row>
|
||||
<BaseButton :disabled="true">
|
||||
<BaseButton @click="mergeDialog = true">
|
||||
<template #icon> {{ $globals.icons.foods }} </template>
|
||||
Combine
|
||||
</BaseButton>
|
||||
|
@ -64,6 +79,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
|
||||
import { computed } from "vue-demi";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { IngredientFood } from "~/types/api-types/recipe";
|
||||
|
@ -144,6 +160,29 @@ export default defineComponent({
|
|||
deleteDialog.value = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Merge Foods
|
||||
|
||||
const mergeDialog = ref(false);
|
||||
const fromFood = ref<IngredientFood | null>(null);
|
||||
const toFood = ref<IngredientFood | null>(null);
|
||||
|
||||
const canMerge = computed(() => {
|
||||
return fromFood.value && toFood.value && fromFood.value.id !== toFood.value.id;
|
||||
});
|
||||
|
||||
async function mergeFoods() {
|
||||
if (!canMerge.value || !fromFood.value || !toFood.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await userApi.foods.merge(fromFood.value.id, toFood.value.id);
|
||||
|
||||
if (data) {
|
||||
refreshFoods();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Labels
|
||||
|
||||
|
@ -170,6 +209,12 @@ export default defineComponent({
|
|||
deleteEventHandler,
|
||||
deleteDialog,
|
||||
deleteFood,
|
||||
// Merge
|
||||
canMerge,
|
||||
mergeFoods,
|
||||
mergeDialog,
|
||||
fromFood,
|
||||
toFood,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -312,7 +312,7 @@
|
|||
|
||||
<AdvancedOnly>
|
||||
<v-container class="narrow-container d-flex justify-end">
|
||||
<v-btn outlined rounded to="/group/data/migrations"> Looking For Migrations? </v-btn>
|
||||
<v-btn outlined rounded to="/group/migrations"> Looking For Migrations? </v-btn>
|
||||
</v-container>
|
||||
</AdvancedOnly>
|
||||
</div>
|
||||
|
|
|
@ -98,15 +98,17 @@
|
|||
Manage your preferences, change your password, and update your email
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
|
||||
:image="require('~/static/svgs/manage-api-tokens.svg')"
|
||||
>
|
||||
<template #title> API Tokens </template>
|
||||
Manage your API Tokens for access from external applications
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<AdvancedOnly>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Your API Tokens', to: '/user/profile/api-tokens' }"
|
||||
:image="require('~/static/svgs/manage-api-tokens.svg')"
|
||||
>
|
||||
<template #title> API Tokens </template>
|
||||
Manage your API Tokens for access from external applications
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
</AdvancedOnly>
|
||||
</v-row>
|
||||
</section>
|
||||
<v-divider class="my-7"></v-divider>
|
||||
|
@ -134,24 +136,6 @@
|
|||
Manage a collection of recipe categories and generate pages for them.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Webhooks', to: '/group/webhooks' }"
|
||||
:image="require('~/static/svgs/manage-webhooks.svg')"
|
||||
>
|
||||
<template #title> Webhooks </template>
|
||||
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Notifiers', to: '/group/notifiers' }"
|
||||
:image="require('~/static/svgs/manage-notifiers.svg')"
|
||||
>
|
||||
<template #title> Notifiers </template>
|
||||
Setup email and push notifications that trigger on specific events.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.canManage" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Members', to: '/group/members' }"
|
||||
|
@ -161,33 +145,50 @@
|
|||
See who's in your group and manage their permissions.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Recipe Data', to: '/group/data/recipes' }"
|
||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
||||
>
|
||||
<template #title> Recipe Data </template>
|
||||
Manage your recipe data and make bulk changes
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
|
||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
||||
>
|
||||
<template #title> Manage Data </template>
|
||||
Manage your Food and Units (more options coming soon)
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Data Migrations', to: '/group/data/migrations' }"
|
||||
:image="require('~/static/svgs/manage-data-migrations.svg')"
|
||||
>
|
||||
<template #title> Data Migrations </template>
|
||||
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
<AdvancedOnly>
|
||||
<v-col v-if="user.advanced" cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Webhooks', to: '/group/webhooks' }"
|
||||
:image="require('~/static/svgs/manage-webhooks.svg')"
|
||||
>
|
||||
<template #title> Webhooks </template>
|
||||
Setup webhooks that trigger on days that you have have mealplan scheduled.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
</AdvancedOnly>
|
||||
<AdvancedOnly>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Notifiers', to: '/group/notifiers' }"
|
||||
:image="require('~/static/svgs/manage-notifiers.svg')"
|
||||
>
|
||||
<template #title> Notifiers </template>
|
||||
Setup email and push notifications that trigger on specific events.
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
</AdvancedOnly>
|
||||
<AdvancedOnly>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Data', to: '/group/data/foods' }"
|
||||
:image="require('~/static/svgs/manage-recipes.svg')"
|
||||
>
|
||||
<template #title> Manage Data </template>
|
||||
Manage your Food and Units (more options coming soon)
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
</AdvancedOnly>
|
||||
<AdvancedOnly>
|
||||
<v-col cols="12" sm="12" md="6">
|
||||
<UserProfileLinkCard
|
||||
:link="{ text: 'Manage Data Migrations', to: '/group/migrations' }"
|
||||
:image="require('~/static/svgs/manage-data-migrations.svg')"
|
||||
>
|
||||
<template #title> Data Migrations </template>
|
||||
Migrate your existing data from other applications like Nextcloud Recipes and Chowdown
|
||||
</UserProfileLinkCard>
|
||||
</v-col>
|
||||
</AdvancedOnly>
|
||||
</v-row>
|
||||
</section>
|
||||
</v-container>
|
||||
|
|
|
@ -113,6 +113,10 @@ export interface MultiPurposeLabelSummary {
|
|||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface IngredientMerge {
|
||||
fromFood: string;
|
||||
toFood: string;
|
||||
}
|
||||
/**
|
||||
* A list of ingredient references.
|
||||
*/
|
||||
|
|
132
frontend/types/components.d.ts
vendored
132
frontend/types/components.d.ts
vendored
|
@ -1,74 +1,76 @@
|
|||
// This Code is auto generated by gen_global_components.py
|
||||
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
|
||||
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
|
||||
import AppLoader from "@/components/global/AppLoader.vue";
|
||||
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
||||
import ReportTable from "@/components/global/ReportTable.vue";
|
||||
import AppToolbar from "@/components/global/AppToolbar.vue";
|
||||
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
|
||||
import BaseButton from "@/components/global/BaseButton.vue";
|
||||
import BannerExperimental from "@/components/global/BannerExperimental.vue";
|
||||
import BaseDialog from "@/components/global/BaseDialog.vue";
|
||||
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
|
||||
import StatsCards from "@/components/global/StatsCards.vue";
|
||||
import HelpIcon from "@/components/global/HelpIcon.vue";
|
||||
import InputLabelType from "@/components/global/InputLabelType.vue";
|
||||
import BaseStatCard from "@/components/global/BaseStatCard.vue";
|
||||
import DevDumpJson from "@/components/global/DevDumpJson.vue";
|
||||
import LanguageDialog from "@/components/global/LanguageDialog.vue";
|
||||
import InputQuantity from "@/components/global/InputQuantity.vue";
|
||||
import ToggleState from "@/components/global/ToggleState.vue";
|
||||
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
||||
import CrudTable from "@/components/global/CrudTable.vue";
|
||||
import InputColor from "@/components/global/InputColor.vue";
|
||||
import BaseDivider from "@/components/global/BaseDivider.vue";
|
||||
import AutoForm from "@/components/global/AutoForm.vue";
|
||||
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
|
||||
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
|
||||
import BasePageTitle from "@/components/global/BasePageTitle.vue";
|
||||
import ButtonLink from "@/components/global/ButtonLink.vue";
|
||||
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
|
||||
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
|
||||
import AppLoader from "@/components/global/AppLoader.vue";
|
||||
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
|
||||
import ReportTable from "@/components/global/ReportTable.vue";
|
||||
import AppToolbar from "@/components/global/AppToolbar.vue";
|
||||
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
|
||||
import BaseButton from "@/components/global/BaseButton.vue";
|
||||
import BannerExperimental from "@/components/global/BannerExperimental.vue";
|
||||
import BaseDialog from "@/components/global/BaseDialog.vue";
|
||||
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
|
||||
import StatsCards from "@/components/global/StatsCards.vue";
|
||||
import HelpIcon from "@/components/global/HelpIcon.vue";
|
||||
import InputLabelType from "@/components/global/InputLabelType.vue";
|
||||
import BaseStatCard from "@/components/global/BaseStatCard.vue";
|
||||
import DevDumpJson from "@/components/global/DevDumpJson.vue";
|
||||
import LanguageDialog from "@/components/global/LanguageDialog.vue";
|
||||
import InputQuantity from "@/components/global/InputQuantity.vue";
|
||||
import ToggleState from "@/components/global/ToggleState.vue";
|
||||
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
|
||||
import CrudTable from "@/components/global/CrudTable.vue";
|
||||
import InputColor from "@/components/global/InputColor.vue";
|
||||
import BaseDivider from "@/components/global/BaseDivider.vue";
|
||||
import AutoForm from "@/components/global/AutoForm.vue";
|
||||
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
|
||||
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
|
||||
import BasePageTitle from "@/components/global/BasePageTitle.vue";
|
||||
import ButtonLink from "@/components/global/ButtonLink.vue";
|
||||
|
||||
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
|
||||
import AppHeader from "@/components/layout/AppHeader.vue";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import AppFooter from "@/components/layout/AppFooter.vue";
|
||||
|
||||
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
|
||||
import AppHeader from "@/components/layout/AppHeader.vue";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import AppFooter from "@/components/layout/AppFooter.vue";
|
||||
|
||||
declare module "vue" {
|
||||
export interface GlobalComponents {
|
||||
// Global Components
|
||||
BaseCardSectionTitle: typeof BaseCardSectionTitle;
|
||||
MarkdownEditor: typeof MarkdownEditor;
|
||||
AppLoader: typeof AppLoader;
|
||||
BaseOverflowButton: typeof BaseOverflowButton;
|
||||
ReportTable: typeof ReportTable;
|
||||
AppToolbar: typeof AppToolbar;
|
||||
BaseButtonGroup: typeof BaseButtonGroup;
|
||||
BaseButton: typeof BaseButton;
|
||||
BannerExperimental: typeof BannerExperimental;
|
||||
BaseDialog: typeof BaseDialog;
|
||||
RecipeJsonEditor: typeof RecipeJsonEditor;
|
||||
StatsCards: typeof StatsCards;
|
||||
HelpIcon: typeof HelpIcon;
|
||||
InputLabelType: typeof InputLabelType;
|
||||
BaseStatCard: typeof BaseStatCard;
|
||||
DevDumpJson: typeof DevDumpJson;
|
||||
LanguageDialog: typeof LanguageDialog;
|
||||
InputQuantity: typeof InputQuantity;
|
||||
ToggleState: typeof ToggleState;
|
||||
AppButtonCopy: typeof AppButtonCopy;
|
||||
CrudTable: typeof CrudTable;
|
||||
InputColor: typeof InputColor;
|
||||
BaseDivider: typeof BaseDivider;
|
||||
AutoForm: typeof AutoForm;
|
||||
AppButtonUpload: typeof AppButtonUpload;
|
||||
AdvancedOnly: typeof AdvancedOnly;
|
||||
BasePageTitle: typeof BasePageTitle;
|
||||
ButtonLink: typeof ButtonLink;
|
||||
// Layout Components
|
||||
TheSnackbar: typeof TheSnackbar;
|
||||
AppHeader: typeof AppHeader;
|
||||
AppSidebar: typeof AppSidebar;
|
||||
AppFooter: typeof AppFooter;
|
||||
BaseCardSectionTitle: typeof BaseCardSectionTitle;
|
||||
MarkdownEditor: typeof MarkdownEditor;
|
||||
AppLoader: typeof AppLoader;
|
||||
BaseOverflowButton: typeof BaseOverflowButton;
|
||||
ReportTable: typeof ReportTable;
|
||||
AppToolbar: typeof AppToolbar;
|
||||
BaseButtonGroup: typeof BaseButtonGroup;
|
||||
BaseButton: typeof BaseButton;
|
||||
BannerExperimental: typeof BannerExperimental;
|
||||
BaseDialog: typeof BaseDialog;
|
||||
RecipeJsonEditor: typeof RecipeJsonEditor;
|
||||
StatsCards: typeof StatsCards;
|
||||
HelpIcon: typeof HelpIcon;
|
||||
InputLabelType: typeof InputLabelType;
|
||||
BaseStatCard: typeof BaseStatCard;
|
||||
DevDumpJson: typeof DevDumpJson;
|
||||
LanguageDialog: typeof LanguageDialog;
|
||||
InputQuantity: typeof InputQuantity;
|
||||
ToggleState: typeof ToggleState;
|
||||
AppButtonCopy: typeof AppButtonCopy;
|
||||
CrudTable: typeof CrudTable;
|
||||
InputColor: typeof InputColor;
|
||||
BaseDivider: typeof BaseDivider;
|
||||
AutoForm: typeof AutoForm;
|
||||
AppButtonUpload: typeof AppButtonUpload;
|
||||
AdvancedOnly: typeof AdvancedOnly;
|
||||
BasePageTitle: typeof BasePageTitle;
|
||||
ButtonLink: typeof ButtonLink;
|
||||
// Layout Components
|
||||
TheSnackbar: typeof TheSnackbar;
|
||||
AppHeader: typeof AppHeader;
|
||||
AppSidebar: typeof AppSidebar;
|
||||
AppFooter: typeof AppFooter;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ from mealie.db.models.recipe.tool import Tool
|
|||
from mealie.db.models.server.task import ServerTaskModel
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.repos.repository_foods import RepositoryFood
|
||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||
|
@ -94,8 +95,8 @@ class AllRepositories:
|
|||
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
|
||||
|
||||
@cached_property
|
||||
def ingredient_foods(self) -> RepositoryGeneric[IngredientFood, IngredientFoodModel]:
|
||||
return RepositoryGeneric(self.session, PK_ID, IngredientFoodModel, IngredientFood)
|
||||
def ingredient_foods(self) -> RepositoryFood:
|
||||
return RepositoryFood(self.session, PK_ID, IngredientFoodModel, IngredientFood)
|
||||
|
||||
@cached_property
|
||||
def ingredient_units(self) -> RepositoryGeneric[IngredientUnit, IngredientUnitModel]:
|
||||
|
|
32
mealie/repos/repository_foods.py
Normal file
32
mealie/repos/repository_foods.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from pydantic import UUID4
|
||||
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
|
||||
def merge(self, from_food: UUID4, to_food: UUID4) -> IngredientFood | None:
|
||||
|
||||
from_model: IngredientFoodModel = (
|
||||
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": from_food})).one()
|
||||
)
|
||||
|
||||
to_model: IngredientFoodModel = (
|
||||
self.session.query(self.sql_model).filter_by(**self._filter_builder(**{"id": to_food})).one()
|
||||
)
|
||||
|
||||
to_model.ingredients += from_model.ingredients
|
||||
|
||||
try:
|
||||
self.session.delete(from_model)
|
||||
self.session.commit()
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
raise e
|
||||
|
||||
return self.get_one(to_food)
|
||||
|
||||
def by_group(self, group_id: UUID4) -> "RepositoryFood":
|
||||
return super().by_group(group_id) # type: ignore
|
|
@ -1,6 +1,6 @@
|
|||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.abc_controller import BaseUserController
|
||||
|
@ -8,7 +8,13 @@ from mealie.routes._base.controller import controller
|
|||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, SaveIngredientFood
|
||||
from mealie.schema.recipe.recipe_ingredient import (
|
||||
CreateIngredientFood,
|
||||
IngredientFood,
|
||||
IngredientMerge,
|
||||
SaveIngredientFood,
|
||||
)
|
||||
from mealie.schema.response.responses import SuccessResponse
|
||||
|
||||
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
|
||||
|
||||
|
@ -27,6 +33,15 @@ class IngredientFoodsController(BaseUserController):
|
|||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
@router.put("/merge", response_model=SuccessResponse)
|
||||
def merge_one(self, data: IngredientMerge):
|
||||
try:
|
||||
self.repo.merge(data.from_food, data.to_food)
|
||||
return SuccessResponse.respond("Successfully merged foods")
|
||||
except Exception as e:
|
||||
self.deps.logger.error(e)
|
||||
raise HTTPException(500, "Failed to merge foods") from e
|
||||
|
||||
@router.get("", response_model=list[IngredientFood])
|
||||
def get_all(self, q: GetAll = Depends(GetAll)):
|
||||
return self.repo.get_all(start=q.start, limit=q.limit)
|
||||
|
|
|
@ -95,6 +95,11 @@ class IngredientRequest(MealieModel):
|
|||
ingredient: str
|
||||
|
||||
|
||||
class IngredientMerge(MealieModel):
|
||||
from_food: UUID4
|
||||
to_food: UUID4
|
||||
|
||||
|
||||
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
|
||||
|
||||
IngredientFood.update_forward_refs()
|
||||
|
|
18
poetry.lock
generated
18
poetry.lock
generated
|
@ -374,7 +374,7 @@ cli = ["requests"]
|
|||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.74.1"
|
||||
version = "0.75.1"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -387,8 +387,8 @@ starlette = "0.17.1"
|
|||
[package.extras]
|
||||
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
||||
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
|
||||
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
|
||||
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
|
||||
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<6.0.0)"]
|
||||
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
|
@ -1229,7 +1229,7 @@ rdflib = ">=5.0.0"
|
|||
|
||||
[[package]]
|
||||
name = "recipe-scrapers"
|
||||
version = "13.23.0"
|
||||
version = "13.28.0"
|
||||
description = "Python package, scraping recipes from all over the internet"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -1599,7 +1599,7 @@ pgsql = ["psycopg2-binary"]
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "7541b47452a32f483ab233daa846f07707a3d9da6f4e50c1285249639b1c40fd"
|
||||
content-hash = "00c0adae74732437eaa473f24757191d620edfde671dceb5fdae28de9843d0c3"
|
||||
|
||||
[metadata.files]
|
||||
aiofiles = [
|
||||
|
@ -1826,8 +1826,8 @@ extruct = [
|
|||
{file = "extruct-0.13.0.tar.gz", hash = "sha256:50a5b5bac4c5e19ecf682bf63a28fde0b1bb57433df7057371f60b58c94a2c64"},
|
||||
]
|
||||
fastapi = [
|
||||
{file = "fastapi-0.74.1-py3-none-any.whl", hash = "sha256:b8ec8400623ef0b2ff558ebe06753b349f8e3a5dd38afea650800f2644ddba34"},
|
||||
{file = "fastapi-0.74.1.tar.gz", hash = "sha256:b58a2c46df14f62ebe6f24a9439927539ba1959b9be55ba0e2f516a683e5b9d4"},
|
||||
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"},
|
||||
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"},
|
||||
]
|
||||
filelock = [
|
||||
{file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"},
|
||||
|
@ -2527,8 +2527,8 @@ rdflib-jsonld = [
|
|||
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
|
||||
]
|
||||
recipe-scrapers = [
|
||||
{file = "recipe_scrapers-13.23.0-py3-none-any.whl", hash = "sha256:120b356ca422e4f2afb8c944ecf2b53d3c9c73ac9f5345cf35bc168147056e17"},
|
||||
{file = "recipe_scrapers-13.23.0.tar.gz", hash = "sha256:d99fbdaa1323e6d11e1378bfda0adc5536bd6acf3c71dc57380898300c577f45"},
|
||||
{file = "recipe_scrapers-13.28.0-py3-none-any.whl", hash = "sha256:114ab8fb8baa85976f8709955baca4e6df07b565bfd5b60404eff89584d68e3f"},
|
||||
{file = "recipe_scrapers-13.28.0.tar.gz", hash = "sha256:a12258f2218f8b222bdb57cf9d9d6b0288b892c258ccaec8efec02a292a8aded"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||
|
|
|
@ -13,7 +13,7 @@ python = "^3.10"
|
|||
aiofiles = "0.5.0"
|
||||
aniso8601 = "7.0.0"
|
||||
appdirs = "1.4.4"
|
||||
fastapi = "^0.74.1"
|
||||
fastapi = "^0.75.1"
|
||||
uvicorn = {extras = ["standard"], version = "^0.13.0"}
|
||||
APScheduler = "^3.8.1"
|
||||
SQLAlchemy = "^1.4.29"
|
||||
|
@ -31,7 +31,7 @@ passlib = "^1.7.4"
|
|||
lxml = "^4.7.1"
|
||||
Pillow = "^8.2.0"
|
||||
apprise = "^0.9.6"
|
||||
recipe-scrapers = "^13.23.0"
|
||||
recipe-scrapers = "^13.28.0"
|
||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||
gunicorn = "^20.1.0"
|
||||
emails = "^0.6"
|
||||
|
|
48
tests/unit_tests/repository_tests/test_food_repository.py
Normal file
48
tests/unit_tests/repository_tests/test_food_repository.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
def test_food_merger(database: AllRepositories, unique_user: TestUser):
|
||||
slug1 = random_string(10)
|
||||
|
||||
food_1 = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
food_2 = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
recipe = database.recipes.create(
|
||||
Recipe(
|
||||
name=slug1,
|
||||
user_id=unique_user.group_id,
|
||||
group_id=unique_user.group_id,
|
||||
recipe_ingredient=[
|
||||
RecipeIngredient(note="", food=food_1), # type: ignore
|
||||
RecipeIngredient(note="", food=food_2), # type: ignore
|
||||
],
|
||||
) # type: ignore
|
||||
)
|
||||
|
||||
# Santiy check make sure recipe got created
|
||||
assert recipe.id is not None
|
||||
|
||||
for ing in recipe.recipe_ingredient:
|
||||
assert ing.food.id in [food_1.id, food_2.id] # type: ignore
|
||||
|
||||
database.ingredient_foods.merge(food_2.id, food_1.id)
|
||||
|
||||
recipe = database.recipes.get_one(recipe.slug)
|
||||
|
||||
for ingredient in recipe.recipe_ingredient:
|
||||
assert ingredient.food.id == food_1.id # type: ignore
|
Loading…
Reference in a new issue