diff --git a/frontend/api/class-interfaces/recipe-bulk-actions.ts b/frontend/api/class-interfaces/recipe-bulk-actions.ts index 838ea82564cf..efe47d380038 100644 --- a/frontend/api/class-interfaces/recipe-bulk-actions.ts +++ b/frontend/api/class-interfaces/recipe-bulk-actions.ts @@ -1,11 +1,10 @@ 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"; // Many bulk actions return nothing // eslint-disable-next-line @typescript-eslint/no-empty-interface -interface BulkActionResponse { -} +interface BulkActionResponse {} const prefix = "/api"; @@ -15,6 +14,7 @@ const routes = { bulkCategorize: prefix + "/recipes/bulk-actions/categorize", bulkTag: prefix + "/recipes/bulk-actions/tag", bulkDelete: prefix + "/recipes/bulk-actions/delete", + bulkSettings: prefix + "/recipes/bulk-actions/settings", }; export class BulkActionsAPI extends BaseAPI { @@ -26,6 +26,10 @@ export class BulkActionsAPI extends BaseAPI { return await this.requests.post(routes.bulkCategorize, payload); } + async bulkSetSettings(payload: AssignSettings) { + return await this.requests.post(routes.bulkSettings, payload); + } + async bulkTag(payload: AssignTags) { return await this.requests.post(routes.bulkTag, payload); } diff --git a/frontend/components/Domain/Recipe/RecipeSettingsMenu.vue b/frontend/components/Domain/Recipe/RecipeSettingsMenu.vue index e3b4a93c7094..85f4652ab47c 100644 --- a/frontend/components/Domain/Recipe/RecipeSettingsMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeSettingsMenu.vue @@ -17,17 +17,7 @@ - + @@ -35,9 +25,11 @@ diff --git a/frontend/components/Domain/Recipe/RecipeSettingsSwitches.vue b/frontend/components/Domain/Recipe/RecipeSettingsSwitches.vue new file mode 100644 index 000000000000..364b78dd55ee --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeSettingsSwitches.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/frontend/pages/group/data/recipes.vue b/frontend/pages/group/data/recipes.vue index 899f3b072a82..1163422c0bb4 100644 --- a/frontend/pages/group/data/recipes.vue +++ b/frontend/pages/group/data/recipes.vue @@ -55,6 +55,15 @@ + +

Settings chosen here, excluding the locked option, will be applied to all selected recipes.

+
+ +
+

+ {{ selected.length }} recipe(s) settings will be updated. +

