1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 15:49:42 +02:00

feature/category-tag-crud (#354)

* update tag route

* search.and

* offset for mobile

* relative imports

* get settings

* new page

* category/tag CRUD

* bulk assign frontend

* Bulk assign

* debounce search

* remove dev data

* recipe store refactor

* fix mobile view

* fix failing tests

* commit test data

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-04-27 11:17:00 -08:00 committed by GitHub
parent f748bbba68
commit 846d1eda5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1028 additions and 145 deletions

40
.gitignore vendored
View file

@ -10,7 +10,6 @@ mealie/temp/api.html
.temp/ .temp/
.secret .secret
dev/data/backups/* dev/data/backups/*
dev/data/debug/* dev/data/debug/*
dev/data/img/* dev/data/img/*
@ -157,39 +156,6 @@ mealie/data/debug/last_recipe.json
*.sqlite *.sqlite
dev/data/db/test.db dev/data/db/test.db
scratch.py scratch.py
frontend/dist/favicon.ico dev/data/backups/dev_sample_data*.zip
frontend/dist/index.html dev/data/backups/dev_sample_data*.zip
frontend/dist/css/app.29fe0155.css !dev/data/backups/test*.zip
frontend/dist/css/chunk-vendors.db944396.css
frontend/dist/fonts/materialdesignicons-webfont.7a44ea19.woff2
frontend/dist/fonts/materialdesignicons-webfont.64d4cf64.eot
frontend/dist/fonts/materialdesignicons-webfont.147e3378.woff
frontend/dist/fonts/materialdesignicons-webfont.174c02fc.ttf
frontend/dist/fonts/roboto-latin-100.5cb7edfc.woff
frontend/dist/fonts/roboto-latin-100.7370c367.woff2
frontend/dist/fonts/roboto-latin-100italic.f8b1df51.woff2
frontend/dist/fonts/roboto-latin-100italic.f9e8e590.woff
frontend/dist/fonts/roboto-latin-300.b00849e0.woff
frontend/dist/fonts/roboto-latin-300.ef7c6637.woff2
frontend/dist/fonts/roboto-latin-300italic.4df32891.woff
frontend/dist/fonts/roboto-latin-300italic.14286f3b.woff2
frontend/dist/fonts/roboto-latin-400.60fa3c06.woff
frontend/dist/fonts/roboto-latin-400.479970ff.woff2
frontend/dist/fonts/roboto-latin-400italic.51521a2a.woff2
frontend/dist/fonts/roboto-latin-400italic.fe65b833.woff
frontend/dist/fonts/roboto-latin-500.020c97dc.woff2
frontend/dist/fonts/roboto-latin-500.87284894.woff
frontend/dist/fonts/roboto-latin-500italic.288ad9c6.woff
frontend/dist/fonts/roboto-latin-500italic.db4a2a23.woff2
frontend/dist/fonts/roboto-latin-700.2735a3a6.woff2
frontend/dist/fonts/roboto-latin-700.adcde98f.woff
frontend/dist/fonts/roboto-latin-700italic.81f57861.woff
frontend/dist/fonts/roboto-latin-700italic.da0e7178.woff2
frontend/dist/fonts/roboto-latin-900.9b3766ef.woff2
frontend/dist/fonts/roboto-latin-900.bb1e4dc6.woff
frontend/dist/fonts/roboto-latin-900italic.28f91510.woff
frontend/dist/fonts/roboto-latin-900italic.ebf6d164.woff2
frontend/dist/js/app.36f2760c.js
frontend/dist/js/app.36f2760c.js.map
frontend/dist/js/chunk-vendors.c93761e4.js
frontend/dist/js/chunk-vendors.c93761e4.js.map

View file

@ -19,6 +19,11 @@
## Features and Improvements ## Features and Improvements
### General ### General
- New Toolbox Page!
- Bulk assign categories and tags by keyword search
- Title case all Categories or Tags with 1 click
- Create/Rename/Delete Operations for Tags/Categories
- Remove Unused Categories or Tags with 1 click
- More localization - More localization
- Start date for Week is now selectable - Start date for Week is now selectable
- Languages are now managed through Crowdin - Languages are now managed through Crowdin

View file

@ -46,6 +46,7 @@ export default {
this.darkModeAddEventListener(); this.darkModeAddEventListener();
this.$store.dispatch("requestAppInfo"); this.$store.dispatch("requestAppInfo");
this.$store.dispatch("requestCustomPages"); this.$store.dispatch("requestCustomPages");
this.$store.dispatch("requestSiteSettings");
}, },
methods: { methods: {

View file

@ -39,6 +39,16 @@ const apiReq = {
processResponse(response); processResponse(response);
return response; return response;
}, },
patch: async function(url, data) {
let response = await axios.patch(url, data).catch(function(error) {
if (error.response) {
processResponse(error.response);
return response;
} else return;
});
processResponse(response);
return response;
},
get: async function(url, data) { get: async function(url, data) {
let response = await axios.get(url, data).catch(function(error) { let response = await axios.get(url, data).catch(function(error) {

View file

@ -6,8 +6,10 @@ const prefix = baseURL + "categories";
const categoryURLs = { const categoryURLs = {
getAll: `${prefix}`, getAll: `${prefix}`,
getEmpty: `${prefix}/empty`,
getCategory: category => `${prefix}/${category}`, getCategory: category => `${prefix}/${category}`,
deleteCategory: category => `${prefix}/${category}`, deleteCategory: category => `${prefix}/${category}`,
updateCategory: category => `${prefix}/${category}`,
}; };
export const categoryAPI = { export const categoryAPI = {
@ -15,6 +17,10 @@ export const categoryAPI = {
let response = await apiReq.get(categoryURLs.getAll); let response = await apiReq.get(categoryURLs.getAll);
return response.data; return response.data;
}, },
async getEmpty() {
let response = await apiReq.get(categoryURLs.getEmpty);
return response.data;
},
async create(name) { async create(name) {
let response = await apiReq.post(categoryURLs.getAll, { name: name }); let response = await apiReq.post(categoryURLs.getAll, { name: name });
store.dispatch("requestCategories"); store.dispatch("requestCategories");
@ -24,9 +30,20 @@ export const categoryAPI = {
let response = await apiReq.get(categoryURLs.getCategory(category)); let response = await apiReq.get(categoryURLs.getCategory(category));
return response.data; return response.data;
}, },
async delete(category) { async update(name, newName, overrideRequest = false) {
let response = await apiReq.put(categoryURLs.updateCategory(name), {
name: newName,
});
if (!overrideRequest) {
store.dispatch("requestCategories");
}
return response.data;
},
async delete(category, overrideRequest = false) {
let response = await apiReq.delete(categoryURLs.deleteCategory(category)); let response = await apiReq.delete(categoryURLs.deleteCategory(category));
store.dispatch("requestCategories"); if (!overrideRequest) {
store.dispatch("requestCategories");
}
return response.data; return response.data;
}, },
}; };
@ -35,8 +52,10 @@ const tagPrefix = baseURL + "tags";
const tagURLs = { const tagURLs = {
getAll: `${tagPrefix}`, getAll: `${tagPrefix}`,
getEmpty: `${tagPrefix}/empty`,
getTag: tag => `${tagPrefix}/${tag}`, getTag: tag => `${tagPrefix}/${tag}`,
deleteTag: tag => `${tagPrefix}/${tag}`, deleteTag: tag => `${tagPrefix}/${tag}`,
updateTag: tag => `${tagPrefix}/${tag}`,
}; };
export const tagAPI = { export const tagAPI = {
@ -44,6 +63,10 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getAll); let response = await apiReq.get(tagURLs.getAll);
return response.data; return response.data;
}, },
async getEmpty() {
let response = await apiReq.get(tagURLs.getEmpty);
return response.data;
},
async create(name) { async create(name) {
let response = await apiReq.post(tagURLs.getAll, { name: name }); let response = await apiReq.post(tagURLs.getAll, { name: name });
store.dispatch("requestTags"); store.dispatch("requestTags");
@ -53,9 +76,20 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getTag(tag)); let response = await apiReq.get(tagURLs.getTag(tag));
return response.data; return response.data;
}, },
async delete(tag) { async update(name, newName, overrideRequest = false) {
let response = await apiReq.put(tagURLs.updateTag(name), { name: newName });
if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data;
},
async delete(tag, overrideRequest = false) {
let response = await apiReq.delete(tagURLs.deleteTag(tag)); let response = await apiReq.delete(tagURLs.deleteTag(tag));
store.dispatch("requestTags"); if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data; return response.data;
}, },
}; };

View file

@ -67,7 +67,13 @@ export const recipeAPI = {
async update(data) { async update(data) {
let response = await apiReq.put(recipeURLs.update(data.slug), data); let response = await apiReq.put(recipeURLs.update(data.slug), data);
store.dispatch("requestRecentRecipes"); store.dispatch("patchRecipe", response.data);
return response.data.slug; // ! Temporary until I rewrite to refresh page without additional request
},
async patch(data) {
let response = await apiReq.patch(recipeURLs.update(data.slug), data);
store.dispatch("patchRecipe", response.data);
return response.data; return response.data;
}, },

View file

@ -3,7 +3,8 @@
<v-card-title class=" headline"> <v-card-title class=" headline">
{{ $t("meal-plan.create-a-new-meal-plan") }} {{ $t("meal-plan.create-a-new-meal-plan") }}
<v-btn color="info" class="ml-auto" @click="setQuickWeek()"> <v-btn color="info" class="ml-auto" @click="setQuickWeek()">
<v-icon left>mdi-calendar-minus</v-icon> {{$t('meal-plan.quick-week')}} <v-icon left>mdi-calendar-minus</v-icon>
{{ $t("meal-plan.quick-week") }}
</v-btn> </v-btn>
</v-card-title> </v-card-title>
@ -153,7 +154,7 @@ export default {
return recipes.length > 0 ? recipes : this.items; return recipes.length > 0 ? recipes : this.items;
}, },
allRecipes() { allRecipes() {
return this.$store.getters.getRecentRecipes; return this.$store.getters.getAllRecipes;
}, },
}, },

View file

@ -60,10 +60,10 @@
<v-row v-else dense> <v-row v-else dense>
<v-col <v-col
cols="12" cols="12"
sm="12" :sm="singleColumn ? '12' : '12'"
md="6" :md="singleColumn ? '12' : '6'"
lg="4" :lg="singleColumn ? '12' : '4'"
xl="3" :xl="singleColumn ? '12' : '3'"
v-for="recipe in recipes.slice(0, cardLimit)" v-for="recipe in recipes.slice(0, cardLimit)"
:key="recipe.name" :key="recipe.name"
> >
@ -79,14 +79,16 @@
</v-row> </v-row>
</div> </div>
<div v-intersect="bumpList" class="d-flex"> <div v-intersect="bumpList" class="d-flex">
<v-progress-circular <v-expand-x-transition>
v-if="loading" <v-progress-circular
class="mx-auto mt-1" v-if="loading"
:size="50" class="mx-auto mt-1"
:width="7" :size="50"
color="primary" :width="7"
indeterminate color="primary"
></v-progress-circular> indeterminate
></v-progress-circular>
</v-expand-x-transition>
</div> </div>
</div> </div>
</template> </template>
@ -109,6 +111,12 @@ export default {
hardLimit: { hardLimit: {
default: 99999, default: 99999,
}, },
mobileCards: {
default: false,
},
singleColumn: {
defualt: false,
},
recipes: Array, recipes: Array,
}, },
data() { data() {
@ -117,8 +125,14 @@ export default {
loading: false, loading: false,
}; };
}, },
watch: {
recipes() {
this.bumpList();
},
},
computed: { computed: {
viewScale() { viewScale() {
if (this.mobileCards) return true;
switch (this.$vuetify.breakpoint.name) { switch (this.$vuetify.breakpoint.name) {
case "xs": case "xs":
return true; return true;
@ -128,10 +142,16 @@ export default {
return false; return false;
} }
}, },
effectiveHardLimit() {
return Math.min(this.hardLimit, this.recipes.length);
},
}, },
methods: { methods: {
bumpList() { bumpList() {
const newCardLimit = Math.min(this.cardLimit + 20, this.hardLimit); const newCardLimit = Math.min(
this.cardLimit + 20,
this.effectiveHardLimit
);
if (this.loading === false && newCardLimit > this.cardLimit) { if (this.loading === false && newCardLimit > this.cardLimit) {
this.setLoader(); this.setLoader();
@ -141,7 +161,7 @@ export default {
}, },
async setLoader() { async setLoader() {
this.loading = true; this.loading = true;
await new Promise(r => setTimeout(r, 3000)); await new Promise(r => setTimeout(r, 1000));
this.loading = false; this.loading = false;
}, },
}, },

View file

@ -1,21 +1,38 @@
<template> <template>
<div> <div>
<v-dialog v-model="dialog" :width="modalWidth + 'px'"> <v-dialog
<v-app-bar dark :color="color" class="mt-n1 mb-2"> v-model="dialog"
<v-icon large left v-if="!loading"> :width="modalWidth + 'px'"
{{ titleIcon }} :content-class="top ? 'top-dialog' : undefined"
</v-icon> >
<v-progress-circular <v-card class="pb-10" height="100%">
v-else <v-app-bar dark :color="color" class="mt-n1 mb-2">
<v-icon large left>
{{ titleIcon }}
</v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-progress-linear
v-if="loading"
indeterminate indeterminate
color="white" color="primary"
large ></v-progress-linear>
class="mr-2" <slot> </slot>
> <v-card-actions>
</v-progress-circular> <slot name="card-actions">
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-btn text color="grey" @click="dialog = false">
<v-spacer></v-spacer> Cancel
</v-app-bar> </v-btn>
<v-spacer></v-spacer>
<v-btn color="success" @click="$emit('submit')">
Submit
</v-btn>
</slot>
</v-card-actions>
<slot name="below-actions"> </slot>
</v-card>
</v-dialog> </v-dialog>
</div> </div>
</template> </template>
@ -35,6 +52,12 @@ export default {
modalWidth: { modalWidth: {
default: "500", default: "500",
}, },
loading: {
default: false,
},
top: {
default: false,
},
}, },
data() { data() {
return { return {
@ -45,9 +68,15 @@ export default {
open() { open() {
this.dialog = true; this.dialog = true;
}, },
close() {
this.dialog = false;
},
}, },
}; };
</script> </script>
<style scoped> <style scoped>
.top-dialog {
align-self: flex-start;
}
</style> </style>

View file

@ -1,8 +1,10 @@
<template> <template>
<div> <div>
<v-btn icon @click="dialog = true" class="mt-n1"> <slot>
<v-icon :color="color">mdi-plus</v-icon> <v-btn icon @click="dialog = true" class="mt-n1">
</v-btn> <v-icon :color="color">mdi-plus</v-icon>
</v-btn>
</slot>
<v-dialog v-model="dialog" width="500"> <v-dialog v-model="dialog" width="500">
<v-card> <v-card>
<v-app-bar dense dark color="primary mb-2"> <v-app-bar dense dark color="primary mb-2">
@ -80,6 +82,9 @@ export default {
}, },
methods: { methods: {
open() {
this.dialog = true;
},
async select() { async select() {
const newItem = await (async () => { const newItem = await (async () => {
if (this.tagDialog) { if (this.tagDialog) {

View file

@ -0,0 +1,79 @@
<template>
<div>
<slot> </slot>
</div>
</template>
<script>
const RESULTS_EVENT = "results";
import Fuse from "fuse.js";
export default {
props: {
search: {
default: "",
},
rawData: {
default: true,
},
/** Defaults to Show All Results */
showAll: {
default: true,
},
keys: {
type: Array,
default: () => ["name"],
},
defaultOptions: {
default: () => ({
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
}),
},
},
data() {
return {
results: [],
fuseResults: [],
};
},
computed: {
options() {
return { ...this.defaultOptions, ...{ keys: this.keys } };
},
autoResults() {
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
},
fuse() {
return new Fuse(this.rawData, this.options);
},
isSearching() {
return this.search && this.search.length > 0;
},
},
watch: {
search() {
try {
this.results = this.fuse.search(this.search.trim());
} catch {
this.results = this.rawData
.map(x => ({ item: x }))
.sort((a, b) => (a.name > b.name ? 1 : -1));
}
this.$emit(RESULTS_EVENT, this.results);
if (this.showResults === true) {
this.fuseResults = this.results;
}
},
},
};
</script>
<style scoped>
</style>

