mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
feat: Recipe Actions (#3448)
Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com> Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
ee87a14401
commit
3807778e2f
@ -0,0 +1,52 @@
|
||||
"""add group recipe actions
|
||||
|
||||
Revision ID: 7788478a0338
|
||||
Revises: d7c6efd2de42
|
||||
Create Date: 2024-04-07 01:05:20.816270
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7788478a0338"
|
||||
down_revision = "d7c6efd2de42"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"recipe_actions",
|
||||
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("action_type", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["group_id"],
|
||||
["groups.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_recipe_actions_action_type"), "recipe_actions", ["action_type"], unique=False)
|
||||
op.create_index(op.f("ix_recipe_actions_created_at"), "recipe_actions", ["created_at"], unique=False)
|
||||
op.create_index(op.f("ix_recipe_actions_group_id"), "recipe_actions", ["group_id"], unique=False)
|
||||
op.create_index(op.f("ix_recipe_actions_title"), "recipe_actions", ["title"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_recipe_actions_title"), table_name="recipe_actions")
|
||||
op.drop_index(op.f("ix_recipe_actions_group_id"), table_name="recipe_actions")
|
||||
op.drop_index(op.f("ix_recipe_actions_created_at"), table_name="recipe_actions")
|
||||
op.drop_index(op.f("ix_recipe_actions_action_type"), table_name="recipe_actions")
|
||||
op.drop_table("recipe_actions")
|
||||
# ### end Alembic commands ###
|
@ -81,12 +81,60 @@ The meal planner has the concept of plan rules. These offer a flexible way to us
|
||||
|
||||
The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.
|
||||
|
||||
!!! warning
|
||||
At this time there isn't a tight integration between meal-plans and shopping lists; however, it's something we have planned for the future.
|
||||
|
||||
|
||||
[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }
|
||||
|
||||
## Integrations
|
||||
|
||||
Mealie is designed to integrate with many different external services. There are several ways you can integrate with Mealie to achieve custom IoT automations, data synchronization, and anything else you can think of. [You can work directly with Mealie through the API](./api-usage.md), or leverage other services to make seamless integrations.
|
||||
|
||||
### Notifiers
|
||||
|
||||
Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
|
||||
- creating a recipe
|
||||
- adding items to a shopping list
|
||||
- creating a new mealplan
|
||||
|
||||
Notifiers use the [Apprise library](https://github.com/caronc/apprise/wiki), which integrates with a large number of notification services. In addition, certain custom notifiers send basic event data to the consumer (e.g. the `id` of the resource). These include:
|
||||
- `form` and `forms`
|
||||
- `json` and `jsons`
|
||||
- `xml` and `xmls`
|
||||
|
||||
[Notifiers Demo](https://demo.mealie.io/group/notifiers){ .md-button .md-button--primary }
|
||||
|
||||
### Webhooks
|
||||
|
||||
Unlike notifiers, which are event-driven notifications, Webhooks allow you to send scheduled notifications to your desired endpoint. Webhooks are sent on the day of a scheduled mealplan, at the specified time, and contain the mealplan data in the request.
|
||||
|
||||
[Webhooks Demo](https://demo.mealie.io/group/webhooks){ .md-button .md-button--primary }
|
||||
|
||||
### Recipe Actions
|
||||
|
||||
Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions:
|
||||
1. link - these actions will take you directly to an external page
|
||||
2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant
|
||||
|
||||
Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
|
||||
```
|
||||
https://www.google.com/search?q=${slug}
|
||||
```
|
||||
|
||||
When the action is clicked on, the `${slug}` field is replaced with the recipe's slug value. So, for example, it might take you to this URL on one of your recipes:
|
||||
```
|
||||
https://www.google.com/search?q=pasta-fagioli
|
||||
```
|
||||
|
||||
A common use case for "link" recipe actions is to integrate with the Bring! shopping list. Simply add a Recipe Action with the following URL:
|
||||
```
|
||||
https://api.getbring.com/rest/bringrecipes/deeplink?url=${url}&source=web
|
||||
```
|
||||
|
||||
Below is a list of all valid merge fields:
|
||||
- ${id}
|
||||
- ${slug}
|
||||
- ${url}
|
||||
|
||||
To add, modify, or delete Recipe Actions, visit the Data Management page (more on that below).
|
||||
|
||||
## Data Management
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -70,6 +70,7 @@
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: loggedIn,
|
||||
recipeActions: true,
|
||||
}"
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
|
@ -105,6 +105,26 @@
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-group @click.stop>
|
||||
<template #activator>
|
||||
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
|
||||
</template>
|
||||
<v-list dense class="ma-0 pa-0">
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
class="pl-6"
|
||||
@click="executeRecipeAction(action)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-list-group>
|
||||
</div>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
@ -117,11 +137,12 @@ import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
||||
import { useGroupSelf } from "~/composables/use-groups";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { ShoppingListSummary } from "~/lib/api/types/group";
|
||||
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/group";
|
||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||
|
||||
@ -134,6 +155,7 @@ export interface ContextMenuIncludes {
|
||||
print: boolean;
|
||||
printPreferences: boolean;
|
||||
share: boolean;
|
||||
recipeActions: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@ -163,6 +185,7 @@ export default defineComponent({
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
@ -347,6 +370,19 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||
|
||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||
const response = await groupRecipeActionsStore.execute(action, props.recipe);
|
||||
|
||||
if (action.actionType === "post") {
|
||||
if (!response || (response.status >= 200 && response.status < 300)) {
|
||||
alert.success(i18n.tc("events.message-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
await api.recipes.deleteOne(props.slug);
|
||||
@ -437,6 +473,8 @@ export default defineComponent({
|
||||
...toRefs(state),
|
||||
recipeRef,
|
||||
recipeRefWithScale,
|
||||
executeRecipeAction,
|
||||
recipeActions: groupRecipeActionsStore.recipeActions,
|
||||
shoppingLists,
|
||||
duplicateRecipe,
|
||||
contextMenuEventHandler,
|
||||
|
98
frontend/composables/use-group-recipe-actions.ts
Normal file
98
frontend/composables/use-group-recipe-actions.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { computed, reactive, ref } from "@nuxtjs/composition-api";
|
||||
import { useStoreActions } from "./partials/use-actions-factory";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { GroupRecipeActionOut, RecipeActionType } from "~/lib/api/types/group";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
export function useGroupRecipeActionData() {
|
||||
const data = reactive({
|
||||
id: "",
|
||||
actionType: "link" as RecipeActionType,
|
||||
title: "",
|
||||
url: "",
|
||||
});
|
||||
|
||||
function reset() {
|
||||
data.id = "";
|
||||
data.actionType = "link";
|
||||
data.title = "";
|
||||
data.url = "";
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const useGroupRecipeActions = function (
|
||||
orderBy: string | null = "title",
|
||||
orderDirection: string | null = "asc",
|
||||
) {
|
||||
const api = useUserApi();
|
||||
|
||||
async function refreshGroupRecipeActions() {
|
||||
loading.value = true;
|
||||
const { data } = await api.groupRecipeActions.getAll(1, -1, { orderBy, orderDirection });
|
||||
groupRecipeActions.value = data?.items || null;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const recipeActions = computed<GroupRecipeActionOut[] | null>(() => {
|
||||
return groupRecipeActions.value;
|
||||
});
|
||||
|
||||
function parseRecipeActionUrl(url: string, recipe: Recipe): string {
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
return url
|
||||
.replace("${url}", window.location.href)
|
||||
.replace("${id}", recipe.id || "")
|
||||
.replace("${slug}", recipe.slug || "")
|
||||
/* eslint-enable no-template-curly-in-string */
|
||||
};
|
||||
|
||||
async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | Response> {
|
||||
const url = parseRecipeActionUrl(action.url, recipe);
|
||||
|
||||
switch (action.actionType) {
|
||||
case "link":
|
||||
window.open(url, "_blank")?.focus();
|
||||
break;
|
||||
case "post":
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
// The "text/plain" content type header is used here to skip the CORS preflight request,
|
||||
// since it may fail. This is fine, since we don't care about the response, we just want
|
||||
// the request to get sent.
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: JSON.stringify(recipe),
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (!groupRecipeActions.value && !loading.value) {
|
||||
refreshGroupRecipeActions();
|
||||
};
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<GroupRecipeActionOut>(api.groupRecipeActions, groupRecipeActions, loading),
|
||||
flushStore() {
|
||||
groupRecipeActions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actions,
|
||||
execute,
|
||||
recipeActions,
|
||||
};
|
||||
};
|
@ -64,6 +64,7 @@
|
||||
"something-went-wrong": "Something Went Wrong!",
|
||||
"subscribed-events": "Subscribed Events",
|
||||
"test-message-sent": "Test Message Sent",
|
||||
"message-sent": "Message Sent",
|
||||
"new-notification": "New Notification",
|
||||
"event-notifiers": "Event Notifiers",
|
||||
"apprise-url-skipped-if-blank": "Apprise URL (skipped if blank)",
|
||||
@ -160,6 +161,7 @@
|
||||
"test": "Test",
|
||||
"themes": "Themes",
|
||||
"thursday": "Thursday",
|
||||
"title": "Title",
|
||||
"token": "Token",
|
||||
"tuesday": "Tuesday",
|
||||
"type": "Type",
|
||||
@ -582,7 +584,8 @@
|
||||
"upload-image": "Upload image",
|
||||
"screen-awake": "Keep Screen Awake",
|
||||
"remove-image": "Remove image",
|
||||
"nextStep": "Next step"
|
||||
"nextStep": "Next step",
|
||||
"recipe-actions": "Recipe Actions"
|
||||
},
|
||||
"search": {
|
||||
"advanced-search": "Advanced Search",
|
||||
@ -1001,6 +1004,12 @@
|
||||
"delete-recipes": "Delete Recipes",
|
||||
"source-unit-will-be-deleted": "Source Unit will be deleted"
|
||||
},
|
||||
"recipe-actions": {
|
||||
"recipe-actions-data": "Recipe Actions Data",
|
||||
"new-recipe-action": "New Recipe Action",
|
||||
"edit-recipe-action": "Edit Recipe Action",
|
||||
"action-type": "Action Type"
|
||||
},
|
||||
"create-alias": "Create Alias",
|
||||
"manage-aliases": "Manage Aliases",
|
||||
"seed-data": "Seed Data",
|
||||
|
@ -9,6 +9,7 @@ import { UtilsAPI } from "./user/utils";
|
||||
import { FoodAPI } from "./user/recipe-foods";
|
||||
import { UnitAPI } from "./user/recipe-units";
|
||||
import { CookbookAPI } from "./user/group-cookbooks";
|
||||
import { GroupRecipeActionsAPI } from "./user/group-recipe-actions";
|
||||
import { WebhooksAPI } from "./user/group-webhooks";
|
||||
import { RegisterAPI } from "./user/user-registration";
|
||||
import { MealPlanAPI } from "./user/group-mealplan";
|
||||
@ -36,6 +37,7 @@ export class UserApiClient {
|
||||
public foods: FoodAPI;
|
||||
public units: UnitAPI;
|
||||
public cookbooks: CookbookAPI;
|
||||
public groupRecipeActions: GroupRecipeActionsAPI;
|
||||
public groupWebhooks: WebhooksAPI;
|
||||
public register: RegisterAPI;
|
||||
public mealplans: MealPlanAPI;
|
||||
@ -65,6 +67,7 @@ export class UserApiClient {
|
||||
this.users = new UserApi(requests);
|
||||
this.groups = new GroupAPI(requests);
|
||||
this.cookbooks = new CookbookAPI(requests);
|
||||
this.groupRecipeActions = new GroupRecipeActionsAPI(requests);
|
||||
this.groupWebhooks = new WebhooksAPI(requests);
|
||||
this.register = new RegisterAPI(requests);
|
||||
this.mealplans = new MealPlanAPI(requests);
|
||||
|
@ -5,6 +5,9 @@
|
||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||
*/
|
||||
|
||||
export type RecipeActionType =
|
||||
| "link"
|
||||
| "post";
|
||||
export type WebhookType = "mealplan";
|
||||
export type SupportedMigrations =
|
||||
| "nextcloud"
|
||||
@ -26,6 +29,11 @@ export interface CreateGroupPreferences {
|
||||
recipeDisableAmount?: boolean;
|
||||
groupId: string;
|
||||
}
|
||||
export interface CreateGroupRecipeAction {
|
||||
actionType: RecipeActionType;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
export interface CreateInviteToken {
|
||||
uses: number;
|
||||
}
|
||||
@ -191,6 +199,13 @@ export interface GroupEventNotifierUpdate {
|
||||
options?: GroupEventNotifierOptions;
|
||||
id: string;
|
||||
}
|
||||
export interface GroupRecipeActionOut {
|
||||
actionType: RecipeActionType;
|
||||
title: string;
|
||||
url: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface GroupStatistics {
|
||||
totalRecipes: number;
|
||||
totalUsers: number;
|
||||
@ -230,6 +245,12 @@ export interface ReadWebhook {
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface SaveGroupRecipeAction {
|
||||
actionType: RecipeActionType;
|
||||
title: string;
|
||||
url: string;
|
||||
groupId: string;
|
||||
}
|
||||
export interface SaveInviteToken {
|
||||
usesLeft: number;
|
||||
groupId: string;
|
||||
|
14
frontend/lib/api/user/group-recipe-actions.ts
Normal file
14
frontend/lib/api/user/group-recipe-actions.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { BaseCRUDAPI } from "../base/base-clients";
|
||||
import { CreateGroupRecipeAction, GroupRecipeActionOut } from "~/lib/api/types/group";
|
||||
|
||||
const prefix = "/api";
|
||||
|
||||
const routes = {
|
||||
groupRecipeActions: `${prefix}/groups/recipe-actions`,
|
||||
groupRecipeActionsId: (id: string | number) => `${prefix}/groups/recipe-actions/${id}`,
|
||||
};
|
||||
|
||||
export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> {
|
||||
baseRoute = routes.groupRecipeActions;
|
||||
itemRoute = routes.groupRecipeActionsId;
|
||||
}
|
@ -41,6 +41,7 @@ export default defineComponent({
|
||||
const { i18n } = useContext();
|
||||
const buttonLookup: { [key: string]: string } = {
|
||||
recipes: i18n.tc("general.recipes"),
|
||||
recipeActions: i18n.tc("recipe.recipe-actions"),
|
||||
foods: i18n.tc("general.foods"),
|
||||
units: i18n.tc("general.units"),
|
||||
labels: i18n.tc("data-pages.labels.labels"),
|
||||
@ -56,6 +57,11 @@ export default defineComponent({
|
||||
text: i18n.t("general.recipes"),
|
||||
value: "new",
|
||||
to: "/group/data/recipes",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.recipe-actions"),
|
||||
value: "new",
|
||||
to: "/group/data/recipe-actions",
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
@ -92,7 +98,13 @@ export default defineComponent({
|
||||
]);
|
||||
|
||||
const buttonText = computed(() => {
|
||||
const last = route.value.path.split("/").pop();
|
||||
const last = route.value.path
|
||||
.split("/")
|
||||
.pop()
|
||||
// convert hypenated-values to camelCase
|
||||
?.replace(/-([a-z])/g, function (g) {
|
||||
return g[1].toUpperCase();
|
||||
})
|
||||
|
||||
if (last) {
|
||||
return buttonLookup[last];
|
||||
|
265
frontend/pages/group/data/recipe-actions.vue
Normal file
265
frontend/pages/group/data/recipe-actions.vue
Normal file
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Create Dialog -->
|
||||
<BaseDialog
|
||||
v-model="state.createDialog"
|
||||
:title="$t('data-pages.recipe-actions.new-recipe-action')"
|
||||
:icon="$globals.icons.primary"
|
||||
@submit="createAction"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="domNewActionForm">
|
||||
<v-text-field
|
||||
v-model="createTarget.title"
|
||||
autofocus
|
||||
:label="$t('general.title')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="createTarget.url"
|
||||
:label="$t('general.url')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-select
|
||||
v-model="createTarget.actionType"
|
||||
:items="actionTypeOptions"
|
||||
:label="$t('data-pages.recipe-actions.action-type')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<BaseDialog
|
||||
v-model="state.editDialog"
|
||||
:icon="$globals.icons.primary"
|
||||
:title="$t('data-pages.recipe-actions.edit-recipe-action')"
|
||||
:submit-text="$tc('general.save')"
|
||||
@submit="editSaveAction"
|
||||
>
|
||||
<v-card-text v-if="editTarget">
|
||||
<div class="mt-4">
|
||||
<v-text-field v-model="editTarget.title" :label="$t('general.title')"/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<v-text-field v-model="editTarget.url" :label="$t('general.url')"/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<v-select
|
||||
v-model="editTarget.actionType"
|
||||
:items="actionTypeOptions"
|
||||
:label="$t('data-pages.recipe-actions.action-type')"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="state.deleteDialog"
|
||||
:title="$tc('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
@confirm="deleteAction"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("general.confirm-delete-generic") }}
|
||||
<p v-if="deleteTarget" class="mt-4 ml-4">{{ deleteTarget.title }}</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Bulk Delete Dialog -->
|
||||
<BaseDialog
|
||||
v-model="state.bulkDeleteDialog"
|
||||
width="650px"
|
||||
:title="$tc('general.confirm')"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
color="error"
|
||||
@confirm="deleteSelected"
|
||||
>
|
||||
<v-card-text>
|
||||
<p class="h4">{{ $t('general.confirm-delete-generic-items') }}</p>
|
||||
<v-card outlined>
|
||||
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
|
||||
<template #default="{ item }">
|
||||
<v-list-item class="pb-2">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Data Table -->
|
||||
<BaseCardSectionTitle :icon="$globals.icons.primary" section :title="$tc('data-pages.recipe-actions.recipe-actions-data')"> </BaseCardSectionTitle>
|
||||
<CrudTable
|
||||
:table-config="tableConfig"
|
||||
:headers.sync="tableHeaders"
|
||||
:data="actions || []"
|
||||
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
|
||||
@delete-one="deleteEventHandler"
|
||||
@edit-one="editEventHandler"
|
||||
@delete-selected="bulkDeleteEventHandler"
|
||||
>
|
||||
<template #button-row>
|
||||
<BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton>
|
||||
</template>
|
||||
<template #item.onHand="{ item }">
|
||||
<v-icon :color="item.onHand ? 'success' : undefined">
|
||||
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</CrudTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useGroupRecipeActions, useGroupRecipeActionData } from "~/composables/use-group-recipe-actions";
|
||||
import { GroupRecipeActionOut } from "~/lib/api/types/group";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { i18n } = useContext();
|
||||
const tableConfig = {
|
||||
hideColumns: true,
|
||||
canExport: true,
|
||||
};
|
||||
const tableHeaders = [
|
||||
{
|
||||
text: i18n.t("general.id"),
|
||||
value: "id",
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.title"),
|
||||
value: "title",
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.url"),
|
||||
value: "url",
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
text: i18n.t("data-pages.recipe-actions.action-type"),
|
||||
value: "actionType",
|
||||
show: true,
|
||||
},
|
||||
];
|
||||
|
||||
const state = reactive({
|
||||
createDialog: false,
|
||||
editDialog: false,
|
||||
deleteDialog: false,
|
||||
bulkDeleteDialog: false,
|
||||
});
|
||||
|
||||
const actionData = useGroupRecipeActionData();
|
||||
const actionStore = useGroupRecipeActions(null, null);
|
||||
const actionTypeOptions = ["link", "post"]
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Create Action
|
||||
|
||||
async function createAction() {
|
||||
// @ts-ignore groupId isn't required
|
||||
await actionStore.actions.createOne({
|
||||
actionType: actionData.data.actionType,
|
||||
title: actionData.data.title,
|
||||
url: actionData.data.url,
|
||||
});
|
||||
actionData.reset();
|
||||
state.createDialog = false;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Edit Action
|
||||
|
||||
const editTarget = ref<GroupRecipeActionOut | null>(null);
|
||||
|
||||
function editEventHandler(item: GroupRecipeActionOut) {
|
||||
state.editDialog = true;
|
||||
editTarget.value = item;
|
||||
}
|
||||
|
||||
async function editSaveAction() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
await actionStore.actions.updateOne(editTarget.value);
|
||||
state.editDialog = false;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Delete Action
|
||||
|
||||
const deleteTarget = ref<GroupRecipeActionOut | null>(null);
|
||||
|
||||
function deleteEventHandler(item: GroupRecipeActionOut) {
|
||||
state.deleteDialog = true;
|
||||
deleteTarget.value = item;
|
||||
}
|
||||
|
||||
async function deleteAction() {
|
||||
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
|
||||
return;
|
||||
}
|
||||
await actionStore.actions.deleteOne(deleteTarget.value.id);
|
||||
state.deleteDialog = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bulk Delete Action
|
||||
|
||||
const bulkDeleteTarget = ref<GroupRecipeActionOut[]>([]);
|
||||
function bulkDeleteEventHandler(selection: GroupRecipeActionOut[]) {
|
||||
bulkDeleteTarget.value = selection;
|
||||
state.bulkDeleteDialog = true;
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
for (const item of bulkDeleteTarget.value) {
|
||||
await actionStore.actions.deleteOne(item.id);
|
||||
}
|
||||
bulkDeleteTarget.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
tableConfig,
|
||||
tableHeaders,
|
||||
actionTypeOptions,
|
||||
actions: actionStore.recipeActions,
|
||||
validators,
|
||||
|
||||
// create
|
||||
createTarget: actionData.data,
|
||||
createAction,
|
||||
|
||||
// edit
|
||||
editTarget,
|
||||
editEventHandler,
|
||||
editSaveAction,
|
||||
|
||||
// delete
|
||||
deleteTarget,
|
||||
deleteEventHandler,
|
||||
deleteAction,
|
||||
|
||||
// bulk delete
|
||||
bulkDeleteTarget,
|
||||
bulkDeleteEventHandler,
|
||||
deleteSelected,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -5,6 +5,7 @@ from .group import *
|
||||
from .invite_tokens import *
|
||||
from .mealplan import *
|
||||
from .preferences import *
|
||||
from .recipe_action import *
|
||||
from .report import *
|
||||
from .shopping_list import *
|
||||
from .webhooks import *
|
||||
|
@ -25,6 +25,7 @@ if TYPE_CHECKING:
|
||||
from ..users import User
|
||||
from .events import GroupEventNotifierModel
|
||||
from .exports import GroupDataExportsModel
|
||||
from .recipe_action import GroupRecipeAction
|
||||
from .report import ReportModel
|
||||
from .shopping_list import ShoppingList
|
||||
|
||||
@ -64,6 +65,7 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
GroupMealPlan, order_by="GroupMealPlan.date", **common_args
|
||||
)
|
||||
webhooks: Mapped[list[GroupWebhooksModel]] = orm.relationship(GroupWebhooksModel, **common_args)
|
||||
recipe_actions: Mapped[list["GroupRecipeAction"]] = orm.relationship("GroupRecipeAction", **common_args)
|
||||
cookbooks: Mapped[list[CookBook]] = orm.relationship(CookBook, **common_args)
|
||||
server_tasks: Mapped[list[ServerTaskModel]] = orm.relationship(ServerTaskModel, **common_args)
|
||||
data_exports: Mapped[list["GroupDataExportsModel"]] = orm.relationship("GroupDataExportsModel", **common_args)
|
||||
@ -82,6 +84,7 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
exclude={
|
||||
"users",
|
||||
"webhooks",
|
||||
"recipe_actions",
|
||||
"shopping_lists",
|
||||
"cookbooks",
|
||||
"preferences",
|
||||
|
25
mealie/db/models/group/recipe_action.py
Normal file
25
mealie/db/models/group/recipe_action.py
Normal file
@ -0,0 +1,25 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
|
||||
|
||||
class GroupRecipeAction(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_actions"
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
|
||||
group: Mapped["Group"] = relationship("Group", back_populates="recipe_actions", single_parent=True)
|
||||
|
||||
action_type: Mapped[str] = mapped_column(String, index=True)
|
||||
title: Mapped[str] = mapped_column(String, index=True)
|
||||
url: Mapped[str] = mapped_column(String)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
@ -11,6 +11,7 @@ from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.mealplan import GroupMealPlanRules
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.recipe_action import GroupRecipeAction
|
||||
from mealie.db.models.group.shopping_list import (
|
||||
ShoppingList,
|
||||
ShoppingListItem,
|
||||
@ -39,6 +40,7 @@ from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.group_recipe_action import GroupRecipeActionOut
|
||||
from mealie.schema.group.group_shopping_list import (
|
||||
ShoppingListItemOut,
|
||||
ShoppingListItemRecipeRefOut,
|
||||
@ -192,6 +194,10 @@ class AllRepositories:
|
||||
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||
return RepositoryGeneric(self.session, PK_ID, CookBook, ReadCookBook)
|
||||
|
||||
@cached_property
|
||||
def group_recipe_actions(self) -> RepositoryGeneric[GroupRecipeActionOut, GroupRecipeAction]:
|
||||
return RepositoryGeneric(self.session, PK_ID, GroupRecipeAction, GroupRecipeActionOut)
|
||||
|
||||
# ================================================================
|
||||
# Meal Plan
|
||||
|
||||
|
@ -3,6 +3,7 @@ from fastapi import APIRouter
|
||||
from . import (
|
||||
controller_cookbooks,
|
||||
controller_group_notifications,
|
||||
controller_group_recipe_actions,
|
||||
controller_group_reports,
|
||||
controller_group_self_service,
|
||||
controller_invitations,
|
||||
@ -31,4 +32,5 @@ router.include_router(controller_shopping_lists.router)
|
||||
router.include_router(controller_shopping_lists.item_router)
|
||||
router.include_router(controller_labels.router)
|
||||
router.include_router(controller_group_notifications.router)
|
||||
router.include_router(controller_group_recipe_actions.router)
|
||||
router.include_router(controller_seeder.router)
|
||||
|
55
mealie/routes/groups/controller_group_recipe_actions.py
Normal file
55
mealie/routes/groups/controller_group_recipe_actions.py
Normal file
@ -0,0 +1,55 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.base_controllers import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.schema.group.group_recipe_action import (
|
||||
CreateGroupRecipeAction,
|
||||
GroupRecipeActionOut,
|
||||
GroupRecipeActionPagination,
|
||||
SaveGroupRecipeAction,
|
||||
)
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
|
||||
router = APIRouter(prefix="/groups/recipe-actions", tags=["Groups: Recipe Actions"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class GroupRecipeActionController(BaseUserController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.group_recipe_actions.by_group(self.group_id)
|
||||
|
||||
@property
|
||||
def mixins(self):
|
||||
return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger)
|
||||
|
||||
@router.get("", response_model=GroupRecipeActionPagination)
|
||||
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
||||
response = self.repo.page_all(
|
||||
pagination=q,
|
||||
override=GroupRecipeActionOut,
|
||||
)
|
||||
|
||||
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
|
||||
return response
|
||||
|
||||
@router.post("", response_model=GroupRecipeActionOut, status_code=201)
|
||||
def create_one(self, data: CreateGroupRecipeAction):
|
||||
save = data.cast(SaveGroupRecipeAction, group_id=self.group.id)
|
||||
return self.mixins.create_one(save)
|
||||
|
||||
@router.get("/{item_id}", response_model=GroupRecipeActionOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=GroupRecipeActionOut)
|
||||
def update_one(self, item_id: UUID4, data: SaveGroupRecipeAction):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=GroupRecipeActionOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id)
|
@ -14,7 +14,18 @@ from .group_events import (
|
||||
from .group_exports import GroupDataExport
|
||||
from .group_migration import DataMigrationCreate, SupportedMigrations
|
||||
from .group_permissions import SetPermissions
|
||||
from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
|
||||
from .group_preferences import (
|
||||
CreateGroupPreferences,
|
||||
ReadGroupPreferences,
|
||||
UpdateGroupPreferences,
|
||||
)
|
||||
from .group_recipe_action import (
|
||||
CreateGroupRecipeAction,
|
||||
GroupRecipeActionOut,
|
||||
GroupRecipeActionPagination,
|
||||
GroupRecipeActionType,
|
||||
SaveGroupRecipeAction,
|
||||
)
|
||||
from .group_seeder import SeederConfig
|
||||
from .group_shopping_list import (
|
||||
ShoppingListAddRecipeParams,
|
||||
@ -41,10 +52,56 @@ from .group_shopping_list import (
|
||||
ShoppingListUpdate,
|
||||
)
|
||||
from .group_statistics import GroupStatistics, GroupStorage
|
||||
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
|
||||
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||
from .invite_token import (
|
||||
CreateInviteToken,
|
||||
EmailInitationResponse,
|
||||
EmailInvitation,
|
||||
ReadInviteToken,
|
||||
SaveInviteToken,
|
||||
)
|
||||
from .webhook import (
|
||||
CreateWebhook,
|
||||
ReadWebhook,
|
||||
SaveWebhook,
|
||||
WebhookPagination,
|
||||
WebhookType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
"GroupEventNotifierOptionsSave",
|
||||
"GroupEventNotifierOut",
|
||||
"GroupEventNotifierPrivate",
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
"CreateGroupRecipeAction",
|
||||
"GroupRecipeActionOut",
|
||||
"GroupRecipeActionPagination",
|
||||
"GroupRecipeActionType",
|
||||
"SaveGroupRecipeAction",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupDataExport",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SeederConfig",
|
||||
"CreateInviteToken",
|
||||
"EmailInitationResponse",
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"SetPermissions",
|
||||
"ShoppingListAddRecipeParams",
|
||||
"ShoppingListCreate",
|
||||
"ShoppingListItemBase",
|
||||
@ -67,6 +124,7 @@ __all__ = [
|
||||
"ShoppingListSave",
|
||||
"ShoppingListSummary",
|
||||
"ShoppingListUpdate",
|
||||
"GroupAdminUpdate",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
|
32
mealie/schema/group/group_recipe_action.py
Normal file
32
mealie/schema/group/group_recipe_action.py
Normal file
@ -0,0 +1,32 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import UUID4, ConfigDict
|
||||
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
|
||||
class GroupRecipeActionType(Enum):
|
||||
link = "link"
|
||||
post = "post"
|
||||
|
||||
|
||||
class CreateGroupRecipeAction(MealieModel):
|
||||
action_type: GroupRecipeActionType
|
||||
title: str
|
||||
url: str
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
|
||||
class SaveGroupRecipeAction(CreateGroupRecipeAction):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class GroupRecipeActionOut(SaveGroupRecipeAction):
|
||||
id: UUID4
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GroupRecipeActionPagination(PaginationBase):
|
||||
items: list[GroupRecipeActionOut]
|
@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.schema.group.group_recipe_action import CreateGroupRecipeAction, GroupRecipeActionOut, GroupRecipeActionType
|
||||
from tests.utils import api_routes, assert_derserialize
|
||||
from tests.utils.factories import random_int, random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
def new_link_action() -> CreateGroupRecipeAction:
|
||||
return CreateGroupRecipeAction(
|
||||
action_type=GroupRecipeActionType.link,
|
||||
title=random_string(),
|
||||
url=random_string(),
|
||||
)
|
||||
|
||||
|
||||
def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser):
|
||||
action_in = new_link_action()
|
||||
response = api_client.post(api_routes.groups_recipe_actions, json=action_in.model_dump(), headers=unique_user.token)
|
||||
data = assert_derserialize(response, 201)
|
||||
|
||||
action_out = GroupRecipeActionOut(**data)
|
||||
assert action_out.id
|
||||
assert str(action_out.group_id) == unique_user.group_id
|
||||
assert action_out.action_type == action_in.action_type
|
||||
assert action_out.title == action_in.title
|
||||
assert action_out.url == action_in.url
|
||||
|
||||
|
||||
def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestUser):
|
||||
expected_ids: set[str] = set()
|
||||
for _ in range(random_int(3, 5)):
|
||||
response = api_client.post(
|
||||
api_routes.groups_recipe_actions,
|
||||
json=new_link_action().model_dump(),
|
||||
headers=unique_user.token,
|
||||
)
|
||||
data = assert_derserialize(response, 201)
|
||||
expected_ids.add(data["id"])
|
||||
|
||||
response = api_client.get(api_routes.groups_recipe_actions, headers=unique_user.token)
|
||||
data = assert_derserialize(response, 200)
|
||||
fetched_ids = set(item["id"] for item in data["items"])
|
||||
for expected_id in expected_ids:
|
||||
assert expected_id in fetched_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_own_group", [True, False])
|
||||
def test_group_recipe_actions_get_one(
|
||||
api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool
|
||||
):
|
||||
action_in = new_link_action()
|
||||
response = api_client.post(api_routes.groups_recipe_actions, json=action_in.model_dump(), headers=unique_user.token)
|
||||
data = assert_derserialize(response, 201)
|
||||
expected_action_out = GroupRecipeActionOut(**data)
|
||||
|
||||
if is_own_group:
|
||||
fetch_user = unique_user
|
||||
else:
|
||||
fetch_user = g2_user
|
||||
|
||||
response = api_client.get(
|
||||
api_routes.groups_recipe_actions_item_id(expected_action_out.id), headers=fetch_user.token
|
||||
)
|
||||
if not is_own_group:
|
||||
assert response.status_code == 404
|
||||
return
|
||||
|
||||
data = assert_derserialize(response, 200)
|
||||
action_out = GroupRecipeActionOut(**data)
|
||||
assert action_out == expected_action_out
|
||||
|
||||
|
||||
def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser):
|
||||
action_in = new_link_action()
|
||||
response = api_client.post(api_routes.groups_recipe_actions, json=action_in.model_dump(), headers=unique_user.token)
|
||||
data = assert_derserialize(response, 201)
|
||||
action_id = data["id"]
|
||||
|
||||
new_title = random_string()
|
||||
data["title"] = new_title
|
||||
response = api_client.put(api_routes.groups_recipe_actions_item_id(action_id), json=data, headers=unique_user.token)
|
||||
data = assert_derserialize(response, 200)
|
||||
updated_action = GroupRecipeActionOut(**data)
|
||||
|
||||
assert updated_action.title == new_title
|
||||
|
||||
|
||||
def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser):
|
||||
action_in = new_link_action()
|
||||
response = api_client.post(api_routes.groups_recipe_actions, json=action_in.model_dump(), headers=unique_user.token)
|
||||
data = assert_derserialize(response, 201)
|
||||
action_id = data["id"]
|
||||
|
||||
response = api_client.delete(api_routes.groups_recipe_actions_item_id(action_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(api_routes.groups_recipe_actions_item_id(action_id), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
@ -87,6 +87,8 @@ groups_permissions = "/api/groups/permissions"
|
||||
"""`/api/groups/permissions`"""
|
||||
groups_preferences = "/api/groups/preferences"
|
||||
"""`/api/groups/preferences`"""
|
||||
groups_recipe_actions = "/api/groups/recipe-actions"
|
||||
"""`/api/groups/recipe-actions`"""
|
||||
groups_reports = "/api/groups/reports"
|
||||
"""`/api/groups/reports`"""
|
||||
groups_seeders_foods = "/api/groups/seeders/foods"
|
||||
@ -322,6 +324,11 @@ def groups_mealplans_rules_item_id(item_id):
|
||||
return f"{prefix}/groups/mealplans/rules/{item_id}"
|
||||
|
||||
|
||||
def groups_recipe_actions_item_id(item_id):
|
||||
"""`/api/groups/recipe-actions/{item_id}`"""
|
||||
return f"{prefix}/groups/recipe-actions/{item_id}"
|
||||
|
||||
|
||||
def groups_reports_item_id(item_id):
|
||||
"""`/api/groups/reports/{item_id}`"""
|
||||
return f"{prefix}/groups/reports/{item_id}"
|
||||
|
Loading…
x
Reference in New Issue
Block a user