From 3807778e2fbd1bbb9844f0a24b01699c745fe99e Mon Sep 17 00:00:00 2001
From: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Date: Wed, 1 May 2024 02:20:52 -0500
Subject: [PATCH] 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>
---
...0_7788478a0338_add_group_recipe_actions.py | 52 ++++
.../documentation/getting-started/features.md | 54 +++-
docs/docs/overrides/api.html | 2 +-
.../Domain/Recipe/RecipeActionMenu.vue | 1 +
.../Domain/Recipe/RecipeContextMenu.vue | 40 ++-
.../composables/use-group-recipe-actions.ts | 98 +++++++
frontend/lang/messages/en-US.json | 11 +-
frontend/lib/api/client-user.ts | 3 +
frontend/lib/api/types/group.ts | 21 ++
frontend/lib/api/user/group-recipe-actions.ts | 14 +
frontend/pages/group/data.vue | 14 +-
frontend/pages/group/data/recipe-actions.vue | 265 ++++++++++++++++++
mealie/db/models/group/__init__.py | 1 +
mealie/db/models/group/group.py | 3 +
mealie/db/models/group/recipe_action.py | 25 ++
mealie/repos/repository_factory.py | 6 +
mealie/routes/groups/__init__.py | 2 +
.../groups/controller_group_recipe_actions.py | 55 ++++
mealie/schema/group/__init__.py | 64 ++++-
mealie/schema/group/group_recipe_action.py | 32 +++
.../test_group_recipe_actions.py | 100 +++++++
tests/utils/api_routes/__init__.py | 7 +
22 files changed, 860 insertions(+), 10 deletions(-)
create mode 100644 alembic/versions/2024-04-07-01.05.20_7788478a0338_add_group_recipe_actions.py
create mode 100644 frontend/composables/use-group-recipe-actions.ts
create mode 100644 frontend/lib/api/user/group-recipe-actions.ts
create mode 100644 frontend/pages/group/data/recipe-actions.vue
create mode 100644 mealie/db/models/group/recipe_action.py
create mode 100644 mealie/routes/groups/controller_group_recipe_actions.py
create mode 100644 mealie/schema/group/group_recipe_action.py
create mode 100644 tests/integration_tests/user_group_tests/test_group_recipe_actions.py
diff --git a/alembic/versions/2024-04-07-01.05.20_7788478a0338_add_group_recipe_actions.py b/alembic/versions/2024-04-07-01.05.20_7788478a0338_add_group_recipe_actions.py
new file mode 100644
index 000000000..1a7162c39
--- /dev/null
+++ b/alembic/versions/2024-04-07-01.05.20_7788478a0338_add_group_recipe_actions.py
@@ -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 ###
diff --git a/docs/docs/documentation/getting-started/features.md b/docs/docs/documentation/getting-started/features.md
index 463bf18ac..75c4b590a 100644
--- a/docs/docs/documentation/getting-started/features.md
+++ b/docs/docs/documentation/getting-started/features.md
@@ -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
diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html
index 1a9cf805c..ec16cb46c 100644
--- a/docs/docs/overrides/api.html
+++ b/docs/docs/overrides/api.html
@@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue
index 694a23d36..adb0b0b09 100644
--- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue
+++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue
@@ -70,6 +70,7 @@
print: true,
printPreferences: true,
share: loggedIn,
+ recipeActions: true,
}"
@print="$emit('print')"
/>
diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue
index dd96bb137..3e1e27edc 100644
--- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue
+++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue
@@ -105,6 +105,26 @@
{{ item.title }}
+
+
+
+
+ {{ $tc("recipe.recipe-actions") }}
+
+
+
+
+ {{ action.title }}
+
+
+
+
+
@@ -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,
diff --git a/frontend/composables/use-group-recipe-actions.ts b/frontend/composables/use-group-recipe-actions.ts
new file mode 100644
index 000000000..6605baaf2
--- /dev/null
+++ b/frontend/composables/use-group-recipe-actions.ts
@@ -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(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(() => {
+ 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 {
+ 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(api.groupRecipeActions, groupRecipeActions, loading),
+ flushStore() {
+ groupRecipeActions.value = [];
+ }
+ }
+
+ return {
+ actions,
+ execute,
+ recipeActions,
+ };
+};
diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json
index 8dabe5b82..47c2e41e8 100644
--- a/frontend/lang/messages/en-US.json
+++ b/frontend/lang/messages/en-US.json
@@ -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",
diff --git a/frontend/lib/api/client-user.ts b/frontend/lib/api/client-user.ts
index 6c7e1dd85..cc40f42f3 100644
--- a/frontend/lib/api/client-user.ts
+++ b/frontend/lib/api/client-user.ts
@@ -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);
diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts
index f9bb8c9ec..a824019ca 100644
--- a/frontend/lib/api/types/group.ts
+++ b/frontend/lib/api/types/group.ts
@@ -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;
diff --git a/frontend/lib/api/user/group-recipe-actions.ts b/frontend/lib/api/user/group-recipe-actions.ts
new file mode 100644
index 000000000..b1288bf1b
--- /dev/null
+++ b/frontend/lib/api/user/group-recipe-actions.ts
@@ -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 {
+ baseRoute = routes.groupRecipeActions;
+ itemRoute = routes.groupRecipeActionsId;
+ }
diff --git a/frontend/pages/group/data.vue b/frontend/pages/group/data.vue
index f48b59a9c..2dfce3531 100644
--- a/frontend/pages/group/data.vue
+++ b/frontend/pages/group/data.vue
@@ -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];
diff --git a/frontend/pages/group/data/recipe-actions.vue b/frontend/pages/group/data/recipe-actions.vue
new file mode 100644
index 000000000..6389f9e28
--- /dev/null
+++ b/frontend/pages/group/data/recipe-actions.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("general.confirm-delete-generic") }}
+ {{ deleteTarget.title }}
+
+
+
+
+
+
+ {{ $t('general.confirm-delete-generic-items') }}
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("general.create") }}
+
+
+
+ {{ item.onHand ? $globals.icons.check : $globals.icons.close }}
+
+
+
+
+
+
+
diff --git a/mealie/db/models/group/__init__.py b/mealie/db/models/group/__init__.py
index 5053f5c5f..84afb502c 100644
--- a/mealie/db/models/group/__init__.py
+++ b/mealie/db/models/group/__init__.py
@@ -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 *
diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py
index 8d2691356..d344ce58b 100644
--- a/mealie/db/models/group/group.py
+++ b/mealie/db/models/group/group.py
@@ -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",
diff --git a/mealie/db/models/group/recipe_action.py b/mealie/db/models/group/recipe_action.py
new file mode 100644
index 000000000..75704461e
--- /dev/null
+++ b/mealie/db/models/group/recipe_action.py
@@ -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
diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py
index 701de1587..5116f7ab7 100644
--- a/mealie/repos/repository_factory.py
+++ b/mealie/repos/repository_factory.py
@@ -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
diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py
index 52459414d..2aa62bf8f 100644
--- a/mealie/routes/groups/__init__.py
+++ b/mealie/routes/groups/__init__.py
@@ -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)
diff --git a/mealie/routes/groups/controller_group_recipe_actions.py b/mealie/routes/groups/controller_group_recipe_actions.py
new file mode 100644
index 000000000..a43bd9ae8
--- /dev/null
+++ b/mealie/routes/groups/controller_group_recipe_actions.py
@@ -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)
diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py
index 7dce2767c..84e950b3f 100644
--- a/mealie/schema/group/__init__.py
+++ b/mealie/schema/group/__init__.py
@@ -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",
diff --git a/mealie/schema/group/group_recipe_action.py b/mealie/schema/group/group_recipe_action.py
new file mode 100644
index 000000000..95e8800ce
--- /dev/null
+++ b/mealie/schema/group/group_recipe_action.py
@@ -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]
diff --git a/tests/integration_tests/user_group_tests/test_group_recipe_actions.py b/tests/integration_tests/user_group_tests/test_group_recipe_actions.py
new file mode 100644
index 000000000..036274aef
--- /dev/null
+++ b/tests/integration_tests/user_group_tests/test_group_recipe_actions.py
@@ -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
diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py
index 65b7e3544..52184d91f 100644
--- a/tests/utils/api_routes/__init__.py
+++ b/tests/utils/api_routes/__init__.py
@@ -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}"