feat: bulk recipe settings update (#1557)

* extract switches from menu component

* implement bulk updater for settings

* fix browser cache api calls issue

* add frontend for bulk settings modifications
This commit is contained in:
Hayden 2022-08-14 10:37:44 -08:00 committed by GitHub
parent 5cfff75dbe
commit 7adcc86d03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 168 additions and 66 deletions

View File

@ -1,11 +1,10 @@
import { BaseAPI } from "../_base"; import { BaseAPI } from "../_base";
import { AssignCategories, AssignTags, DeleteRecipes, ExportRecipes } from "~/types/api-types/recipe"; import { AssignCategories, AssignSettings, AssignTags, DeleteRecipes, ExportRecipes } from "~/types/api-types/recipe";
import { GroupDataExport } from "~/types/api-types/group"; import { GroupDataExport } from "~/types/api-types/group";
// Many bulk actions return nothing // Many bulk actions return nothing
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface BulkActionResponse { interface BulkActionResponse {}
}
const prefix = "/api"; const prefix = "/api";
@ -15,6 +14,7 @@ const routes = {
bulkCategorize: prefix + "/recipes/bulk-actions/categorize", bulkCategorize: prefix + "/recipes/bulk-actions/categorize",
bulkTag: prefix + "/recipes/bulk-actions/tag", bulkTag: prefix + "/recipes/bulk-actions/tag",
bulkDelete: prefix + "/recipes/bulk-actions/delete", bulkDelete: prefix + "/recipes/bulk-actions/delete",
bulkSettings: prefix + "/recipes/bulk-actions/settings",
}; };
export class BulkActionsAPI extends BaseAPI { export class BulkActionsAPI extends BaseAPI {
@ -26,6 +26,10 @@ export class BulkActionsAPI extends BaseAPI {
return await this.requests.post<BulkActionResponse>(routes.bulkCategorize, payload); return await this.requests.post<BulkActionResponse>(routes.bulkCategorize, payload);
} }
async bulkSetSettings(payload: AssignSettings) {
return await this.requests.post<BulkActionResponse>(routes.bulkSettings, payload);
}
async bulkTag(payload: AssignTags) { async bulkTag(payload: AssignTags) {
return await this.requests.post<BulkActionResponse>(routes.bulkTag, payload); return await this.requests.post<BulkActionResponse>(routes.bulkTag, payload);
} }

View File

@ -17,17 +17,7 @@
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-card-text class="mt-n5 pt-6 pb-2"> <v-card-text class="mt-n5 pt-6 pb-2">
<v-switch <RecipeSettingsSwitches v-model="value" :is-owner="isOwner" />
v-for="(itemValue, key) in value"
:key="key"
v-model="value[key]"
xs
dense
:disabled="key == 'locked' && !isOwner"
class="my-1"
:label="labels[key]"
hide-details
></v-switch>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-menu> </v-menu>
@ -35,9 +25,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api"; import { defineComponent } from "@nuxtjs/composition-api";
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
export default defineComponent({ export default defineComponent({
components: { RecipeSettingsSwitches },
props: { props: {
value: { value: {
type: Object, type: Object,
@ -48,22 +40,6 @@ export default defineComponent({
required: false, required: false,
}, },
}, },
setup() {
const { i18n } = useContext();
const labels = {
public: i18n.t("recipe.public-recipe"),
showNutrition: i18n.t("recipe.show-nutrition-values"),
showAssets: i18n.t("asset.show-assets"),
landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
disableComments: i18n.t("recipe.disable-comments"),
disableAmount: i18n.t("recipe.disable-amount"),
locked: i18n.t("recipe.locked"),
};
return {
labels,
};
},
}); });
</script> </script>

View File

@ -0,0 +1,51 @@
<template>
<div>
<v-switch
v-for="(_, key) in value"
:key="key"
v-model="value[key]"
xs
dense
:disabled="key == 'locked' && !isOwner"
class="my-1"
:label="labels[key]"
hide-details
></v-switch>
</div>
</template>
<script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api";
import { RecipeSettings } from "~/types/api-types/recipe";
export default defineComponent({
props: {
value: {
type: Object as () => RecipeSettings,
required: true,
},
isOwner: {
type: Boolean,
required: false,
},
},
setup() {
const { i18n } = useContext();
const labels: Record<keyof RecipeSettings, string> = {
public: i18n.tc("recipe.public-recipe"),
showNutrition: i18n.tc("recipe.show-nutrition-values"),
showAssets: i18n.tc("asset.show-assets"),
landscapeView: i18n.tc("recipe.landscape-view-coming-soon"),
disableComments: i18n.tc("recipe.disable-comments"),
disableAmount: i18n.tc("recipe.disable-amount"),
locked: i18n.tc("recipe.locked"),
};
return {
labels,
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@ -55,6 +55,15 @@
</v-virtual-scroll> </v-virtual-scroll>
</v-card> </v-card>
</v-card-text> </v-card-text>
<v-card-text v-else-if="dialog.mode == MODES.updateSettings" class="px-12">
<p>Settings chosen here, excluding the locked option, will be applied to all selected recipes.</p>
<div class="mx-auto">
<RecipeSettingsSwitches v-model="recipeSettings" />
</div>
<p class="text-center mb-0">
<i>{{ selected.length }} recipe(s) settings will be updated.</i>
</p>
</v-card-text>
</BaseDialog> </BaseDialog>
<section> <section>
<!-- Recipe Data Table --> <!-- Recipe Data Table -->
@ -100,6 +109,7 @@
@tag-selected="openDialog(MODES.tag)" @tag-selected="openDialog(MODES.tag)"
@categorize-selected="openDialog(MODES.category)" @categorize-selected="openDialog(MODES.category)"
@delete-selected="openDialog(MODES.delete)" @delete-selected="openDialog(MODES.delete)"
@update-settings="openDialog(MODES.updateSettings)"
> >
</BaseOverflowButton> </BaseOverflowButton>
@ -152,20 +162,22 @@ import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useRecipes, allRecipes } from "~/composables/recipes"; import { useRecipes, allRecipes } from "~/composables/recipes";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe, RecipeSettings } from "~/types/api-types/recipe";
import GroupExportData from "~/components/Domain/Group/GroupExportData.vue"; import GroupExportData from "~/components/Domain/Group/GroupExportData.vue";
import { GroupDataExport } from "~/types/api-types/group"; import { GroupDataExport } from "~/types/api-types/group";
import { MenuItem } from "~/components/global/BaseOverflowButton.vue"; import { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import RecipeSettingsSwitches from "~/components/Domain/Recipe/RecipeSettingsSwitches.vue";
const MODES = { enum MODES {
tag: "tag", tag = "tag",
category: "category", category = "category",
export: "export", export = "export",
delete: "delete", delete = "delete",
}; updateSettings = "updateSettings",
}
export default defineComponent({ export default defineComponent({
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData }, components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches },
scrollToTop: true, scrollToTop: true,
setup() { setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true); const { getAllRecipes, refreshRecipes } = useRecipes(true, true);
@ -217,6 +229,11 @@ export default defineComponent({
text: "Categorize", text: "Categorize",
event: "categorize-selected", event: "categorize-selected",
}, },
{
icon: $globals.icons.cog,
text: "Update Settings",
event: "update-settings",
},
{ {
icon: $globals.icons.delete, icon: $globals.icons.delete,
text: "Delete", text: "Delete",
@ -307,6 +324,29 @@ export default defineComponent({
resetAll(); resetAll();
} }
const recipeSettings = reactive<RecipeSettings>({
public: false,
showNutrition: false,
showAssets: false,
landscapeView: false,
disableComments: false,
disableAmount: false,
locked: false,
});
async function updateSettings() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
const { response, data } = await api.bulk.bulkSetSettings({ recipes, settings: recipeSettings });
console.log(response, data);
await refreshRecipes();
resetAll();
}
// ============================================================ // ============================================================
// Dialog Management // Dialog Management
@ -322,26 +362,29 @@ export default defineComponent({
icon: $globals.icons.tags, icon: $globals.icons.tags,
}); });
function openDialog(mode: string) { function openDialog(mode: MODES) {
const titles = { const titles: Record<MODES, string> = {
[MODES.tag]: "Tag Recipes", [MODES.tag]: "Tag Recipes",
[MODES.category]: "Categorize Recipes", [MODES.category]: "Categorize Recipes",
[MODES.export]: "Export Recipes", [MODES.export]: "Export Recipes",
[MODES.delete]: "Delete Recipes", [MODES.delete]: "Delete Recipes",
[MODES.updateSettings]: "Update Settings",
}; };
const callbacks = { const callbacks: Record<MODES, () => Promise<void>> = {
[MODES.tag]: tagSelected, [MODES.tag]: tagSelected,
[MODES.category]: categorizeSelected, [MODES.category]: categorizeSelected,
[MODES.export]: exportSelected, [MODES.export]: exportSelected,
[MODES.delete]: deleteSelected, [MODES.delete]: deleteSelected,
[MODES.updateSettings]: updateSettings,
}; };
const icons = { const icons: Record<MODES, string> = {
[MODES.tag]: $globals.icons.tags, [MODES.tag]: $globals.icons.tags,
[MODES.category]: $globals.icons.tags, [MODES.category]: $globals.icons.tags,
[MODES.export]: $globals.icons.database, [MODES.export]: $globals.icons.database,
[MODES.delete]: $globals.icons.delete, [MODES.delete]: $globals.icons.delete,
[MODES.updateSettings]: $globals.icons.cog,
}; };
dialog.mode = mode; dialog.mode = mode;
@ -352,6 +395,7 @@ export default defineComponent({
} }
return { return {
recipeSettings,
selectAll, selectAll,
loading, loading,
actions, actions,

View File

@ -18,6 +18,19 @@ export interface CategoryBase {
id: string; id: string;
slug: string; slug: string;
} }
export interface AssignSettings {
recipes: string[];
settings: RecipeSettings;
}
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
showAssets?: boolean;
landscapeView?: boolean;
disableComments?: boolean;
disableAmount?: boolean;
locked?: boolean;
}
export interface AssignTags { export interface AssignTags {
recipes: string[]; recipes: string[];
tags: TagBase[]; tags: TagBase[];
@ -214,15 +227,6 @@ export interface RecipeStep {
text: string; text: string;
ingredientReferences?: IngredientReferences[]; ingredientReferences?: IngredientReferences[];
} }
export interface RecipeSettings {
public?: boolean;
showNutrition?: boolean;
showAssets?: boolean;
landscapeView?: boolean;
disableComments?: boolean;
disableAmount?: boolean;
locked?: boolean;
}
export interface RecipeAsset { export interface RecipeAsset {
name: string; name: string;
icon: string; icon: string;

View File

@ -5,8 +5,6 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script /* Do not modify it by hand - just update the pydantic models and then re-run the script
*/ */
export type OrderDirection = "asc" | "desc";
export interface ErrorResponse { export interface ErrorResponse {
message: string; message: string;
error?: boolean; error?: boolean;
@ -15,13 +13,6 @@ export interface ErrorResponse {
export interface FileTokenResponse { export interface FileTokenResponse {
fileToken: string; fileToken: string;
} }
export interface PaginationQuery {
page?: number;
perPage?: number;
orderBy?: string;
orderDirection?: OrderDirection & string;
queryFilter?: string;
}
export interface SuccessResponse { export interface SuccessResponse {
message: string; message: string;
error?: boolean; error?: boolean;

View File

@ -1,3 +1,4 @@
import contextlib
import json import json
from collections.abc import Callable from collections.abc import Callable
from enum import Enum from enum import Enum
@ -31,16 +32,15 @@ class MealieCrudRoute(APIRoute):
original_route_handler = super().get_route_handler() original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response: async def custom_route_handler(request: Request) -> Response:
try: with contextlib.suppress(JSONDecodeError):
response = await original_route_handler(request) response = await original_route_handler(request)
response_body = json.loads(response.body) response_body = json.loads(response.body)
if type(response_body) == dict: if type(response_body) == dict:
if last_modified := response_body.get("updateAt"): if last_modified := response_body.get("updateAt"):
response.headers["last-modified"] = last_modified response.headers["last-modified"] = last_modified
except JSONDecodeError: # Force no-cache for all responses to prevent browser from caching API calls
pass response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response return response
return custom_route_handler return custom_route_handler

View File

@ -7,7 +7,13 @@ from mealie.core.dependencies.dependencies import temporary_zip_path
from mealie.core.security import create_file_token from mealie.core.security import create_file_token
from mealie.routes._base import BaseUserController, controller from mealie.routes._base import BaseUserController, controller
from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe.recipe_bulk_actions import AssignCategories, AssignTags, DeleteRecipes, ExportRecipes from mealie.schema.recipe.recipe_bulk_actions import (
AssignCategories,
AssignSettings,
AssignTags,
DeleteRecipes,
ExportRecipes,
)
from mealie.schema.response.responses import SuccessResponse from mealie.schema.response.responses import SuccessResponse
from mealie.services.recipe.recipe_bulk_service import RecipeBulkActionsService from mealie.services.recipe.recipe_bulk_service import RecipeBulkActionsService
@ -25,6 +31,10 @@ class RecipeBulkActionsController(BaseUserController):
def bulk_tag_recipes(self, tag_data: AssignTags): def bulk_tag_recipes(self, tag_data: AssignTags):
self.service.assign_tags(tag_data.recipes, tag_data.tags) self.service.assign_tags(tag_data.recipes, tag_data.tags)
@router.post("/settings")
def bulk_settings_recipes(self, settings_data: AssignSettings):
self.service.set_settings(settings_data.recipes, settings_data.settings)
@router.post("/categorize") @router.post("/categorize")
def bulk_categorize_recipes(self, assign_cats: AssignCategories): def bulk_categorize_recipes(self, assign_cats: AssignCategories):
self.service.assign_categories(assign_cats.recipes, assign_cats.categories) self.service.assign_categories(assign_cats.recipes, assign_cats.categories)

View File

@ -2,6 +2,7 @@ import enum
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
from mealie.schema.recipe.recipe_settings import RecipeSettings
class ExportTypes(str, enum.Enum): class ExportTypes(str, enum.Enum):
@ -24,5 +25,9 @@ class AssignTags(ExportBase):
tags: list[TagBase] tags: list[TagBase]
class AssignSettings(ExportBase):
settings: RecipeSettings
class DeleteRecipes(ExportBase): class DeleteRecipes(ExportBase):
pass pass

View File

@ -4,6 +4,7 @@ from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.recipe import CategoryBase from mealie.schema.recipe import CategoryBase
from mealie.schema.recipe.recipe_category import TagBase from mealie.schema.recipe.recipe_category import TagBase
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.user.user import GroupInDB, PrivateUser from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService from mealie.services._base_service import BaseService
from mealie.services.exporter import Exporter, RecipeExporter from mealie.services.exporter import Exporter, RecipeExporter
@ -47,6 +48,22 @@ class RecipeBulkActionsService(BaseService):
return exports_deleted return exports_deleted
def set_settings(self, recipes: list[str], settings: RecipeSettings) -> None:
for slug in recipes:
recipe = self.repos.recipes.get_one(slug)
if recipe is None:
self.logger.error(f"Failed to set settings for recipe {slug}, no recipe found")
settings.locked = recipe.settings.locked
recipe.settings = settings
try:
self.repos.recipes.update(slug, recipe)
except Exception as e:
self.logger.error(f"Failed to set settings for recipe {slug}")
self.logger.error(e)
def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None: def assign_tags(self, recipes: list[str], tags: list[TagBase]) -> None:
for slug in recipes: for slug in recipes:
recipe = self.repos.recipes.get_one(slug) recipe = self.repos.recipes.get_one(slug)