+
@@ -100,6 +109,7 @@ @tag-selected="openDialog(MODES.tag)" @categorize-selected="openDialog(MODES.category)" @delete-selected="openDialog(MODES.delete)" + @update-settings="openDialog(MODES.updateSettings)" > @@ -152,20 +162,22 @@ import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue"; import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; import { useUserApi } from "~/composables/api"; 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 { GroupDataExport } from "~/types/api-types/group"; import { MenuItem } from "~/components/global/BaseOverflowButton.vue"; +import RecipeSettingsSwitches from "~/components/Domain/Recipe/RecipeSettingsSwitches.vue"; -const MODES = { - tag: "tag", - category: "category", - export: "export", - delete: "delete", -}; +enum MODES { + tag = "tag", + category = "category", + export = "export", + delete = "delete", + updateSettings = "updateSettings", +} export default defineComponent({ - components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData }, + components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches }, scrollToTop: true, setup() { const { getAllRecipes, refreshRecipes } = useRecipes(true, true); @@ -217,6 +229,11 @@ export default defineComponent({ text: "Categorize", event: "categorize-selected", }, + { + icon: $globals.icons.cog, + text: "Update Settings", + event: "update-settings", + }, { icon: $globals.icons.delete, text: "Delete", @@ -307,6 +324,29 @@ export default defineComponent({ resetAll(); } + const recipeSettings = reactive({ + 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 @@ -322,26 +362,29 @@ export default defineComponent({ icon: $globals.icons.tags, }); - function openDialog(mode: string) { - const titles = { + function openDialog(mode: MODES) { + const titles: Record = { [MODES.tag]: "Tag Recipes", [MODES.category]: "Categorize Recipes", [MODES.export]: "Export Recipes", [MODES.delete]: "Delete Recipes", + [MODES.updateSettings]: "Update Settings", }; - const callbacks = { + const callbacks: Record Promise> = { [MODES.tag]: tagSelected, [MODES.category]: categorizeSelected, [MODES.export]: exportSelected, [MODES.delete]: deleteSelected, + [MODES.updateSettings]: updateSettings, }; - const icons = { + const icons: Record = { [MODES.tag]: $globals.icons.tags, [MODES.category]: $globals.icons.tags, [MODES.export]: $globals.icons.database, [MODES.delete]: $globals.icons.delete, + [MODES.updateSettings]: $globals.icons.cog, }; dialog.mode = mode; @@ -352,6 +395,7 @@ export default defineComponent({ } return { + recipeSettings, selectAll, loading, actions, diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index 358d287d7200..aed12e7e0448 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -18,6 +18,19 @@ export interface CategoryBase { id: 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 { recipes: string[]; tags: TagBase[]; @@ -214,15 +227,6 @@ export interface RecipeStep { text: string; ingredientReferences?: IngredientReferences[]; } -export interface RecipeSettings { - public?: boolean; - showNutrition?: boolean; - showAssets?: boolean; - landscapeView?: boolean; - disableComments?: boolean; - disableAmount?: boolean; - locked?: boolean; -} export interface RecipeAsset { name: string; icon: string; diff --git a/frontend/types/api-types/response.ts b/frontend/types/api-types/response.ts index 0f2c7dab8478..8e1cb0f5d0a6 100644 --- a/frontend/types/api-types/response.ts +++ b/frontend/types/api-types/response.ts @@ -5,8 +5,6 @@ /* 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 { message: string; error?: boolean; @@ -15,13 +13,6 @@ export interface ErrorResponse { export interface FileTokenResponse { fileToken: string; } -export interface PaginationQuery { - page?: number; - perPage?: number; - orderBy?: string; - orderDirection?: OrderDirection & string; - queryFilter?: string; -} export interface SuccessResponse { message: string; error?: boolean; diff --git a/mealie/routes/_base/routers.py b/mealie/routes/_base/routers.py index 8672dccdbf27..4efdac955d74 100644 --- a/mealie/routes/_base/routers.py +++ b/mealie/routes/_base/routers.py @@ -1,3 +1,4 @@ +import contextlib import json from collections.abc import Callable from enum import Enum @@ -31,16 +32,15 @@ class MealieCrudRoute(APIRoute): original_route_handler = super().get_route_handler() async def custom_route_handler(request: Request) -> Response: - try: + with contextlib.suppress(JSONDecodeError): response = await original_route_handler(request) response_body = json.loads(response.body) if type(response_body) == dict: if last_modified := response_body.get("updateAt"): response.headers["last-modified"] = last_modified - except JSONDecodeError: - pass - + # Force no-cache for all responses to prevent browser from caching API calls + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" return response return custom_route_handler diff --git a/mealie/routes/recipe/bulk_actions.py b/mealie/routes/recipe/bulk_actions.py index f698590d5802..2682564ca02f 100644 --- a/mealie/routes/recipe/bulk_actions.py +++ b/mealie/routes/recipe/bulk_actions.py @@ -7,7 +7,13 @@ from mealie.core.dependencies.dependencies import temporary_zip_path from mealie.core.security import create_file_token from mealie.routes._base import BaseUserController, controller 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.services.recipe.recipe_bulk_service import RecipeBulkActionsService @@ -25,6 +31,10 @@ class RecipeBulkActionsController(BaseUserController): def bulk_tag_recipes(self, tag_data: AssignTags): 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") def bulk_categorize_recipes(self, assign_cats: AssignCategories): self.service.assign_categories(assign_cats.recipes, assign_cats.categories) diff --git a/mealie/schema/recipe/recipe_bulk_actions.py b/mealie/schema/recipe/recipe_bulk_actions.py index f5a90cc14b4c..45a184d9e2c7 100644 --- a/mealie/schema/recipe/recipe_bulk_actions.py +++ b/mealie/schema/recipe/recipe_bulk_actions.py @@ -2,6 +2,7 @@ import enum from mealie.schema._mealie import MealieModel from mealie.schema.recipe.recipe_category import CategoryBase, TagBase +from mealie.schema.recipe.recipe_settings import RecipeSettings class ExportTypes(str, enum.Enum): @@ -24,5 +25,9 @@ class AssignTags(ExportBase): tags: list[TagBase] +class AssignSettings(ExportBase): + settings: RecipeSettings + + class DeleteRecipes(ExportBase): pass diff --git a/mealie/services/recipe/recipe_bulk_service.py b/mealie/services/recipe/recipe_bulk_service.py index 415ed99d6487..e83f6565c478 100644 --- a/mealie/services/recipe/recipe_bulk_service.py +++ b/mealie/services/recipe/recipe_bulk_service.py @@ -4,6 +4,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.recipe import CategoryBase 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.services._base_service import BaseService from mealie.services.exporter import Exporter, RecipeExporter @@ -47,6 +48,22 @@ class RecipeBulkActionsService(BaseService): 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: for slug in recipes: recipe = self.repos.recipes.get_one(slug)