View file

@ -132,7 +132,7 @@ export default {
}, },
computed: { computed: {
data() { data() {
return this.$store.getters.getRecentRecipes; return this.$store.getters.getAllRecipes;
}, },
autoResults() { autoResults() {
return this.fuseResults.length > 1 ? this.fuseResults : this.results; return this.fuseResults.length > 1 ? this.fuseResults : this.results;

View file

@ -163,6 +163,11 @@ export default {
to: "/admin/settings", to: "/admin/settings",
title: this.$t("settings.site-settings"), title: this.$t("settings.site-settings"),
}, },
{
icon: "mdi-tools",
to: "/admin/toolbox",
title: this.$t("settings.toolbox.toolbox"),
},
{ {
icon: "mdi-account-group", icon: "mdi-account-group",
to: "/admin/manage-users", to: "/admin/manage-users",

View file

@ -6,6 +6,7 @@
bottom bottom
right right
offset-y offset-y
offset-overflow
open-on-hover open-on-hover
close-delay="200" close-delay="200"
> >
@ -72,7 +73,7 @@ export default {
}, },
{ {
icon: "mdi-logout", icon: "mdi-logout",
title: this.$t('user.logout'), title: this.$t("user.logout"),
restricted: true, restricted: true,
nav: "/logout", nav: "/logout",
}, },
@ -85,7 +86,6 @@ export default {
], ],
}; };
}, },
mounted() {},
computed: { computed: {
filteredItems() { filteredItems() {
if (this.loggedIn) { if (this.loggedIn) {

View file

@ -152,6 +152,7 @@
"exclude": "Exclude", "exclude": "Exclude",
"include": "Include", "include": "Include",
"or": "Or", "or": "Or",
"and": "and",
"search": "Search", "search": "Search",
"tag-filter": "Tag Filter" "tag-filter": "Tag Filter"
}, },
@ -217,6 +218,16 @@
"test-webhooks": "Test Webhooks", "test-webhooks": "Test Webhooks",
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at", "the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at",
"webhook-url": "Webhook URL" "webhook-url": "Webhook URL"
},
"toolbox": {
"toolbox": "Toolbox",
"new-name": "New Name",
"recipes-effected": "Recipes Effected",
"title-case-all": "Title Case All",
"no-unused-items": "No Unused Items",
"remove-unused": "Remove Unused",
"assign-all": "Assign All",
"bulk-assign": "Bulk Assign"
} }
}, },
"user": { "user": {

View file

@ -0,0 +1,171 @@
<template>
<div>
<base-dialog
ref="assignDialog"
title-icon="mdi-tag"
color="primary"
title="Bulk Assign"
:loading="loading"
modal-width="700"
:top="true"
>
<v-card-text>
<v-text-field
v-model="search"
autocomplete="off"
label="Keyword"
></v-text-field>
<CategoryTagSelector
:tag-selector="false"
v-model="catsToAssign"
:return-object="false"
/>
<CategoryTagSelector
:tag-selector="true"
v-model="tagsToAssign"
:return-object="false"
/>
</v-card-text>
<template slot="card-actions">
<v-btn text color="grey" @click="closeDialog">
{{ $t("general.cancel") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="success"
@click="assignAll"
:loading="loading"
:disabled="results.length < 1"
>
{{ $t("settings.toolbox.assign-all") }}
</v-btn>
</template>
<template slot="below-actions">
<v-card-title class="headline"> </v-card-title>
<CardSection
class="px-2 pb-2"
:title="`${results.length || 0} Recipes Effected`"
:mobile-cards="true"
:recipes="results"
:single-column="true"
/>
</template>
</base-dialog>
<v-btn @click="openDialog" small color="success">
{{ $t("settings.toolbox.bulk-assign") }}
</v-btn>
</div>
</template>
<script>
import CardSection from "@/components/UI/CardSection";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
export default {
props: {
isTags: {
default: true,
},
},
components: {
CardSection,
BaseDialog,
CategoryTagSelector,
},
data() {
return {
results: [],
search: "",
loading: false,
assignTargetRecipes: [],
catsToAssign: [],
tagsToAssign: [],
};
},
mounted() {
this.$store.dispatch("requestAllRecipes");
},
watch: {
search() {
this.getResults();
},
},
computed: {
allRecipes() {
return this.$store.getters.getAllRecipes;
},
// results() {
// if (this.search === null || this.search === "") {
// return [];
// }
// return this.allRecipes.filter(x => {
// return (
// this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
// );
// });
// },
keywords() {
const lowered = this.search.toLowerCase();
return lowered.split(" ");
},
},
methods: {
reset() {
this.search = "";
this.loading = false;
this.assignTargetRecipes = [];
this.catsToAssign = [];
this.tagsToAssign = [];
},
assignAll() {
this.loading = true;
this.results.forEach(async element => {
element.recipeCategory = element.recipeCategory.concat(
this.catsToAssign
);
element.tags = element.tags.concat(this.tagsToAssign);
await api.recipes.patch(element);
});
this.loading = false;
this.closeDialog();
},
closeDialog() {
this.$refs.assignDialog.close();
},
async openDialog() {
this.$refs.assignDialog.open();
this.reset();
},
getResults() {
this.loading = true;
// cancel pending call
clearTimeout(this._timerId);
this._timerId = setTimeout(() => {
this.results = this.filterResults();
}, 300);
this.loading = false;
// delay new call 500ms
},
filterResults() {
if (this.search === null || this.search === "") {
return [];
}
return this.allRecipes.filter(x => {
return (
this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
);
});
},
checkForKeywords(str) {
const searchStr = str.toLowerCase();
return this.keywords.some(x => searchStr.includes(x));
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,100 @@
<template>
<div>
<base-dialog
ref="deleteDialog"
title-icon="mdi-tag"
color="error"
:title="
$t('general.delete') +
' ' +
(isTags ? $t('recipe.tags') : $t('recipe.categories'))
"
:loading="loading"
modal-width="400"
>
<v-list v-if="deleteList.length > 0">
<v-list-item v-for="item in deleteList" :key="item.slug">
<v-list-item-content>
{{ item.name }}
</v-list-item-content>
</v-list-item>
</v-list>
<v-card-text v-else class=" mt-4 text-center">
{{ $t("settings.toolbox.no-unused-items") }}
</v-card-text>
<template slot="card-actions">
<v-btn text color="grey" @click="closeDialog">
{{ $t("general.cancel") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="error"
@click="deleteUnused"
:loading="loading"
:disabled="deleteList.length < 1"
>
{{ $t("general.delete") }}
</v-btn>
</template>
</base-dialog>
<v-btn @click="openDialog" small color="error" class="mr-1">
{{ $t("settings.toolbox.remove-unused") }}
</v-btn>
</div>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
export default {
props: {
isTags: {
default: true,
},
},
components: {
BaseDialog,
},
data() {
return {
deleteList: [],
loading: false,
};
},
methods: {
closeDialog() {
this.$refs.deleteDialog.close();
},
async openDialog() {
this.$refs.deleteDialog.open();
console.log(this.isTags);
if (this.isTags) {
this.deleteList = await api.tags.getEmpty();
} else {
this.deleteList = await api.categories.getEmpty();
}
},
async deleteUnused() {
this.loading = true;
if (this.isTags) {
this.deleteList.forEach(async element => {
await api.tags.delete(element.slug, true);
});
this.$store.dispatch("requestTags");
} else {
this.deleteList.forEach(async element => {
await api.categories.delete(element.slug, true);
});
this.$store.dispatch("requestCategories");
}
this.loading = false;
this.closeDialog();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,244 @@
<template>
<v-card outlined class="mt-n1">
<base-dialog
ref="renameDialog"
title-icon="mdi-tag"
:title="renameTarget.title"
modal-width="800"
@submit="renameFromDialog(renameTarget.slug, renameTarget.newName)"
>
<v-form ref="renameForm">
<v-card-text>
<v-text-field
:placeholder="$t('settings.toolbox.new-name')"
:rules="[existsRule]"
v-model="renameTarget.newName"
></v-text-field>
</v-card-text>
</v-form>
<template slot="below-actions">
<v-card-title class="headline">
{{ renameTarget.recipes.length || 0 }}
{{ $t("settings.toolbox.recipes-effected") }}
</v-card-title>
<MobileRecipeCard
class="ml-2 mr-2 mt-2 mb-2"
v-for="recipe in renameTarget.recipes"
:key="recipe.slug"
:slug="recipe.slug"
:name="recipe.name"
:description="recipe.description"
:rating="recipe.rating"
:route="false"
:tags="recipe.tags"
/>
</template>
</base-dialog>
<div class="d-flex justify-center align-center pa-2 flex-wrap">
<new-category-tag-dialog ref="newDialog" :tag-dialog="isTags">
<v-btn @click="openNewDialog" small color="success" class="mr-1 mb-1">
{{ $t("general.create") }}
</v-btn>
</new-category-tag-dialog>
<BulkAssign isTags="isTags" class="mr-1 mb-1" />
<v-btn
@click="titleCaseAll"
small
color="success"
class="mr-1 mb-1"
:loading="loadingTitleCase"
>
{{ $t("settings.toolbox.title-case-all") }}
</v-btn>
<RemoveUnused :isTags="isTags" class="mb-1" />
<v-spacer v-if="!isMobile"> </v-spacer>
<fuse-search-bar
:raw-data="allItems"
@results="filterItems"
:search="searchString"
>
<v-text-field
v-model="searchString"
clearable
solo
dense
class="mx-2"
hide-details
single-line
:placeholder="$t('search.search')"
prepend-inner-icon="mdi-magnify"
>
</v-text-field>
</fuse-search-bar>
</div>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col
cols="12"
:sm="12"
:md="6"
:lg="4"
:xl="3"
v-for="item in results"
:key="item.id"
>
<v-card>
<v-card-actions>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
<v-btn small text color="info" @click="openEditDialog(item)">
Edit
</v-btn>
<v-btn small text color="error" @click="deleteItem(item.slug)"
>Delete
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import FuseSearchBar from "@/components/UI/Search/FuseSearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import RemoveUnused from "./RemoveUnused";
import BulkAssign from "./BulkAssign";
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
export default {
mixins: [validators],
components: {
BaseDialog,
MobileRecipeCard,
FuseSearchBar,
RemoveUnused,
NewCategoryTagDialog,
BulkAssign,
},
props: {
isTags: {
default: true,
},
},
data() {
return {
loadingTitleCase: false,
searchString: "",
searchResults: [],
renameTarget: {
title: "",
name: "",
slug: "",
newName: "",
recipes: [],
},
};
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
allItems() {
return this.isTags
? this.$store.getters.getAllTags
: this.$store.getters.getAllCategories;
},
results() {
if (this.searchString != null && this.searchString.length >= 1) {
return this.searchResults;
}
return this.allItems;
},
},
methods: {
filterItems(val) {
this.searchResults = val.map(x => x.item);
},
openNewDialog() {
this.$refs.newDialog.open();
},
async openEditDialog(item) {
let fromAPI = {};
if (this.isTags) {
fromAPI = await api.tags.getRecipesInTag(item.slug);
} else {
fromAPI = await api.categories.getRecipesInCategory(item.slug);
}
this.renameTarget = {
title: `Rename ${item.name}`,
name: item.name,
slug: item.slug,
newName: "",
recipes: fromAPI.recipes,
};
this.$refs.renameDialog.open();
},
async deleteItem(name) {
if (this.isTags) {
await api.tags.delete(name);
} else {
await api.categories.delete(name);
}
},
async renameFromDialog(name, newName) {
if (this.$refs.renameForm.validate()) {
await this.rename(name, newName);
}
this.$refs.renameDialog.close();
},
async rename(name, newName) {
if (this.isTags) {
await api.tags.update(name, newName);
} else {
await api.categories.update(name, newName);
}
},
titleCase(lowerName) {
return lowerName.replace(/(?:^|\s|-)\S/g, x => x.toUpperCase());
},
async titleCaseAll() {
this.loadingTitleCase = true;
const renameList = this.allItems.map(x => ({
slug: x.slug,
name: x.name,
newName: this.titleCase(x.name),
}));
if (this.isTags) {
renameList.forEach(async element => {
if (element.name === element.newName) return;
await api.tags.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestTags");
} else {
renameList.forEach(async element => {
if (element.name === element.newName) return;
await api.categories.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestCategories");
}
this.loadingTitleCase = false;
},
},
};
</script>
<style>
.overflow-fix .v-toolbar__content {
height: auto !important;
flex-wrap: wrap;
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div>
<v-card flat>
<v-tabs
v-model="tab"
background-color="primary"
centered
dark
icons-and-text
>
<v-tabs-slider></v-tabs-slider>
<v-tab>
{{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
<v-tab>
{{ $t("recipe.tags") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item>
</v-tabs-items>
</v-card>
</div>
</template>
<script>
import CategoryTagEditor from "./CategoryTagEditor";
export default {
components: {
CategoryTagEditor,
},
data() {
return {
tab: 0,
};
},
};
</script>
<style>
</style>

View file

@ -38,8 +38,8 @@ export default {
return this.$store.getters.getSiteSettings; return this.$store.getters.getSiteSettings;
}, },
recentRecipes() { recentRecipes() {
let recipes = this.$store.getters.getRecentRecipes; return this.$store.getters.getRecentRecipes;
return recipes.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
}, },
}, },
async mounted() { async mounted() {

View file

@ -1,5 +1,10 @@
<template> <template>
<v-container> <v-container>
<v-progress-linear
v-if="loading"
indeterminate
color="primary"
></v-progress-linear>
<CardSection <CardSection
:sortable="true" :sortable="true"
:title="$t('page.all-recipes')" :title="$t('page.all-recipes')"
@ -18,14 +23,20 @@ export default {
CardSection, CardSection,
}, },
data() { data() {
return {}; return {
loading: false,
};
}, },
mounted() { async mounted() {
this.$store.dispatch("requestAllRecipes"); if (this.allRecipes.length < 1) {
this.loading = true;
}
await this.$store.dispatch("requestAllRecipes");
this.loading = false;
}, },
computed: { computed: {
allRecipes() { allRecipes() {
return this.$store.getters.getRecentRecipes; return this.$store.getters.getAllRecipes;
}, },
}, },
methods: { methods: {

View file

@ -119,7 +119,7 @@ export default {
}, },
computed: { computed: {
allRecipes() { allRecipes() {
return this.$store.getters.getRecentRecipes; return this.$store.getters.getAllRecipes;
}, },
filteredRecipes() { filteredRecipes() {
return this.allRecipes.filter(recipe => { return this.allRecipes.filter(recipe => {

View file

@ -7,9 +7,10 @@ import Profile from "@/pages/Admin/Profile";
import ManageUsers from "@/pages/Admin/ManageUsers"; import ManageUsers from "@/pages/Admin/ManageUsers";
import Settings from "@/pages/Admin/Settings"; import Settings from "@/pages/Admin/Settings";
import About from "@/pages/Admin/About"; import About from "@/pages/Admin/About";
import Toolbox from "@/pages/Admin/Toolbox";
import { store } from "../store"; import { store } from "../store";
export const adminRoutes = { export const adminRoutes = {
path: "/admin", path: "/admin",
component: Admin, component: Admin,
beforeEnter: (to, _from, next) => { beforeEnter: (to, _from, next) => {
@ -72,6 +73,13 @@ export const adminRoutes = {
title: "settings.site-settings", title: "settings.site-settings",
}, },
}, },
{
path: "toolbox",
component: Toolbox,
meta: {
title: "settings.toolbox.toolbox",
},
},
{ {
path: "about", path: "about",
component: About, component: About,

View file

@ -4,12 +4,12 @@ import { authRoutes } from "./auth";
import { recipeRoutes } from "./recipes"; import { recipeRoutes } from "./recipes";
import { mealRoutes } from "./meal"; import { mealRoutes } from "./meal";
import { generalRoutes } from "./general"; import { generalRoutes } from "./general";
import { store } from "../store"; import { store } from "@/store";
import VueRouter from "vue-router"; import VueRouter from "vue-router";
import VueI18n from "@/i18n"; import VueI18n from "@/i18n";
import Vuetify from "@/plugins/vuetify"; import Vuetify from "@/plugins/vuetify";
import Vue from "vue"; import Vue from "vue";
import i18n from '@/i18n.js'; import i18n from "@/i18n.js";
export const routes = [ export const routes = [
...generalRoutes, ...generalRoutes,

View file

@ -5,6 +5,7 @@ import createPersistedState from "vuex-persistedstate";
import userSettings from "./modules/userSettings"; import userSettings from "./modules/userSettings";
import language from "./modules/language"; import language from "./modules/language";
import siteSettings from "./modules/siteSettings"; import siteSettings from "./modules/siteSettings";
import recipes from "./modules/recipes";
import groups from "./modules/groups"; import groups from "./modules/groups";
Vue.use(Vuex); Vue.use(Vuex);
@ -20,6 +21,7 @@ const store = new Vuex.Store({
language, language,
siteSettings, siteSettings,
groups, groups,
recipes,
}, },
state: { state: {
// All Recipe Data Store // All Recipe Data Store
@ -35,9 +37,6 @@ const store = new Vuex.Store({
}, },
mutations: { mutations: {
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
setMealPlanCategories(state, payload) { setMealPlanCategories(state, payload) {
state.mealPlanCategories = payload; state.mealPlanCategories = payload;
}, },
@ -53,18 +52,6 @@ const store = new Vuex.Store({
}, },
actions: { actions: {
async requestRecentRecipes({ getters }) {
const payload = await api.recipes.allSummary(0, 30);
const recent = getters.getRecentRecipes;
if (recent.length >= 30) return;
this.commit("setRecentRecipes", payload);
},
async requestAllRecipes({ getters }) {
const recent = getters.getRecentRecipes;
const start = recent.length + 1;
const payload = await api.recipes.allSummary(start, 9999);
this.commit("setRecentRecipes", [...recent, ...payload]);
},
async requestCategories({ commit }) { async requestCategories({ commit }) {
const categories = await api.categories.getAll(); const categories = await api.categories.getAll();
commit("setAllCategories", categories); commit("setAllCategories", categories);
@ -80,7 +67,6 @@ const store = new Vuex.Store({
}, },
getters: { getters: {
getRecentRecipes: state => state.recentRecipes,
getMealPlanCategories: state => state.mealPlanCategories, getMealPlanCategories: state => state.mealPlanCategories,
getAllCategories: state => getAllCategories: state =>
state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)), state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)),

View file

@ -0,0 +1,73 @@
import { api } from "@/api";
const state = {
recentRecipes: [],
allRecipes: [],
};
const mutations = {
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
patchRecentRecipes(state, payload) {
if (state.recentRecipes[payload.id]) {
state.recentRecipes[payload.id] = payload;
}
},
dropRecentRecipes(state, payload) {
if (state.recentRecipes[payload.id]) {
delete state.recentRecipes[payload.id];
}
},
setAllRecipes(state, payload) {
state.allRecipes = payload;
},
patchAllRecipes(state, payload) {
state.allRecipes[payload.id] = payload;
},
dropAllRecipes(state, payload) {
if (state.allRecipes[payload.id]) {
delete state.allRecipes[payload.id];
}
},
};
const actions = {
async requestRecentRecipes() {
const payload = await api.recipes.allSummary(0, 30);
payload.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
console.log(payload);
const hash = Object.fromEntries(payload.map(e => [e.id, e]));
this.commit("setRecentRecipes", hash);
},
async requestAllRecipes({ getters }) {
const all = getters.getAllRecipes;
const payload = await api.recipes.allSummary(all.length, 9999);
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));
console.log(hash);
this.commit("setAllRecipes", hash);
},
patchRecipe({ commit }, payload) {
commit("patchAllRecipes", payload);
commit("patchRecentRecipes", payload);
},
dropRecipe({ commit }, payload) {
commit("dropAllRecipes", payload);
commit("dropRecentRecipes", payload);
},
};
const getters = {
getAllRecipes: state => Object.values(state.allRecipes),
getAllRecipesHash: state => state.allRecipes,
getRecentRecipes: state => Object.values(state.recentRecipes),
getRecentRecipesHash: state => state.recentRecipes,
};
export default {
state,
mutations,
actions,
getters,
};

View file

@ -29,10 +29,10 @@ const actions = {
let settings = await api.siteSettings.get(); let settings = await api.siteSettings.get();
commit("setSettings", settings); commit("setSettings", settings);
}, },
async requestCustomPages({commit }) { async requestCustomPages({ commit }) {
const customPages = await api.siteSettings.getPages() const customPages = await api.siteSettings.getPages();
commit("setCustomPages", customPages) commit("setCustomPages", customPages);
} },
}; };
const getters = { const getters = {

View file

@ -1,3 +1,5 @@
from logging import getLogger
from mealie.db.db_base import BaseDocument from mealie.db.db_base import BaseDocument
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlanModel from mealie.db.models.mealplan import MealPlanModel
@ -16,12 +18,13 @@ from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, UserInDB from mealie.schema.user import GroupInDB, UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
logger = getLogger()
class _Recipes(BaseDocument): class _Recipes(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model: RecipeModel = RecipeModel self.sql_model: RecipeModel = RecipeModel
self.orm_mode = True
self.schema: Recipe = Recipe self.schema: Recipe = Recipe
def update_image(self, session: Session, slug: str, extension: str = None) -> str: def update_image(self, session: Session, slug: str, extension: str = None) -> str:
@ -36,23 +39,26 @@ class _Categories(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model = Category self.sql_model = Category
self.orm_mode = True
self.schema = RecipeCategoryResponse self.schema = RecipeCategoryResponse
def get_empty(self, session: Session):
return session.query(Category).filter(~Category.recipes.any()).all()
class _Tags(BaseDocument): class _Tags(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "slug" self.primary_key = "slug"
self.sql_model = Tag self.sql_model = Tag
self.orm_mode = True
self.schema = RecipeTagResponse self.schema = RecipeTagResponse
def get_empty(self, session: Session):
return session.query(Tag).filter(~Tag.recipes.any()).all()
class _Meals(BaseDocument): class _Meals(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "uid" self.primary_key = "uid"
self.sql_model = MealPlanModel self.sql_model = MealPlanModel
self.orm_mode = True
self.schema = MealPlanInDB self.schema = MealPlanInDB
@ -60,7 +66,6 @@ class _Settings(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "id" self.primary_key = "id"
self.sql_model = SiteSettings self.sql_model = SiteSettings
self.orm_mode = True
self.schema = SiteSettingsSchema self.schema = SiteSettingsSchema
@ -68,7 +73,6 @@ class _Themes(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "name" self.primary_key = "name"
self.sql_model = SiteThemeModel self.sql_model = SiteThemeModel
self.orm_mode = True
self.schema = SiteTheme self.schema = SiteTheme
@ -76,7 +80,6 @@ class _Users(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "id" self.primary_key = "id"
self.sql_model = User self.sql_model = User
self.orm_mode = True
self.schema = UserInDB self.schema = UserInDB
def update_password(self, session, id, password: str): def update_password(self, session, id, password: str):
@ -91,7 +94,6 @@ class _Groups(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "id" self.primary_key = "id"
self.sql_model = Group self.sql_model = Group
self.orm_mode = True
self.schema = GroupInDB self.schema = GroupInDB
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]: def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]:
@ -116,7 +118,6 @@ class _SignUps(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "token" self.primary_key = "token"
self.sql_model = SignUp self.sql_model = SignUp
self.orm_mode = True
self.schema = SignUpOut self.schema = SignUpOut
@ -124,7 +125,6 @@ class _CustomPages(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "id" self.primary_key = "id"
self.sql_model = CustomPage self.sql_model = CustomPage
self.orm_mode = True
self.schema = CustomPageOut self.schema = CustomPageOut

View file

@ -12,7 +12,6 @@ class BaseDocument:
self.primary_key: str self.primary_key: str
self.store: str self.store: str
self.sql_model: SqlAlchemyBase self.sql_model: SqlAlchemyBase
self.orm_mode = False
self.schema: BaseModel self.schema: BaseModel
# TODO: Improve Get All Query Functionality # TODO: Improve Get All Query Functionality
@ -138,3 +137,4 @@ class BaseDocument:
session.delete(result) session.delete(result)
session.commit() session.commit()

View file

@ -11,28 +11,28 @@ site_settings2categories = sa.Table(
"site_settings2categoories", "site_settings2categoories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")), sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
group2categories = sa.Table( group2categories = sa.Table(
"group2categories", "group2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")), sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
recipes2categories = sa.Table( recipes2categories = sa.Table(
"recipes2categories", "recipes2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
custom_pages2categories = sa.Table( custom_pages2categories = sa.Table(
"custom_pages2categories", "custom_pages2categories",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")), sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")),
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
) )
@ -52,6 +52,9 @@ class Category(SqlAlchemyBase):
self.name = name.strip() self.name = name.strip()
self.slug = slugify(name) self.slug = slugify(name)
def update(self, name, session=None) -> None:
self.__init__(name, session)
@staticmethod @staticmethod
def get_ref(session, slug: str): def get_ref(session, slug: str):
return session.query(Category).filter(Category.slug == slug).one() return session.query(Category).filter(Category.slug == slug).one()

View file

@ -86,6 +86,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
rating: int = None, rating: int = None,
orgURL: str = None, orgURL: str = None,
extras: dict = None, extras: dict = None,
*args,
**kwargs
) -> None: ) -> None:
self.name = name self.name = name
self.description = description self.description = description
@ -139,6 +141,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
rating: int = None, rating: int = None,
orgURL: str = None, orgURL: str = None,
extras: dict = None, extras: dict = None,
*args,
**kwargs
): ):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions""" """Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""

View file

@ -11,7 +11,7 @@ recipes2tags = sa.Table(
"recipes2tags", "recipes2tags",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")), sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
) )
@ -31,6 +31,9 @@ class Tag(SqlAlchemyBase):
self.name = name.strip() self.name = name.strip()
self.slug = slugify(self.name) self.slug = slugify(self.name)
def update(self, name, session=None) -> None:
self.__init__(name, session)
@staticmethod @staticmethod
def create_if_not_exist(session, name: str = None): def create_if_not_exist(session, name: str = None):
test_slug = slugify(name) test_slug = slugify(name)

View file

@ -18,6 +18,18 @@ async def get_all_recipe_categories(session: Session = Depends(generate_session)
return db.categories.get_all_limit_columns(session, ["slug", "name"]) return db.categories.get_all_limit_columns(session, ["slug", "name"])
@router.get("/empty")
def get_empty_categories(session: Session = Depends(generate_session)):
""" Returns a list of categories that do not contain any recipes"""
return db.categories.get_empty(session)
@router.get("/{category}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(category: str, session: Session = Depends(generate_session)):
""" Returns a list of recipes associated with the provided category. """
return db.categories.get(session, category)
@router.post("") @router.post("")
async def create_recipe_category( async def create_recipe_category(
category: CategoryIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user) category: CategoryIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
@ -27,10 +39,16 @@ async def create_recipe_category(
return db.categories.create(session, category.dict()) return db.categories.create(session, category.dict())
@router.get("/{category}", response_model=RecipeCategoryResponse) @router.put("/{category}", response_model=RecipeCategoryResponse)
def get_all_recipes_by_category(category: str, session: Session = Depends(generate_session)): async def update_recipe_category(
""" Returns a list of recipes associated with the provided category. """ category: str,
return db.categories.get(session, category) new_category: CategoryIn,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Updates an existing Tag in the database """
return db.categories.update(session, category, new_category.dict())
@router.delete("/{category}") @router.delete("/{category}")

View file

@ -1,7 +1,5 @@
import shutil
from enum import Enum from enum import Enum
import requests
from fastapi import APIRouter, Depends, File, Form, HTTPException from fastapi import APIRouter, Depends, File, Form, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from mealie.db.database import db from mealie.db.database import db
@ -13,10 +11,7 @@ from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, r
from mealie.services.scraper.scraper import create_from_url from mealie.services.scraper.scraper import create_from_url
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter( router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"])
prefix="/api/recipes",
tags=["Recipe CRUD"],
)
@router.post("/create", status_code=201, response_model=str) @router.post("/create", status_code=201, response_model=str)
@ -66,7 +61,30 @@ def update_recipe(
if recipe_slug != recipe.slug: if recipe_slug != recipe.slug:
rename_image(original_slug=recipe_slug, new_slug=recipe.slug) rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
return recipe.slug return recipe
@router.patch("/{recipe_slug}")
def update_recipe(
recipe_slug: str,
data: dict,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Updates a recipe by existing slug and data. """
existing_entry: Recipe = db.recipes.get(session, recipe_slug)
entry_dict = existing_entry.dict()
entry_dict.update(data)
updated_entry = Recipe(**entry_dict) # ! Surely there's a better way?
recipe: Recipe = db.recipes.update(session, recipe_slug, updated_entry.dict())
if recipe_slug != recipe.slug:
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
return recipe
@router.delete("/{recipe_slug}") @router.delete("/{recipe_slug}")

View file

@ -20,6 +20,18 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
return db.tags.get_all_limit_columns(session, ["slug", "name"]) return db.tags.get_all_limit_columns(session, ["slug", "name"])
@router.get("/empty")
def get_empty_tags(session: Session = Depends(generate_session)):
""" Returns a list of tags that do not contain any recipes"""
return db.tags.get_empty(session)
@router.get("/{tag}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
""" Returns a list of recipes associated with the provided tag. """
return db.tags.get(session, tag)
@router.post("") @router.post("")
async def create_recipe_tag( async def create_recipe_tag(
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user) tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
@ -29,10 +41,13 @@ async def create_recipe_tag(
return db.tags.create(session, tag.dict()) return db.tags.create(session, tag.dict())
@router.get("/{tag}", response_model=RecipeTagResponse) @router.put("/{tag}", response_model=RecipeTagResponse)
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)): async def update_recipe_tag(
""" Returns a list of recipes associated with the provided tag. """ tag: str, new_tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
return db.tags.get(session, tag) ):
""" Updates an existing Tag in the database """
return db.tags.update(session, tag, new_tag.dict())
@router.delete("/{tag}") @router.delete("/{tag}")

View file

@ -2,6 +2,7 @@ from typing import List, Optional
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from pydantic.utils import GetterDict
class CategoryIn(CamelModel): class CategoryIn(CamelModel):
@ -15,6 +16,13 @@ class CategoryBase(CategoryIn):
class Config: class Config:
orm_mode = True orm_mode = True
@classmethod
def getter_dict(_cls, name_orm):
return {
**GetterDict(name_orm),
"total_recipes": len(name_orm.recipes),
}
class RecipeCategoryResponse(CategoryBase): class RecipeCategoryResponse(CategoryBase):
recipes: Optional[List[Recipe]] recipes: Optional[List[Recipe]]

View file

@ -35,6 +35,7 @@ class Nutrition(BaseModel):
class RecipeSummary(BaseModel): class RecipeSummary(BaseModel):
id: Optional[int]
name: str name: str
slug: Optional[str] = "" slug: Optional[str] = ""
image: Optional[Any] image: Optional[Any]

View file

@ -8,18 +8,19 @@ from tests.app_routes import AppRoutes
@pytest.fixture @pytest.fixture
def backup_data(): def backup_data():
return { return {
"name": "dev_sample_data_2021-Feb-13.zip", "name": "test_backup_2021-Apr-27.zip",
"force": False, "force": True,
"recipes": True, "recipes": True,
"settings": False, # ! Broken "settings": False, # ! Broken
"themes": True, "themes": True,
"groups": True, "groups": True,
"users": True, "users": True,
"pages": True,
} }
def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, token): def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, token):
import_route = api_routes.backups_file_name_import("dev_sample_data_2021-Feb-13.zip") import_route = api_routes.backups_file_name_import("test_backup_2021-Apr-27.zip")
response = api_client.post(import_route, json=backup_data, headers=token) response = api_client.post(import_route, json=backup_data, headers=token)
assert response.status_code == 200 assert response.status_code == 200
for _, value in json.loads(response.content).items(): for _, value in json.loads(response.content).items():

View file

@ -60,7 +60,7 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data,
response = api_client.put(recipe_url, json=recipe, headers=token) response = api_client.put(recipe_url, json=recipe, headers=token)
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.text) == recipe_data.expected_slug assert json.loads(response.text).get("slug") == recipe_data.expected_slug
response = api_client.get(recipe_url) response = api_client.get(recipe_url)
@ -84,7 +84,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data, toke
response = api_client.put(recipe_url, json=recipe, headers=token) response = api_client.put(recipe_url, json=recipe, headers=token)
assert response.status_code == 200 assert response.status_code == 200
assert json.loads(response.text) == new_slug assert json.loads(response.text).get("slug") == new_slug
recipe_data.expected_slug = new_slug recipe_data.expected_slug = new_slug