mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	feat: Change Recipe Owner (#4355)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
		| @@ -34,8 +34,8 @@ | |||||||
|     <template #item.userId="{ item }"> |     <template #item.userId="{ item }"> | ||||||
|       <v-list-item class="justify-start"> |       <v-list-item class="justify-start"> | ||||||
|         <UserAvatar :user-id="item.userId" :tooltip="false" size="40" /> |         <UserAvatar :user-id="item.userId" :tooltip="false" size="40" /> | ||||||
|         <v-list-item-content> |         <v-list-item-content class="pl-2"> | ||||||
|           <v-list-item-title> |           <v-list-item-title class="text-left"> | ||||||
|             {{ getMember(item.userId) }} |             {{ getMember(item.userId) }} | ||||||
|           </v-list-item-title> |           </v-list-item-title> | ||||||
|         </v-list-item-content> |         </v-list-item-content> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="d-flex justify-start align-center"> |   <div class="d-flex justify-start align-top py-2"> | ||||||
|     <RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> |     <RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> | ||||||
|     <RecipeSettingsMenu |     <RecipeSettingsMenu | ||||||
|       class="my-1 mx-1" |       class="my-1 mx-1" | ||||||
| @@ -7,22 +7,47 @@ | |||||||
|       :is-owner="recipe.userId == user.id" |       :is-owner="recipe.userId == user.id" | ||||||
|       @upload="uploadImage" |       @upload="uploadImage" | ||||||
|     /> |     /> | ||||||
|  |     <v-spacer /> | ||||||
|  |     <v-container class="py-0" style="width: 40%;"> | ||||||
|  |       <v-select | ||||||
|  |         v-model="recipe.userId" | ||||||
|  |         :items="allUsers" | ||||||
|  |         item-text="fullName" | ||||||
|  |         item-value="id" | ||||||
|  |         :label="$tc('general.owner')" | ||||||
|  |         hide-details | ||||||
|  |         :disabled="!canEditOwner" | ||||||
|  |       > | ||||||
|  |         <template #prepend> | ||||||
|  |           <UserAvatar :user-id="recipe.userId" :tooltip="false" /> | ||||||
|  |         </template> | ||||||
|  |       </v-select> | ||||||
|  |       <v-card-text v-if="ownerHousehold" class="pa-0 d-flex" style="align-items: flex-end;"> | ||||||
|  |         <v-spacer /> | ||||||
|  |         <v-icon>{{ $globals.icons.household }}</v-icon> | ||||||
|  |         <span class="pl-1">{{ ownerHousehold.name }}</span> | ||||||
|  |       </v-card-text> | ||||||
|  |     </v-container> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, onUnmounted } from "@nuxtjs/composition-api"; | import { computed, defineComponent, onUnmounted } from "@nuxtjs/composition-api"; | ||||||
| import { clearPageState, usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; | import { clearPageState, usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; | ||||||
| import { NoUndefinedField } from "~/lib/api/types/non-generated"; | import { NoUndefinedField } from "~/lib/api/types/non-generated"; | ||||||
| import { Recipe } from "~/lib/api/types/recipe"; | import { Recipe } from "~/lib/api/types/recipe"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue"; | import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue"; | ||||||
| import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue"; | import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue"; | ||||||
|  | import { useUserStore } from "~/composables/store/use-user-store"; | ||||||
|  | import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; | ||||||
|  | import { useHouseholdStore } from "~/composables/store"; | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { |   components: { | ||||||
|     RecipeImageUploadBtn, |     RecipeImageUploadBtn, | ||||||
|     RecipeSettingsMenu, |     RecipeSettingsMenu, | ||||||
|  |     UserAvatar, | ||||||
|   }, |   }, | ||||||
|   props: { |   props: { | ||||||
|     recipe: { |     recipe: { | ||||||
| @@ -34,6 +59,22 @@ export default defineComponent({ | |||||||
|     const { user } = usePageUser(); |     const { user } = usePageUser(); | ||||||
|     const api = useUserApi(); |     const api = useUserApi(); | ||||||
|     const { imageKey } = usePageState(props.recipe.slug); |     const { imageKey } = usePageState(props.recipe.slug); | ||||||
|  |  | ||||||
|  |     const canEditOwner = computed(() => { | ||||||
|  |       return user.id === props.recipe.userId || user.admin; | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     const { store: allUsers } = useUserStore(); | ||||||
|  |     const { store: households } = useHouseholdStore(); | ||||||
|  |     const ownerHousehold = computed(() => { | ||||||
|  |       const owner = allUsers.value.find((u) => u.id === props.recipe.userId); | ||||||
|  |       if (!owner) { | ||||||
|  |         return null; | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       return households.value.find((h) => h.id === owner.householdId); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     onUnmounted(() => { |     onUnmounted(() => { | ||||||
|       clearPageState(props.recipe.slug); |       clearPageState(props.recipe.slug); | ||||||
|       console.debug("reset RecipePage state during unmount"); |       console.debug("reset RecipePage state during unmount"); | ||||||
| @@ -51,8 +92,11 @@ export default defineComponent({ | |||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       user, |       user, | ||||||
|  |       canEditOwner, | ||||||
|       uploadImage, |       uploadImage, | ||||||
|       imageKey, |       imageKey, | ||||||
|  |       allUsers, | ||||||
|  |       ownerHousehold, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -182,6 +182,7 @@ | |||||||
|     "date": "Date", |     "date": "Date", | ||||||
|     "id": "Id", |     "id": "Id", | ||||||
|     "owner": "Owner", |     "owner": "Owner", | ||||||
|  |     "change-owner": "Change Owner", | ||||||
|     "date-added": "Date Added", |     "date-added": "Date Added", | ||||||
|     "none": "None", |     "none": "None", | ||||||
|     "run": "Run", |     "run": "Run", | ||||||
|   | |||||||
| @@ -77,6 +77,8 @@ export interface ReadWebhook { | |||||||
| } | } | ||||||
| export interface UserSummary { | export interface UserSummary { | ||||||
|   id: string; |   id: string; | ||||||
|  |   groupId: string; | ||||||
|  |   householdId: string; | ||||||
|   username: string; |   username: string; | ||||||
|   fullName: string; |   fullName: string; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -178,6 +178,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> { | |||||||
|     return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`; |     return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async updateMany(payload: Recipe[]) { | ||||||
|  |     return await this.requests.put<Recipe[]>(routes.recipesBase, payload); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   async updateLastMade(recipeSlug: string, timestamp: string) { |   async updateLastMade(recipeSlug: string, timestamp: string) { | ||||||
|     return await this.requests.patch<Recipe, RecipeLastMade>(routes.recipesSlugLastMade(recipeSlug), { timestamp }) |     return await this.requests.patch<Recipe, RecipeLastMade>(routes.recipesSlugLastMade(recipeSlug), { timestamp }) | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -64,6 +64,24 @@ | |||||||
|           <i>{{ $tc('data-pages.recipes.selected-length-recipe-s-settings-will-be-updated', selected.length) }}</i> |           <i>{{ $tc('data-pages.recipes.selected-length-recipe-s-settings-will-be-updated', selected.length) }}</i> | ||||||
|         </p> |         </p> | ||||||
|       </v-card-text> |       </v-card-text> | ||||||
|  |       <v-card-text v-else-if="dialog.mode == MODES.changeOwner"> | ||||||
|  |         <v-select | ||||||
|  |           v-model="selectedOwner" | ||||||
|  |           :items="allUsers" | ||||||
|  |           item-text="fullName" | ||||||
|  |           item-value="id" | ||||||
|  |           :label="$tc('general.owner')" | ||||||
|  |           hide-details | ||||||
|  |         > | ||||||
|  |           <template #prepend> | ||||||
|  |             <UserAvatar :user-id="selectedOwner" :tooltip="false" /> | ||||||
|  |           </template> | ||||||
|  |         </v-select> | ||||||
|  |         <v-card-text v-if="selectedOwnerHousehold" class="d-flex" style="align-items: flex-end;"> | ||||||
|  |           <v-icon>{{ $globals.icons.household }}</v-icon> | ||||||
|  |           <span class="pl-1">{{ selectedOwnerHousehold.name }}</span> | ||||||
|  |         </v-card-text> | ||||||
|  |       </v-card-text> | ||||||
|     </BaseDialog> |     </BaseDialog> | ||||||
|     <section> |     <section> | ||||||
|       <!-- Recipe Data Table --> |       <!-- Recipe Data Table --> | ||||||
| @@ -109,6 +127,7 @@ | |||||||
|           @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)" |           @update-settings="openDialog(MODES.updateSettings)" | ||||||
|  |           @change-owner="openDialog(MODES.changeOwner)" | ||||||
|         > |         > | ||||||
|         </BaseOverflowButton> |         </BaseOverflowButton> | ||||||
|  |  | ||||||
| @@ -155,7 +174,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, reactive, ref, useContext, onMounted } from "@nuxtjs/composition-api"; | import { computed, defineComponent, reactive, ref, useContext, onMounted } from "@nuxtjs/composition-api"; | ||||||
| import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue"; | 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"; | ||||||
| @@ -165,6 +184,9 @@ import GroupExportData from "~/components/Domain/Group/GroupExportData.vue"; | |||||||
| import { GroupDataExport } from "~/lib/api/types/group"; | import { GroupDataExport } from "~/lib/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"; | import RecipeSettingsSwitches from "~/components/Domain/Recipe/RecipeSettingsSwitches.vue"; | ||||||
|  | import { useUserStore } from "~/composables/store/use-user-store"; | ||||||
|  | import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; | ||||||
|  | import { useHouseholdStore } from "~/composables/store/use-household-store"; | ||||||
|  |  | ||||||
| enum MODES { | enum MODES { | ||||||
|   tag = "tag", |   tag = "tag", | ||||||
| @@ -172,10 +194,11 @@ enum MODES { | |||||||
|   export = "export", |   export = "export", | ||||||
|   delete = "delete", |   delete = "delete", | ||||||
|   updateSettings = "updateSettings", |   updateSettings = "updateSettings", | ||||||
|  |   changeOwner = "changeOwner", | ||||||
| } | } | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches }, |   components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches, UserAvatar }, | ||||||
|   scrollToTop: true, |   scrollToTop: true, | ||||||
|   setup() { |   setup() { | ||||||
|     const { $auth, $globals, i18n } = useContext(); |     const { $auth, $globals, i18n } = useContext(); | ||||||
| @@ -230,6 +253,11 @@ export default defineComponent({ | |||||||
|         text: i18n.tc("data-pages.recipes.update-settings"), |         text: i18n.tc("data-pages.recipes.update-settings"), | ||||||
|         event: "update-settings", |         event: "update-settings", | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         icon: $globals.icons.user, | ||||||
|  |         text: i18n.tc("general.change-owner"), | ||||||
|  |         event: "change-owner", | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         icon: $globals.icons.delete, |         icon: $globals.icons.delete, | ||||||
|         text: i18n.tc("general.delete"), |         text: i18n.tc("general.delete"), | ||||||
| @@ -312,9 +340,7 @@ export default defineComponent({ | |||||||
|  |  | ||||||
|       const recipes = selected.value.map((x: Recipe) => x.slug ?? ""); |       const recipes = selected.value.map((x: Recipe) => x.slug ?? ""); | ||||||
|  |  | ||||||
|       const { response, data } = await api.bulk.bulkDelete({ recipes }); |       await api.bulk.bulkDelete({ recipes }); | ||||||
|  |  | ||||||
|       console.log(response, data); |  | ||||||
|  |  | ||||||
|       await refreshRecipes(); |       await refreshRecipes(); | ||||||
|       resetAll(); |       resetAll(); | ||||||
| @@ -335,9 +361,23 @@ export default defineComponent({ | |||||||
|  |  | ||||||
|       const recipes = selected.value.map((x: Recipe) => x.slug ?? ""); |       const recipes = selected.value.map((x: Recipe) => x.slug ?? ""); | ||||||
|  |  | ||||||
|       const { response, data } = await api.bulk.bulkSetSettings({ recipes, settings: recipeSettings }); |       await api.bulk.bulkSetSettings({ recipes, settings: recipeSettings }); | ||||||
|  |  | ||||||
|       console.log(response, data); |       await refreshRecipes(); | ||||||
|  |       resetAll(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async function changeOwner() { | ||||||
|  |       if(!selected.value.length || !selectedOwner.value) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       selected.value.forEach((r) => { | ||||||
|  |         r.userId = selectedOwner.value; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       loading.value = true; | ||||||
|  |       await api.recipes.updateMany(selected.value); | ||||||
|  |  | ||||||
|       await refreshRecipes(); |       await refreshRecipes(); | ||||||
|       resetAll(); |       resetAll(); | ||||||
| @@ -365,6 +405,7 @@ export default defineComponent({ | |||||||
|         [MODES.export]: i18n.tc("data-pages.recipes.export-recipes"), |         [MODES.export]: i18n.tc("data-pages.recipes.export-recipes"), | ||||||
|         [MODES.delete]: i18n.tc("data-pages.recipes.delete-recipes"), |         [MODES.delete]: i18n.tc("data-pages.recipes.delete-recipes"), | ||||||
|         [MODES.updateSettings]: i18n.tc("data-pages.recipes.update-settings"), |         [MODES.updateSettings]: i18n.tc("data-pages.recipes.update-settings"), | ||||||
|  |         [MODES.changeOwner]: i18n.tc("general.change-owner"), | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       const callbacks: Record<MODES, () => Promise<void>> = { |       const callbacks: Record<MODES, () => Promise<void>> = { | ||||||
| @@ -373,6 +414,7 @@ export default defineComponent({ | |||||||
|         [MODES.export]: exportSelected, |         [MODES.export]: exportSelected, | ||||||
|         [MODES.delete]: deleteSelected, |         [MODES.delete]: deleteSelected, | ||||||
|         [MODES.updateSettings]: updateSettings, |         [MODES.updateSettings]: updateSettings, | ||||||
|  |         [MODES.changeOwner]: changeOwner, | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       const icons: Record<MODES, string> = { |       const icons: Record<MODES, string> = { | ||||||
| @@ -381,6 +423,7 @@ export default defineComponent({ | |||||||
|         [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, |         [MODES.updateSettings]: $globals.icons.cog, | ||||||
|  |         [MODES.changeOwner]: $globals.icons.user, | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       dialog.mode = mode; |       dialog.mode = mode; | ||||||
| @@ -390,6 +433,22 @@ export default defineComponent({ | |||||||
|       dialog.state = true; |       dialog.state = true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const { store: allUsers } = useUserStore(); | ||||||
|  |     const { store: households } = useHouseholdStore(); | ||||||
|  |     const selectedOwner = ref(""); | ||||||
|  |     const selectedOwnerHousehold = computed(() => { | ||||||
|  |       if(!selectedOwner.value) { | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const owner = allUsers.value.find((u) => u.id === selectedOwner.value); | ||||||
|  |       if (!owner) { | ||||||
|  |         return null; | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       return households.value.find((h) => h.id === owner.householdId); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       recipeSettings, |       recipeSettings, | ||||||
|       selectAll, |       selectAll, | ||||||
| @@ -412,6 +471,9 @@ export default defineComponent({ | |||||||
|       groupExports, |       groupExports, | ||||||
|       purgeExportsDialog, |       purgeExportsDialog, | ||||||
|       purgeExports, |       purgeExports, | ||||||
|  |       allUsers, | ||||||
|  |       selectedOwner, | ||||||
|  |       selectedOwnerHousehold, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   head() { |   head() { | ||||||
|   | |||||||
| @@ -79,19 +79,21 @@ class GroupCookbookController(BaseCrudController): | |||||||
|             cb = self.mixins.update_one(cookbook, cookbook.id) |             cb = self.mixins.update_one(cookbook, cookbook.id) | ||||||
|             updated_by_group_and_household[cb.group_id][cb.household_id].append(cb) |             updated_by_group_and_household[cb.group_id][cb.household_id].append(cb) | ||||||
|  |  | ||||||
|  |         all_updated: list[ReadCookBook] = [] | ||||||
|         if updated_by_group_and_household: |         if updated_by_group_and_household: | ||||||
|             for group_id, household_dict in updated_by_group_and_household.items(): |             for group_id, household_dict in updated_by_group_and_household.items(): | ||||||
|                 for household_id, updated in household_dict.items(): |                 for household_id, updated_cookbooks in household_dict.items(): | ||||||
|  |                     all_updated.extend(updated_cookbooks) | ||||||
|                     self.publish_event( |                     self.publish_event( | ||||||
|                         event_type=EventTypes.cookbook_updated, |                         event_type=EventTypes.cookbook_updated, | ||||||
|                         document_data=EventCookbookBulkData( |                         document_data=EventCookbookBulkData( | ||||||
|                             operation=EventOperation.update, cookbook_ids=[cb.id for cb in updated] |                             operation=EventOperation.update, cookbook_ids=[cb.id for cb in updated_cookbooks] | ||||||
|                         ), |                         ), | ||||||
|                         group_id=group_id, |                         group_id=group_id, | ||||||
|                         household_id=household_id, |                         household_id=household_id, | ||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|         return updated |         return all_updated | ||||||
|  |  | ||||||
|     @router.get("/{item_id}", response_model=RecipeCookBook) |     @router.get("/{item_id}", response_model=RecipeCookBook) | ||||||
|     def get_one(self, item_id: UUID4 | str): |     def get_one(self, item_id: UUID4 | str): | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | from collections import defaultdict | ||||||
| from functools import cached_property | from functools import cached_property | ||||||
| from shutil import copyfileobj, rmtree | from shutil import copyfileobj, rmtree | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
| @@ -60,6 +61,7 @@ from mealie.schema.response.responses import ErrorResponse | |||||||
| from mealie.services import urls | from mealie.services import urls | ||||||
| from mealie.services.event_bus_service.event_types import ( | from mealie.services.event_bus_service.event_types import ( | ||||||
|     EventOperation, |     EventOperation, | ||||||
|  |     EventRecipeBulkData, | ||||||
|     EventRecipeBulkReportData, |     EventRecipeBulkReportData, | ||||||
|     EventRecipeData, |     EventRecipeData, | ||||||
|     EventTypes, |     EventTypes, | ||||||
| @@ -466,6 +468,31 @@ class RecipeController(BaseRecipeController): | |||||||
|  |  | ||||||
|         return recipe |         return recipe | ||||||
|  |  | ||||||
|  |     @router.put("") | ||||||
|  |     def update_many(self, data: list[Recipe]): | ||||||
|  |         updated_by_group_and_household: defaultdict[UUID4, defaultdict[UUID4, list[Recipe]]] = defaultdict( | ||||||
|  |             lambda: defaultdict(list) | ||||||
|  |         ) | ||||||
|  |         for recipe in data: | ||||||
|  |             r = self.service.update_one(recipe.id, recipe)  # type: ignore | ||||||
|  |             updated_by_group_and_household[r.group_id][r.household_id].append(r) | ||||||
|  |  | ||||||
|  |         all_updated: list[Recipe] = [] | ||||||
|  |         if updated_by_group_and_household: | ||||||
|  |             for group_id, household_dict in updated_by_group_and_household.items(): | ||||||
|  |                 for household_id, updated_recipes in household_dict.items(): | ||||||
|  |                     all_updated.extend(updated_recipes) | ||||||
|  |                     self.publish_event( | ||||||
|  |                         event_type=EventTypes.recipe_updated, | ||||||
|  |                         document_data=EventRecipeBulkData( | ||||||
|  |                             operation=EventOperation.update, recipe_slugs=[r.slug for r in updated_recipes] | ||||||
|  |                         ), | ||||||
|  |                         group_id=group_id, | ||||||
|  |                         household_id=household_id, | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |         return all_updated | ||||||
|  |  | ||||||
|     @router.patch("/{slug}") |     @router.patch("/{slug}") | ||||||
|     def patch_one(self, slug: str, data: Recipe): |     def patch_one(self, slug: str, data: Recipe): | ||||||
|         """Updates a recipe by existing slug and data.""" |         """Updates a recipe by existing slug and data.""" | ||||||
|   | |||||||
| @@ -180,6 +180,8 @@ class UserOut(UserBase): | |||||||
|  |  | ||||||
| class UserSummary(MealieModel): | class UserSummary(MealieModel): | ||||||
|     id: UUID4 |     id: UUID4 | ||||||
|  |     group_id: UUID4 | ||||||
|  |     household_id: UUID4 | ||||||
|     username: str |     username: str | ||||||
|     full_name: str |     full_name: str | ||||||
|     model_config = ConfigDict(from_attributes=True) |     model_config = ConfigDict(from_attributes=True) | ||||||
|   | |||||||
| @@ -138,6 +138,11 @@ class EventRecipeData(EventDocumentDataBase): | |||||||
|     recipe_slug: str |     recipe_slug: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EventRecipeBulkData(EventDocumentDataBase): | ||||||
|  |     document_type: EventDocumentType = EventDocumentType.recipe | ||||||
|  |     recipe_slugs: list[str] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventRecipeBulkReportData(EventDocumentDataBase): | class EventRecipeBulkReportData(EventDocumentDataBase): | ||||||
|     document_type: EventDocumentType = EventDocumentType.recipe_bulk_report |     document_type: EventDocumentType = EventDocumentType.recipe_bulk_report | ||||||
|     report_id: UUID4 |     report_id: UUID4 | ||||||
|   | |||||||
| @@ -483,6 +483,30 @@ def test_read_update( | |||||||
|         assert cats[0]["name"] in test_name |         assert cats[0]["name"] in test_name | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_update_many(api_client: TestClient, unique_user: TestUser): | ||||||
|  |     recipe_slugs = [random_string() for _ in range(3)] | ||||||
|  |     for slug in recipe_slugs: | ||||||
|  |         api_client.post(api_routes.recipes, json={"name": slug}, headers=unique_user.token) | ||||||
|  |  | ||||||
|  |     recipes_data: list[dict] = [ | ||||||
|  |         json.loads(api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token).text) | ||||||
|  |         for slug in recipe_slugs | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     new_slug_by_id = {r["id"]: random_string() for r in recipes_data} | ||||||
|  |     for recipe_data in recipes_data: | ||||||
|  |         recipe_data["name"] = new_slug_by_id[recipe_data["id"]] | ||||||
|  |         recipe_data["slug"] = new_slug_by_id[recipe_data["id"]] | ||||||
|  |  | ||||||
|  |     response = api_client.put(api_routes.recipes, json=recipes_data, headers=unique_user.token) | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     for updated_recipe_data in response.json(): | ||||||
|  |         assert updated_recipe_data["slug"] == new_slug_by_id[updated_recipe_data["id"]] | ||||||
|  |         get_response = api_client.get(api_routes.recipes_slug(updated_recipe_data["slug"]), headers=unique_user.token) | ||||||
|  |         assert get_response.status_code == 200 | ||||||
|  |         assert get_response.json()["slug"] == updated_recipe_data["slug"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_duplicate(api_client: TestClient, unique_user: TestUser): | def test_duplicate(api_client: TestClient, unique_user: TestUser): | ||||||
|     recipe_data = recipe_test_data[0] |     recipe_data = recipe_test_data[0] | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user