mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
feat: Use Backend for Recipe Post Actions (#4163)
This commit is contained in:
parent
8bd26d2230
commit
d8dbcac196
@ -117,10 +117,10 @@ Unlike notifiers, which are event-driven notifications, Webhooks allow you to se
|
|||||||
|
|
||||||
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:
|
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
|
1. link - these actions will take you directly to an external page. Merge fields can be used to customize the URL for each recipe
|
||||||
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
|
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:
|
When using the "link" action type, 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}
|
https://www.google.com/search?q=${slug}
|
||||||
```
|
```
|
||||||
|
@ -376,7 +376,7 @@ export default defineComponent({
|
|||||||
const response = await groupRecipeActionsStore.execute(action, props.recipe);
|
const response = await groupRecipeActionsStore.execute(action, props.recipe);
|
||||||
|
|
||||||
if (action.actionType === "post") {
|
if (action.actionType === "post") {
|
||||||
if (!response || (response.status >= 200 && response.status < 300)) {
|
if (!response?.error) {
|
||||||
alert.success(i18n.tc("events.message-sent"));
|
alert.success(i18n.tc("events.message-sent"));
|
||||||
} else {
|
} else {
|
||||||
alert.error(i18n.tc("events.something-went-wrong"));
|
alert.error(i18n.tc("events.something-went-wrong"));
|
||||||
|
@ -2,6 +2,7 @@ import { computed, reactive, ref } from "@nuxtjs/composition-api";
|
|||||||
import { useStoreActions } from "./partials/use-actions-factory";
|
import { useStoreActions } from "./partials/use-actions-factory";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household";
|
import { GroupRecipeActionOut, GroupRecipeActionType } from "~/lib/api/types/household";
|
||||||
|
import { RequestResponse } from "~/lib/api/types/non-generated";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null);
|
const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null);
|
||||||
@ -54,26 +55,15 @@ export const useGroupRecipeActions = function (
|
|||||||
/* eslint-enable no-template-curly-in-string */
|
/* eslint-enable no-template-curly-in-string */
|
||||||
};
|
};
|
||||||
|
|
||||||
async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | Response> {
|
async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | RequestResponse<unknown>> {
|
||||||
const url = parseRecipeActionUrl(action.url, recipe);
|
const url = parseRecipeActionUrl(action.url, recipe);
|
||||||
|
|
||||||
switch (action.actionType) {
|
switch (action.actionType) {
|
||||||
case "link":
|
case "link":
|
||||||
window.open(url, "_blank")?.focus();
|
window.open(url, "_blank")?.focus();
|
||||||
break;
|
return;
|
||||||
case "post":
|
case "post":
|
||||||
return await fetch(url, {
|
return await api.groupRecipeActions.triggerAction(action.id, recipe.slug || "");
|
||||||
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -165,6 +165,10 @@ export interface GroupRecipeActionOut {
|
|||||||
householdId: string;
|
householdId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
export interface GroupRecipeActionPayload {
|
||||||
|
action: GroupRecipeActionOut;
|
||||||
|
content: unknown;
|
||||||
|
}
|
||||||
export interface HouseholdCreate {
|
export interface HouseholdCreate {
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -6,9 +6,14 @@ const prefix = "/api";
|
|||||||
const routes = {
|
const routes = {
|
||||||
groupRecipeActions: `${prefix}/households/recipe-actions`,
|
groupRecipeActions: `${prefix}/households/recipe-actions`,
|
||||||
groupRecipeActionsId: (id: string | number) => `${prefix}/households/recipe-actions/${id}`,
|
groupRecipeActionsId: (id: string | number) => `${prefix}/households/recipe-actions/${id}`,
|
||||||
|
groupRecipeActionsIdTriggerRecipeSlug: (id: string | number, recipeSlug: string) => `${prefix}/households/recipe-actions/${id}/trigger/${recipeSlug}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> {
|
export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> {
|
||||||
baseRoute = routes.groupRecipeActions;
|
baseRoute = routes.groupRecipeActions;
|
||||||
itemRoute = routes.groupRecipeActionsId;
|
itemRoute = routes.groupRecipeActionsId;
|
||||||
|
|
||||||
|
async triggerAction(id: string | number, recipeSlug: string) {
|
||||||
|
return await this.requests.post(routes.groupRecipeActionsIdTriggerRecipeSlug(id, recipeSlug), {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import requests
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.core.exceptions import NoEntryFound
|
||||||
from mealie.routes._base.base_controllers import BaseUserController
|
from mealie.routes._base.base_controllers import BaseUserController
|
||||||
from mealie.routes._base.controller import controller
|
from mealie.routes._base.controller import controller
|
||||||
from mealie.routes._base.mixins import HttpRepo
|
from mealie.routes._base.mixins import HttpRepo
|
||||||
@ -10,9 +13,13 @@ from mealie.schema.household.group_recipe_action import (
|
|||||||
CreateGroupRecipeAction,
|
CreateGroupRecipeAction,
|
||||||
GroupRecipeActionOut,
|
GroupRecipeActionOut,
|
||||||
GroupRecipeActionPagination,
|
GroupRecipeActionPagination,
|
||||||
|
GroupRecipeActionPayload,
|
||||||
|
GroupRecipeActionType,
|
||||||
SaveGroupRecipeAction,
|
SaveGroupRecipeAction,
|
||||||
)
|
)
|
||||||
|
from mealie.schema.response import ErrorResponse
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
|
from mealie.services.recipe.recipe_service import RecipeService
|
||||||
|
|
||||||
router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"])
|
router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"])
|
||||||
|
|
||||||
@ -27,6 +34,9 @@ class GroupRecipeActionController(BaseUserController):
|
|||||||
def mixins(self):
|
def mixins(self):
|
||||||
return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger)
|
return HttpRepo[CreateGroupRecipeAction, GroupRecipeActionOut, SaveGroupRecipeAction](self.repo, self.logger)
|
||||||
|
|
||||||
|
# ==================================================================================================================
|
||||||
|
# CRUD
|
||||||
|
|
||||||
@router.get("", response_model=GroupRecipeActionPagination)
|
@router.get("", response_model=GroupRecipeActionPagination)
|
||||||
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
||||||
response = self.repo.page_all(
|
response = self.repo.page_all(
|
||||||
@ -53,3 +63,40 @@ class GroupRecipeActionController(BaseUserController):
|
|||||||
@router.delete("/{item_id}", response_model=GroupRecipeActionOut)
|
@router.delete("/{item_id}", response_model=GroupRecipeActionOut)
|
||||||
def delete_one(self, item_id: UUID4):
|
def delete_one(self, item_id: UUID4):
|
||||||
return self.mixins.delete_one(item_id)
|
return self.mixins.delete_one(item_id)
|
||||||
|
|
||||||
|
# ==================================================================================================================
|
||||||
|
# Actions
|
||||||
|
|
||||||
|
@router.post("/{item_id}/trigger/{recipe_slug}", status_code=202)
|
||||||
|
def trigger_action(self, item_id: UUID4, recipe_slug: str, bg_tasks: BackgroundTasks) -> None:
|
||||||
|
recipe_action = self.repos.group_recipe_actions.get_one(item_id)
|
||||||
|
if not recipe_action:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=ErrorResponse.respond(message="Not found."),
|
||||||
|
)
|
||||||
|
|
||||||
|
if recipe_action.action_type == GroupRecipeActionType.post.value:
|
||||||
|
task_action = requests.post
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ErrorResponse.respond(message=f'Cannot trigger action type "{recipe_action.action_type}".'),
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_service = RecipeService(self.repos, self.user, self.household, translator=self.translator)
|
||||||
|
try:
|
||||||
|
recipe = recipe_service.get_one(recipe_slug)
|
||||||
|
except NoEntryFound as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=ErrorResponse.respond(message="Not found."),
|
||||||
|
) from e
|
||||||
|
|
||||||
|
payload = GroupRecipeActionPayload(action=recipe_action, content=recipe)
|
||||||
|
bg_tasks.add_task(
|
||||||
|
task_action,
|
||||||
|
url=recipe_action.url,
|
||||||
|
json=jsonable_encoder(payload.model_dump()),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
@ -14,6 +14,7 @@ from .group_recipe_action import (
|
|||||||
CreateGroupRecipeAction,
|
CreateGroupRecipeAction,
|
||||||
GroupRecipeActionOut,
|
GroupRecipeActionOut,
|
||||||
GroupRecipeActionPagination,
|
GroupRecipeActionPagination,
|
||||||
|
GroupRecipeActionPayload,
|
||||||
GroupRecipeActionType,
|
GroupRecipeActionType,
|
||||||
SaveGroupRecipeAction,
|
SaveGroupRecipeAction,
|
||||||
)
|
)
|
||||||
@ -75,6 +76,7 @@ __all__ = [
|
|||||||
"CreateGroupRecipeAction",
|
"CreateGroupRecipeAction",
|
||||||
"GroupRecipeActionOut",
|
"GroupRecipeActionOut",
|
||||||
"GroupRecipeActionPagination",
|
"GroupRecipeActionPagination",
|
||||||
|
"GroupRecipeActionPayload",
|
||||||
"GroupRecipeActionType",
|
"GroupRecipeActionType",
|
||||||
"SaveGroupRecipeAction",
|
"SaveGroupRecipeAction",
|
||||||
"CreateWebhook",
|
"CreateWebhook",
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import UUID4, ConfigDict
|
from pydantic import UUID4, ConfigDict
|
||||||
|
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
from mealie.schema.response.pagination import PaginationBase
|
from mealie.schema.response.pagination import PaginationBase
|
||||||
|
|
||||||
|
# ==================================================================================================================
|
||||||
|
# CRUD
|
||||||
|
|
||||||
|
|
||||||
class GroupRecipeActionType(Enum):
|
class GroupRecipeActionType(Enum):
|
||||||
link = "link"
|
link = "link"
|
||||||
@ -31,3 +35,12 @@ class GroupRecipeActionOut(SaveGroupRecipeAction):
|
|||||||
|
|
||||||
class GroupRecipeActionPagination(PaginationBase):
|
class GroupRecipeActionPagination(PaginationBase):
|
||||||
items: list[GroupRecipeActionOut]
|
items: list[GroupRecipeActionOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================================================================================================================
|
||||||
|
# Actions
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRecipeActionPayload(MealieModel):
|
||||||
|
action: GroupRecipeActionOut
|
||||||
|
content: Any
|
||||||
|
@ -1,26 +1,51 @@
|
|||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from mealie.schema.household.group_recipe_action import (
|
from mealie.schema.household.group_recipe_action import (
|
||||||
CreateGroupRecipeAction,
|
CreateGroupRecipeAction,
|
||||||
GroupRecipeActionOut,
|
GroupRecipeActionOut,
|
||||||
GroupRecipeActionType,
|
GroupRecipeActionType,
|
||||||
|
SaveGroupRecipeAction,
|
||||||
)
|
)
|
||||||
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
from tests.utils import api_routes, assert_deserialize
|
from tests.utils import api_routes, assert_deserialize
|
||||||
from tests.utils.factories import random_int, random_string
|
from tests.utils.factories import random_int, random_string
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
def new_link_action() -> CreateGroupRecipeAction:
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_requests_post(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(requests, "post", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_action(action_type: GroupRecipeActionType = GroupRecipeActionType.link) -> CreateGroupRecipeAction:
|
||||||
return CreateGroupRecipeAction(
|
return CreateGroupRecipeAction(
|
||||||
action_type=GroupRecipeActionType.link,
|
action_type=action_type,
|
||||||
title=random_string(),
|
title=random_string(),
|
||||||
url=random_string(),
|
url=random_string(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_action(
|
||||||
|
user: TestUser, action_type: GroupRecipeActionType = GroupRecipeActionType.link
|
||||||
|
) -> SaveGroupRecipeAction:
|
||||||
|
action = create_action(action_type)
|
||||||
|
return action.cast(SaveGroupRecipeAction, group_id=UUID(user.group_id), household_id=UUID(user.household_id))
|
||||||
|
|
||||||
|
|
||||||
|
def new_recipe(user: TestUser) -> Recipe:
|
||||||
|
return Recipe(
|
||||||
|
user_id=user.user_id,
|
||||||
|
group_id=UUID(user.group_id),
|
||||||
|
name=random_string(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser):
|
def test_group_recipe_actions_create_one(api_client: TestClient, unique_user: TestUser):
|
||||||
action_in = new_link_action()
|
action_in = create_action()
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
api_routes.households_recipe_actions,
|
api_routes.households_recipe_actions,
|
||||||
json=action_in.model_dump(),
|
json=action_in.model_dump(),
|
||||||
@ -42,7 +67,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU
|
|||||||
for _ in range(random_int(3, 5)):
|
for _ in range(random_int(3, 5)):
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
api_routes.households_recipe_actions,
|
api_routes.households_recipe_actions,
|
||||||
json=new_link_action().model_dump(),
|
json=create_action().model_dump(),
|
||||||
headers=unique_user.token,
|
headers=unique_user.token,
|
||||||
)
|
)
|
||||||
data = assert_deserialize(response, 201)
|
data = assert_deserialize(response, 201)
|
||||||
@ -59,7 +84,7 @@ def test_group_recipe_actions_get_all(api_client: TestClient, unique_user: TestU
|
|||||||
def test_group_recipe_actions_get_one(
|
def test_group_recipe_actions_get_one(
|
||||||
api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool
|
api_client: TestClient, unique_user: TestUser, g2_user: TestUser, is_own_group: bool
|
||||||
):
|
):
|
||||||
action_in = new_link_action()
|
action_in = create_action()
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
api_routes.households_recipe_actions,
|
api_routes.households_recipe_actions,
|
||||||
json=action_in.model_dump(),
|
json=action_in.model_dump(),
|
||||||
@ -87,7 +112,7 @@ def test_group_recipe_actions_get_one(
|
|||||||
|
|
||||||
|
|
||||||
def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser):
|
def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: TestUser):
|
||||||
action_in = new_link_action()
|
action_in = create_action()
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
api_routes.households_recipe_actions,
|
api_routes.households_recipe_actions,
|
||||||
json=action_in.model_dump(),
|
json=action_in.model_dump(),
|
||||||
@ -110,7 +135,7 @@ def test_group_recipe_actions_update_one(api_client: TestClient, unique_user: Te
|
|||||||
|
|
||||||
|
|
||||||
def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser):
|
def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: TestUser):
|
||||||
action_in = new_link_action()
|
action_in = create_action()
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
api_routes.households_recipe_actions,
|
api_routes.households_recipe_actions,
|
||||||
json=action_in.model_dump(),
|
json=action_in.model_dump(),
|
||||||
@ -124,3 +149,46 @@ def test_group_recipe_actions_delete_one(api_client: TestClient, unique_user: Te
|
|||||||
|
|
||||||
response = api_client.get(api_routes.households_recipe_actions_item_id(action_id), headers=unique_user.token)
|
response = api_client.get(api_routes.households_recipe_actions_item_id(action_id), headers=unique_user.token)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing_action", [True, False])
|
||||||
|
@pytest.mark.parametrize("missing_recipe", [True, False])
|
||||||
|
def test_group_recipe_actions_trigger_post(
|
||||||
|
api_client: TestClient, unique_user: TestUser, missing_action: bool, missing_recipe: bool
|
||||||
|
):
|
||||||
|
if missing_action:
|
||||||
|
action_id = uuid4()
|
||||||
|
else:
|
||||||
|
recipe_action = unique_user.repos.group_recipe_actions.create(
|
||||||
|
save_action(unique_user, GroupRecipeActionType.post)
|
||||||
|
)
|
||||||
|
action_id = recipe_action.id
|
||||||
|
|
||||||
|
if missing_recipe:
|
||||||
|
recipe_slug = random_string()
|
||||||
|
else:
|
||||||
|
recipe = unique_user.repos.recipes.create(new_recipe(unique_user))
|
||||||
|
recipe_slug = recipe.slug
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.households_recipe_actions_item_id_trigger_recipe_slug(action_id, recipe_slug),
|
||||||
|
headers=unique_user.token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_action or missing_recipe:
|
||||||
|
assert response.status_code == 404
|
||||||
|
else:
|
||||||
|
# we don't test if the request was actually made, just that the endpoint was hit and accepted
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_recipe_actions_trigger_invalid_type(api_client: TestClient, unique_user: TestUser):
|
||||||
|
recipe_action = unique_user.repos.group_recipe_actions.create(save_action(unique_user, GroupRecipeActionType.link))
|
||||||
|
recipe = unique_user.repos.recipes.create(new_recipe(unique_user))
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.households_recipe_actions_item_id_trigger_recipe_slug(recipe_action.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
@ -332,6 +332,11 @@ def households_recipe_actions_item_id(item_id):
|
|||||||
return f"{prefix}/households/recipe-actions/{item_id}"
|
return f"{prefix}/households/recipe-actions/{item_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def households_recipe_actions_item_id_trigger_recipe_slug(item_id, recipe_slug):
|
||||||
|
"""`/api/households/recipe-actions/{item_id}/trigger/{recipe_slug}`"""
|
||||||
|
return f"{prefix}/households/recipe-actions/{item_id}/trigger/{recipe_slug}"
|
||||||
|
|
||||||
|
|
||||||
def households_shopping_items_item_id(item_id):
|
def households_shopping_items_item_id(item_id):
|
||||||
"""`/api/households/shopping/items/{item_id}`"""
|
"""`/api/households/shopping/items/{item_id}`"""
|
||||||
return f"{prefix}/households/shopping/items/{item_id}"
|
return f"{prefix}/households/shopping/items/{item_id}"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user