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:
Michael Genson 2024-05-01 02:20:52 -05:00 committed by GitHub
parent ee87a14401
commit 3807778e2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 860 additions and 10 deletions

View File

@ -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 ###

View File

@ -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

View File

@ -70,6 +70,7 @@
print: true,
printPreferences: true,
share: loggedIn,
recipeActions: true,
}"
@print="$emit('print')"
/>

View File

@ -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,

View 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,
};
};

View File

@ -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",

View File

@ -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);

View File

@ -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;

View 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;
}

View File

@ -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];

View 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>

View File

@ -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 *

View File

@ -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",

View 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

View File

@ -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

View File

@ -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)

View 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)

View File

@ -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",

View 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]

View File

@ -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

View File

@ -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}"