mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-11-11 14:32:33 -05:00
fix: Refactor Recipe Zip File Flow (#6170)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -377,11 +377,14 @@ async function deleteRecipe() {
|
|||||||
const download = useDownloader();
|
const download = useDownloader();
|
||||||
|
|
||||||
async function handleDownloadEvent() {
|
async function handleDownloadEvent() {
|
||||||
const { data } = await api.recipes.getZipToken(props.slug);
|
const { data: shareToken } = await api.recipes.share.createOne({ recipeId: props.recipeId });
|
||||||
|
if (!shareToken) {
|
||||||
if (data) {
|
console.error("No share token received");
|
||||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
download(api.recipes.share.getZipRedirectUrl(shareToken.id), `${props.slug}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecipeToPlan() {
|
async function addRecipeToPlan() {
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export function useDownloader() {
|
export function useDownloader() {
|
||||||
function download(url: string, filename: string) {
|
function download(url: string, filename: string) {
|
||||||
useFetch(url, {
|
useFetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
onResponse({ response }) {
|
onResponse({ response }) {
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Download failed", response);
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response._data]));
|
const url = window.URL.createObjectURL(new Blob([response._data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
@@ -470,9 +470,6 @@ export interface RecipeToolSave {
|
|||||||
householdsWithTool?: string[];
|
householdsWithTool?: string[];
|
||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
export interface RecipeZipTokenResponse {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
export interface SaveIngredientFood {
|
export interface SaveIngredientFood {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ const prefix = "/api";
|
|||||||
const routes = {
|
const routes = {
|
||||||
shareToken: `${prefix}/shared/recipes`,
|
shareToken: `${prefix}/shared/recipes`,
|
||||||
shareTokenId: (id: string) => `${prefix}/shared/recipes/${id}`,
|
shareTokenId: (id: string) => `${prefix}/shared/recipes/${id}`,
|
||||||
|
shareTokenIdZip: (id: string) => `${prefix}/recipes/shared/${id}/zip`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RecipeShareApi extends BaseCRUDAPI<RecipeShareTokenCreate, RecipeShareToken> {
|
export class RecipeShareApi extends BaseCRUDAPI<RecipeShareTokenCreate, RecipeShareToken> {
|
||||||
baseRoute: string = routes.shareToken;
|
baseRoute: string = routes.shareToken;
|
||||||
itemRoute = routes.shareTokenId;
|
itemRoute = routes.shareTokenId;
|
||||||
|
|
||||||
|
getZipRedirectUrl(tokenId: string) {
|
||||||
|
return routes.shareTokenIdZip(tokenId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
CreateRecipeByUrlBulk,
|
CreateRecipeByUrlBulk,
|
||||||
ParsedIngredient,
|
ParsedIngredient,
|
||||||
UpdateImageResponse,
|
UpdateImageResponse,
|
||||||
RecipeZipTokenResponse,
|
|
||||||
RecipeLastMade,
|
RecipeLastMade,
|
||||||
RecipeSuggestionQuery,
|
RecipeSuggestionQuery,
|
||||||
RecipeSuggestionResponse,
|
RecipeSuggestionResponse,
|
||||||
@@ -46,8 +45,6 @@ const routes = {
|
|||||||
recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
|
recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
|
||||||
|
|
||||||
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
|
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
|
||||||
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
|
|
||||||
recipesRecipeSlugExportZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports/zip`,
|
|
||||||
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
|
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
|
||||||
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
|
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
|
||||||
|
|
||||||
@@ -182,14 +179,6 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
|||||||
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
|
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getZipToken(recipeSlug: string) {
|
|
||||||
return await this.requests.post<RecipeZipTokenResponse>(routes.recipesRecipeSlugExport(recipeSlug), {});
|
|
||||||
}
|
|
||||||
|
|
||||||
getZipRedirectUrl(recipeSlug: string, token: string) {
|
|
||||||
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMany(payload: Recipe[]) {
|
async updateMany(payload: Recipe[]) {
|
||||||
return await this.requests.put<Recipe[]>(routes.recipesBase, payload);
|
return await this.requests.put<Recipe[]>(routes.recipesBase, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
setup() {
|
setup() {
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
error: false,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
@@ -54,15 +54,6 @@ export default defineNuxtComponent({
|
|||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
|
|
||||||
if (response?.status !== 201) {
|
|
||||||
state.error = true;
|
|
||||||
state.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRecipeZip = ref<File | null>(null);
|
const newRecipeZip = ref<File | null>(null);
|
||||||
const newRecipeZipFileName = "archive";
|
const newRecipeZipFileName = "archive";
|
||||||
|
|
||||||
@@ -73,8 +64,21 @@ export default defineNuxtComponent({
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(newRecipeZipFileName, newRecipeZip.value);
|
formData.append(newRecipeZipFileName, newRecipeZip.value);
|
||||||
|
|
||||||
const { response } = await api.upload.file("/api/recipes/create/zip", formData);
|
try {
|
||||||
handleResponse(response);
|
const response = await api.upload.file("/api/recipes/create/zip", formData);
|
||||||
|
if (response?.status !== 201) {
|
||||||
|
throw new Error("Failed to upload zip");
|
||||||
|
}
|
||||||
|
router.push(`/g/${groupSlug.value}/r/${response.data}`);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const i18n = useGlobalI18n();
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -176,36 +176,6 @@ def validate_file_token(token: str | None = None) -> Path:
|
|||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
def validate_recipe_token(token: str | None = None) -> str:
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
token (Optional[str], optional): _description_. Defaults to None.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 400 Bad Request when no token or the recipe doesn't exist
|
|
||||||
HTTPException: 401 PyJWTError when token is invalid
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: token data
|
|
||||||
"""
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
|
|
||||||
slug: str | None = payload.get("slug")
|
|
||||||
except PyJWTError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="could not validate file token",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
if slug is None:
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
return slug
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
|
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
|
||||||
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ def create_file_token(file_path: Path) -> str:
|
|||||||
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
||||||
|
|
||||||
|
|
||||||
def create_recipe_slug_token(file_path: str | Path) -> str:
|
|
||||||
token_data = {"slug": str(file_path)}
|
|
||||||
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
|
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
|
||||||
return get_hasher().hash(password)
|
return get_hasher().hash(password)
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from zipfile import ZipFile
|
|
||||||
|
|
||||||
from fastapi import (
|
|
||||||
HTTPException,
|
|
||||||
)
|
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
from mealie.core.dependencies import (
|
from mealie.core.dependencies import get_temporary_path
|
||||||
get_temporary_path,
|
|
||||||
get_temporary_zip_path,
|
|
||||||
validate_recipe_token,
|
|
||||||
)
|
|
||||||
from mealie.core.security import create_recipe_slug_token
|
|
||||||
from mealie.routes._base import controller
|
from mealie.routes._base import controller
|
||||||
from mealie.routes._base.routers import UserAPIRouter
|
from mealie.routes._base.routers import UserAPIRouter
|
||||||
from mealie.schema.recipe import Recipe, RecipeImageTypes
|
|
||||||
from mealie.schema.recipe.request_helpers import (
|
|
||||||
RecipeZipTokenResponse,
|
|
||||||
)
|
|
||||||
from mealie.services.recipe.template_service import TemplateService
|
from mealie.services.recipe.template_service import TemplateService
|
||||||
|
|
||||||
from ._base import BaseRecipeController, FormatResponse
|
from ._base import BaseRecipeController, FormatResponse
|
||||||
@@ -35,11 +22,6 @@ class RecipeExportController(BaseRecipeController):
|
|||||||
def get_recipe_formats_and_templates(self):
|
def get_recipe_formats_and_templates(self):
|
||||||
return TemplateService().templates
|
return TemplateService().templates
|
||||||
|
|
||||||
@router.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
|
|
||||||
def get_recipe_zip_token(self, slug: str):
|
|
||||||
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
|
|
||||||
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
|
|
||||||
|
|
||||||
@router.get("/{slug}/exports", response_class=FileResponse)
|
@router.get("/{slug}/exports", response_class=FileResponse)
|
||||||
def get_recipe_as_format(self, slug: str, template_name: str):
|
def get_recipe_as_format(self, slug: str, template_name: str):
|
||||||
"""
|
"""
|
||||||
@@ -53,24 +35,3 @@ class RecipeExportController(BaseRecipeController):
|
|||||||
recipe = self.mixins.get_one(slug)
|
recipe = self.mixins.get_one(slug)
|
||||||
file = self.service.render_template(recipe, temp_path, template_name)
|
file = self.service.render_template(recipe, temp_path, template_name)
|
||||||
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
|
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
|
||||||
|
|
||||||
@router.get("/{slug}/exports/zip")
|
|
||||||
def get_recipe_as_zip(self, slug: str, token: str):
|
|
||||||
"""Get a Recipe and Its Original Image as a Zip File"""
|
|
||||||
with get_temporary_zip_path(auto_unlink=False) as temp_path:
|
|
||||||
validated_slug = validate_recipe_token(token)
|
|
||||||
|
|
||||||
if validated_slug != slug:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid Slug")
|
|
||||||
|
|
||||||
recipe: Recipe = self.mixins.get_one(validated_slug)
|
|
||||||
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
|
||||||
with ZipFile(temp_path, "w") as myzip:
|
|
||||||
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
|
|
||||||
|
|
||||||
if image_asset.is_file():
|
|
||||||
myzip.write(image_asset, arcname=image_asset.name)
|
|
||||||
|
|
||||||
return FileResponse(
|
|
||||||
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
|
from mealie.core.dependencies import get_temporary_zip_path
|
||||||
from mealie.core.root_logger import get_logger
|
from mealie.core.root_logger import get_logger
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.repos.all_repositories import get_repositories
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
|
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
|
||||||
from mealie.schema.response import ErrorResponse
|
from mealie.schema.response import ErrorResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -14,7 +20,7 @@ logger = get_logger()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/shared/{token_id}", response_model=Recipe)
|
@router.get("/shared/{token_id}", response_model=Recipe)
|
||||||
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)):
|
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)) -> Recipe:
|
||||||
db = get_repositories(session, group_id=None, household_id=None)
|
db = get_repositories(session, group_id=None, household_id=None)
|
||||||
|
|
||||||
token_summary = db.recipe_share_tokens.get_one(token_id)
|
token_summary = db.recipe_share_tokens.get_one(token_id)
|
||||||
@@ -31,3 +37,22 @@ def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_sessi
|
|||||||
raise HTTPException(status_code=404, detail=ErrorResponse.respond("Token Not Found"))
|
raise HTTPException(status_code=404, detail=ErrorResponse.respond("Token Not Found"))
|
||||||
|
|
||||||
return token_summary.recipe
|
return token_summary.recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared/{token_id}/zip", response_class=FileResponse)
|
||||||
|
def get_shared_recipe_as_zip(token_id: UUID4, session: Session = Depends(generate_session)) -> FileResponse:
|
||||||
|
"""Get a recipe and its original image as a Zip file"""
|
||||||
|
|
||||||
|
recipe = get_shared_recipe(token_id=token_id, session=session)
|
||||||
|
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
|
||||||
|
|
||||||
|
with get_temporary_zip_path(auto_unlink=False) as temp_path:
|
||||||
|
with ZipFile(temp_path, "w") as myzip:
|
||||||
|
myzip.writestr(f"{recipe.slug}.json", recipe.model_dump_json())
|
||||||
|
|
||||||
|
if image_asset.is_file():
|
||||||
|
myzip.write(image_asset, arcname=image_asset.name)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
|
||||||
|
)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ from .recipe_timeline_events import (
|
|||||||
TimelineEventType,
|
TimelineEventType,
|
||||||
)
|
)
|
||||||
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
|
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
|
||||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
from .request_helpers import RecipeDuplicate, RecipeSlug, SlugResponse, UpdateImageResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IngredientReferences",
|
"IngredientReferences",
|
||||||
@@ -178,7 +178,6 @@ __all__ = [
|
|||||||
"RecipeImageTypes",
|
"RecipeImageTypes",
|
||||||
"RecipeDuplicate",
|
"RecipeDuplicate",
|
||||||
"RecipeSlug",
|
"RecipeSlug",
|
||||||
"RecipeZipTokenResponse",
|
|
||||||
"SlugResponse",
|
"SlugResponse",
|
||||||
"UpdateImageResponse",
|
"UpdateImageResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,9 +17,5 @@ class UpdateImageResponse(BaseModel):
|
|||||||
image: str
|
image: str
|
||||||
|
|
||||||
|
|
||||||
class RecipeZipTokenResponse(BaseModel):
|
|
||||||
token: str
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeDuplicate(BaseModel):
|
class RecipeDuplicate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ def test_get_recipe_as_zip(api_client: TestClient, unique_user: TestUser) -> Non
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
slug = response.json()
|
slug = response.json()
|
||||||
|
|
||||||
# Get zip token
|
# Get token
|
||||||
response = api_client.post(api_routes.recipes_slug_exports(slug), headers=unique_user.token)
|
recipe = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token).json()
|
||||||
assert response.status_code == 200
|
assert recipe["slug"] == slug
|
||||||
token = response.json()["token"]
|
response = api_client.post(api_routes.shared_recipes, json={"recipeId": recipe["id"]}, headers=unique_user.token)
|
||||||
assert token
|
assert response.status_code == 201
|
||||||
|
token_id = response.json()["id"]
|
||||||
|
|
||||||
response = api_client.get(api_routes.recipes_slug_exports_zip(slug) + f"?token={token}", headers=unique_user.token)
|
# Get zip file
|
||||||
|
response = api_client.get(api_routes.recipes_shared_token_id_zip(token_id))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the zip
|
# Verify the zip
|
||||||
|
|||||||
@@ -468,6 +468,11 @@ def recipes_shared_token_id(token_id):
|
|||||||
return f"{prefix}/recipes/shared/{token_id}"
|
return f"{prefix}/recipes/shared/{token_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def recipes_shared_token_id_zip(token_id):
|
||||||
|
"""`/api/recipes/shared/{token_id}/zip`"""
|
||||||
|
return f"{prefix}/recipes/shared/{token_id}/zip"
|
||||||
|
|
||||||
|
|
||||||
def recipes_slug(slug):
|
def recipes_slug(slug):
|
||||||
"""`/api/recipes/{slug}`"""
|
"""`/api/recipes/{slug}`"""
|
||||||
return f"{prefix}/recipes/{slug}"
|
return f"{prefix}/recipes/{slug}"
|
||||||
@@ -493,11 +498,6 @@ def recipes_slug_exports(slug):
|
|||||||
return f"{prefix}/recipes/{slug}/exports"
|
return f"{prefix}/recipes/{slug}/exports"
|
||||||
|
|
||||||
|
|
||||||
def recipes_slug_exports_zip(slug):
|
|
||||||
"""`/api/recipes/{slug}/exports/zip`"""
|
|
||||||
return f"{prefix}/recipes/{slug}/exports/zip"
|
|
||||||
|
|
||||||
|
|
||||||
def recipes_slug_image(slug):
|
def recipes_slug_image(slug):
|
||||||
"""`/api/recipes/{slug}/image`"""
|
"""`/api/recipes/{slug}/image`"""
|
||||||
return f"{prefix}/recipes/{slug}/image"
|
return f"{prefix}/recipes/{slug}/image"
|
||||||
|
|||||||
Reference in New Issue
Block a user