feat(backend): 🚧 stub out new exporter service (WIP) (#715)

* chore(backend): 🎨 add isort path to vscode settings

* style(frontend): 💄 remove fab and add general create button

* feat(backend): 🚧 stub out new exporter service

* comment out stub tests

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-10-02 11:37:04 -08:00 committed by GitHub
parent 476aefeeb0
commit 4bdba9f3af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 714 additions and 50 deletions

View File

@ -35,5 +35,6 @@
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"search.mode": "reuseEditor",
"vetur.validation.template": false
"vetur.validation.template": false,
"python.sortImports.path": "${workspaceFolder}/.venv/bin/isort"
}

View File

@ -15,6 +15,8 @@
<v-divider></v-divider>
</template>
<slot></slot>
<!-- Primary Links -->
<template v-if="topLink">
<v-list nav dense>

View File

@ -10,7 +10,32 @@
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLink : []"
@input="sidebar = !sidebar"
/>
>
<v-menu offset-y nudge-bottom="5" open-on-hover close-delay="30" nudge-right="15">
<template #activator="{ on, attrs }">
<v-btn rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
<v-icon left large color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t("general.create") }}
</v-btn>
</template>
<v-list>
<template v-for="(item, index) in createLinks">
<v-divider v-if="item.divider" :key="index" class="mx-2"></v-divider>
<v-list-item v-else :key="item.title" :to="item.to" exact>
<v-list-item-avatar>
<v-icon v-text="item.icon"></v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title>
<v-list-item-subtitle v-text="item.subtitle"></v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
</AppSidebar>
<AppHeader>
<v-btn icon @click.stop="sidebar = !sidebar">
@ -22,7 +47,6 @@
<Nuxt />
</v-scroll-x-transition>
</v-main>
<AppFloatingButton absolute />
</v-app>
</template>
@ -31,11 +55,10 @@
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue";
import AppFloatingButton from "@/components/Layout/AppFloatingButton.vue";
import { useCookbooks } from "~/composables/use-group-cookbooks";
export default defineComponent({
components: { AppHeader, AppSidebar, AppFloatingButton },
components: { AppHeader, AppSidebar },
// @ts-ignore
middleware: "auth",
setup() {
@ -60,6 +83,35 @@ export default defineComponent({
data() {
return {
sidebar: null,
createLinks: [
{
icon: this.$globals.icons.link,
title: "Import",
subtitle: "Import a recipe by URL",
to: "/recipe/create?tab=url",
restricted: true,
},
{
divider: true,
},
{
icon: this.$globals.icons.edit,
title: "Create",
subtitle: "Create a recipe manually",
to: "/recipe/create?tab=new",
restricted: true,
},
{
divider: true,
},
{
icon: this.$globals.icons.zip,
title: "Restore",
subtitle: "Restore from a exported recipe",
to: "/recipe/create?tab=zip",
restricted: true,
},
],
bottomLink: [
{
icon: this.$globals.icons.cog,

View File

@ -0,0 +1,244 @@
<template>
<v-container class="narrow-container flex-column">
<BasePageTitle divider>
<template #header>
<v-img max-height="175" max-width="175" :src="require('~/static/svgs/recipes-create.svg')"></v-img>
</template>
<template #title> Recipe Creation </template>
Select one of the various ways to create a recipe
</BasePageTitle>
<v-tabs v-model="tab">
<v-tab href="#url">From URL</v-tab>
<v-tab href="#new">Create</v-tab>
<v-tab href="#zip">Import Zip</v-tab>
</v-tabs>
<section>
<v-tabs-items v-model="tab" class="mt-10">
<v-tab-item value="url" eager>
<v-form ref="domUrlForm" @submit.prevent="createByUrl(recipeUrl)">
<v-card outlined>
<v-card-text>
<v-text-field
v-model="recipeUrl"
:label="$t('new-recipe.recipe-url')"
validate-on-blur
autofocus
class="rounded-lg my-auto"
:rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton type="submit" :loading="loading" />
</v-card-actions>
</v-card>
</v-form>
<v-expand-transition>
<v-alert v-show="error" color="error" class="mt-6 white--text">
<v-card-title class="ma-0 pa-0">
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
{{ $t("new-recipe.error-title") }}
</v-card-title>
<v-divider class="my-3 mx-2"></v-divider>
<p>
{{ $t("new-recipe.error-details") }}
</p>
<div class="d-flex row justify-space-around my-3 force-white">
<a
class="dark"
href="https://developers.google.com/search/docs/data-types/recipe"
target="_blank"
rel="noreferrer nofollow"
>
{{ $t("new-recipe.google-ld-json-info") }}
</a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.github-issues") }}
</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.recipe-markup-specification") }}
</a>
</div>
<div class="d-flex justify-end">
<v-btn
dark
outlined
:to="{ path: '/recipes/debugger', query: { test_url: recipeUrl } }"
@click="addRecipe = false"
>
<v-icon left> {{ $globals.icons.externalLink }} </v-icon>
{{ $t("new-recipe.view-scraped-data") }}
</v-btn>
</div>
</v-alert>
</v-expand-transition>
</v-tab-item>
<v-tab-item value="new" eager>
<v-card outlined>
<v-card-text>
<v-form ref="domCreateByName">
<v-text-field
v-model="newRecipeName"
:label="$t('recipe.recipe-name')"
validate-on-blur
autofocus
class="rounded-lg my-auto"
:rules="[validators.required]"
hint="New recipe names must be unique"
persistent-hint
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton :loading="loading" @click="createByName(newRecipeName)" />
</v-card-actions>
</v-card>
</v-tab-item>
<v-tab-item value="zip" eager>
<v-form>
<v-card outlined>
<v-card-text>
<v-file-input
v-model="newRecipeZip"
accept=".zip"
placeholder="Select your files"
label="File input"
truncate-length="100"
hint=".zip files must have been exported from Mealie"
persistent-hint
:prepend-icon="$globals.icons.zip"
>
</v-file-input>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<BaseButton :loading="loading" @click="createByZip" />
</v-card-actions>
</v-card>
</v-form>
</v-tab-item>
</v-tabs-items>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const api = useApiSingleton();
const router = useRouter();
function handleResponse(response: any) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
console.log(response);
router.push(`/recipe/${response.data}`);
}
// ===================================================
// Recipe URL Import
// @ts-ignore
const domUrlForm = ref<VForm>(null);
async function createByUrl(url: string) {
if (!domUrlForm.value.validate() || url === "") {
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(url);
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
handleResponse(response);
}
// ===================================================
// Recipe Create By Name
const newRecipeName = ref("");
// @ts-ignore
const domCreateByName = ref<VForm>(null);
async function createByName(name: string) {
if (!domCreateByName.value.validate() || name === "") {
return;
}
const { response } = await api.recipes.createOne({ name });
console.log("Create By Name Func", response);
handleResponse(response);
}
// ===================================================
// Recipe Import From Zip File
// @ts-ignore
const newRecipeZip = ref<File>(null);
const newRecipeZipFileName = "archive";
async function createByZip() {
if (!newRecipeZip.value) {
return;
}
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
const { response } = await api.upload.file("/api/recipes/create-from-zip", formData);
console.log(response);
handleResponse(response);
}
return {
domCreateByName,
domUrlForm,
newRecipeName,
newRecipeZip,
createByName,
createByUrl,
createByZip,
...toRefs(state),
validators,
};
},
// Computed State is used because of the limitation of vue-composition-api in v2.0
computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
recipeUrl: {
set(recipe_import_url) {
this.$router.replace({ query: { ...this.$route.query, recipe_import_url } });
},
get() {
return this.$route.query.recipe_import_url;
},
},
},
});
</script>
<style>
.force-white > a {
color: white !important;
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -1,5 +1,7 @@
import shutil
from pathlib import Path
from typing import Optional
from uuid import uuid4
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@ -112,10 +114,20 @@ def validate_file_token(token: Optional[str] = None) -> Path:
async def temporary_zip_path() -> Path:
temp_path = app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
try:
yield temp_path
finally:
temp_path.unlink(missing_ok=True)
async def temporary_dir() -> Path:
temp_path = app_dirs.TEMP_DIR.joinpath(uuid4().hex)
temp_path.mkdir(exist_ok=True, parents=True)
try:
yield temp_path
finally:
shutil.rmtree(temp_path)

View File

@ -24,11 +24,11 @@ async def get_events(session: Session = Depends(generate_session)):
async def delete_events(session: Session = Depends(generate_session)):
""" Get event from the Database """
db = get_database(session)
return db.events.delete_all(session)
return db.events.delete_all()
@router.delete("/{id}")
async def delete_event(id: int, session: Session = Depends(generate_session)):
""" Delete event from the Database """
db = get_database(session)
return db.events.delete(session, id)
return db.events.delete(id)

View File

@ -11,7 +11,7 @@ from mealie.core.root_logger import get_logger
from mealie.core.security import create_file_token
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.admin import BackupJob, ImportJob, Imports, LocalBackup
from mealie.schema.admin import AllBackups, BackupFile, CreateBackup, ImportJob
from mealie.schema.user.user import PrivateUser
from mealie.services.backups import imports
from mealie.services.backups.exports import backup_all
@ -21,22 +21,24 @@ router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"])
logger = get_logger()
@router.get("/available", response_model=Imports)
@router.get("/available", response_model=AllBackups)
def available_imports():
"""Returns a list of avaiable .zip files for import into Mealie."""
imports = []
for archive in app_dirs.BACKUP_DIR.glob("*.zip"):
backup = LocalBackup(name=archive.name, date=archive.stat().st_ctime)
backup = BackupFile(name=archive.name, date=archive.stat().st_ctime)
imports.append(backup)
templates = [template.name for template in app_dirs.TEMPLATE_DIR.glob("*.*")]
imports.sort(key=operator.attrgetter("date"), reverse=True)
return Imports(imports=imports, templates=templates)
return AllBackups(imports=imports, templates=templates)
@router.post("/export/database", status_code=status.HTTP_201_CREATED)
def export_database(background_tasks: BackgroundTasks, data: BackupJob, session: Session = Depends(generate_session)):
def export_database(
background_tasks: BackgroundTasks, data: CreateBackup, session: Session = Depends(generate_session)
):
"""Generates a backup of the recipe database in json format."""
try:
export_path = backup_all(

View File

@ -1,12 +1,20 @@
from fastapi import APIRouter
from mealie.routes.recipe import all_recipe_routes, comments, image_and_assets, ingredient_parser, recipe_crud_routes
from mealie.routes.recipe import (
all_recipe_routes,
comments,
image_and_assets,
ingredient_parser,
recipe_crud_routes,
recipe_export,
)
prefix = "/recipes"
router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])

View File

@ -1,5 +1,3 @@
import json
import shutil
from zipfile import ZipFile
from fastapi import Depends, File
@ -15,7 +13,6 @@ from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByURL, Recipe, RecipeImageTypes
from mealie.schema.recipe.recipe import CreateRecipe, RecipeSummary
from mealie.services.image.image import write_image
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.scraper.scraper import create_from_url
@ -48,36 +45,15 @@ def test_parse_recipe_url(url: CreateRecipeByURL):
return scrape_url(url.url)
@user_router.post("/create-from-zip")
@user_router.post("/create-from-zip", status_code=201)
async def create_recipe_from_zip(
session: Session = Depends(generate_session),
recipe_service: RecipeService = Depends(RecipeService.private),
temp_path=Depends(temporary_zip_path),
archive: UploadFile = File(...),
):
""" Create recipe from archive """
with temp_path.open("wb") as buffer:
shutil.copyfileobj(archive.file, buffer)
recipe_dict = None
recipe_image = None
with ZipFile(temp_path) as myzip:
for file in myzip.namelist():
if file.endswith(".json"):
with myzip.open(file) as myfile:
recipe_dict = json.loads(myfile.read())
elif file.endswith(".webp"):
with myzip.open(file) as myfile:
recipe_image = myfile.read()
db = get_database(session)
recipe: Recipe = db.recipes.create(Recipe(**recipe_dict))
write_image(recipe.slug, recipe_image, "webp")
return recipe
recipe = recipe_service.create_from_zip(archive, temp_path)
return recipe.slug
@user_router.get("/{slug}", response_model=Recipe)

View File

@ -0,0 +1,40 @@
from fastapi import Depends
from pydantic import BaseModel, Field
from starlette.responses import FileResponse
from mealie.core.dependencies.dependencies import temporary_dir
from mealie.core.root_logger import get_logger
from mealie.routes.routers import UserAPIRouter
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
user_router = UserAPIRouter()
logger = get_logger()
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
@user_router.get("/exports", response_model=FormatResponse)
async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeService.private)):
return TemplateService().templates
@user_router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(
template_name: str,
recipe_service: RecipeService = Depends(RecipeService.write_existing),
temp_dir=Depends(temporary_dir),
):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
file = recipe_service.render_template(temp_dir, template_name)
return FileResponse(file)

View File

@ -42,7 +42,7 @@ class ImportJob(BackupOptions):
}
class BackupJob(BaseModel):
class CreateBackup(BaseModel):
tag: Optional[str]
options: BackupOptions
templates: Optional[List[str]]
@ -57,13 +57,13 @@ class BackupJob(BaseModel):
}
class LocalBackup(BaseModel):
class BackupFile(BaseModel):
name: str
date: datetime
class Imports(BaseModel):
imports: List[LocalBackup]
class AllBackups(BaseModel):
imports: List[BackupFile]
templates: List[str]
class Config:

View File

@ -0,0 +1,7 @@
from mealie.core.config import get_app_dirs, get_settings
class BaseService:
def __init__(self) -> None:
self.app_dirs = get_app_dirs()
self.settings = get_settings()

View File

@ -0,0 +1 @@
from .backup_service import *

View File

@ -0,0 +1,36 @@
import operator
from mealie.schema.admin.backup import AllBackups, BackupFile, CreateBackup
from mealie.services._base_http_service import AdminHttpService
from mealie.services.events import create_backup_event
from .exporter import Exporter
class BackupHttpService(AdminHttpService):
event_func = create_backup_event
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.exporter = Exporter()
def get_all(self) -> AllBackups:
imports = []
for archive in self.app_dirs.BACKUP_DIR.glob("*.zip"):
backup = BackupFile(name=archive.name, date=archive.stat().st_ctime)
imports.append(backup)
templates = [template.name for template in self.app_dirs.TEMPLATE_DIR.glob("*.*")]
imports.sort(key=operator.attrgetter("date"), reverse=True)
return AllBackups(imports=imports, templates=templates)
def create_one(self, options: CreateBackup):
pass
def delete_one(self):
pass
class BackupService:
pass

View File

@ -0,0 +1,2 @@
class Exporter:
pass

View File

@ -0,0 +1,21 @@
from mealie.services._base_http_service import AdminHttpService
from .importer import Importer
class ImportHttpService(AdminHttpService):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.exporter = Importer()
def get_all(self):
pass
def get_one(self):
pass
def create(self):
pass
def delete_one(self):
pass

View File

@ -0,0 +1,2 @@
class Importer:
pass

View File

@ -23,7 +23,6 @@ class ExportDatabase:
with any supported backend database platform. By default tags are timestamps, and no
Jinja2 templates are rendered
Args:
tag ([str], optional): A str to be used as a file tag. Defaults to None.
templates (list, optional): A list of template file names. Defaults to None.

View File

@ -35,17 +35,29 @@ class GroupSelfService(UserHttpService[int, str]):
self.item = self.db.groups.get(self.group_id)
return self.item
# ====================================================================
# Meal Categories
def update_categories(self, new_categories: list[CategoryBase]):
self.item.categories = new_categories
return self.db.groups.update(self.group_id, self.item)
# ====================================================================
# Preferences
def update_preferences(self, new_preferences: UpdateGroupPreferences):
self.db.group_preferences.update(self.group_id, new_preferences)
return self.populate_item()
# ====================================================================
# Group Invites
def create_invite_token(self, uses: int = 1) -> None:
token = SaveInviteToken(uses_left=uses, group_id=self.group_id, token=uuid4().hex)
return self.db.group_invite_tokens.create(token)
def get_invite_tokens(self) -> list[ReadInviteToken]:
return self.db.group_invite_tokens.multi_query({"group_id": self.group_id})
# ====================================================================
# Export / Import Recipes

View File

@ -1,9 +1,12 @@
import json
import shutil
from functools import cached_property
from pathlib import Path
from shutil import copytree, rmtree
from typing import Union
from zipfile import ZipFile
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, UploadFile, status
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
@ -12,8 +15,11 @@ from mealie.schema.recipe.recipe import CreateRecipe, Recipe, RecipeSummary
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_recipe_event
from mealie.services.image.image import write_image
from mealie.services.recipe.mixins import recipe_creation_factory
from .template_service import TemplateService
logger = get_logger(module=__name__)
@ -65,6 +71,33 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
)
return self.item
def create_from_zip(self, archive: UploadFile, temp_path: Path) -> Recipe:
"""
`create_from_zip` creates a recipe in the database from a zip file exported from Mealie. This is NOT
a generic import from a zip file.
"""
with temp_path.open("wb") as buffer:
shutil.copyfileobj(archive.file, buffer)
recipe_dict = None
recipe_image = None
with ZipFile(temp_path) as myzip:
for file in myzip.namelist():
if file.endswith(".json"):
with myzip.open(file) as myfile:
recipe_dict = json.loads(myfile.read())
elif file.endswith(".webp"):
with myzip.open(file) as myfile:
recipe_image = myfile.read()
self.create_one(Recipe(**recipe_dict))
if self.item:
write_image(self.item.slug, recipe_image, "webp")
return self.item
def update_one(self, update_data: Recipe) -> Recipe:
original_slug = self.item.slug
self._update_one(update_data, original_slug)
@ -107,3 +140,10 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
recipe_dir = self.item.directory
rmtree(recipe_dir, ignore_errors=True)
logger.info(f"Recipe Directory Removed: {self.item.slug}")
# =================================================================
# Recipe Template Methods
def render_template(self, temp_dir: Path, template: str = None) -> Path:
t_service = TemplateService(temp_dir)
return t_service.render(self.item, template)

View File

@ -0,0 +1,133 @@
import enum
from pathlib import Path
from zipfile import ZipFile
from jinja2 import Template
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe_image_types import RecipeImageTypes
from mealie.services._base_service import BaseService
class TemplateType(str, enum.Enum):
json = "json"
jinja2 = "jinja2"
zip = "zip"
class TemplateService(BaseService):
def __init__(self, temp: Path = None) -> None:
"""Creates a template service that can be used for multiple template generations
A temporary directory must be provided as a place holder for where to render all templates
Args:
temp (Path): [description]
"""
self.temp = temp
self.types = TemplateType
super().__init__()
@property
def templates(self) -> list:
"""
Returns a list of all templates available to render.
"""
return {
TemplateType.jinja2.value: [x.name for x in self.app_dirs.TEMPLATE_DIR.iterdir() if x.is_file()],
TemplateType.json.value: ["raw"],
TemplateType.zip.value: ["zip"],
}
def __check_temp(self, method) -> None:
"""
Checks if the temporary directory was provided on initialization
"""
if self.temp is None:
raise ValueError(f"Temporary directory must be provided for method {method.__name__}")
def template_type(self, template: str) -> TemplateType:
# Determine Type:
t_type = None
for key, value in self.templates.items():
if template in value:
t_type = key
break
if t_type is None:
raise ValueError(f"Template '{template}' not found.")
return TemplateType(t_type)
def render(self, recipe: Recipe, template: str = None) -> Path:
"""
Renders a TemplateType in a temporary directory and returns the path to the file.
Args:
t_type (TemplateType): The type of template to render
recipe (Recipe): The recipe to render
template (str): The template to render **Required for Jinja2 Templates**
"""
t_type = self.template_type(template)
if t_type == TemplateType.json:
return self._render_json(recipe)
if t_type == TemplateType.jinja2:
return self._render_jinja2(recipe, template)
if t_type == TemplateType.zip:
return self._render_zip(recipe)
def _render_json(self, recipe: Recipe) -> Path:
"""
Renders a JSON file in a temporary directory and returns
the path to the file.
"""
self.__check_temp(self._render_json)
save_path = self.temp.joinpath(f"{recipe.slug}.json")
with open(save_path, "w") as f:
f.write(recipe.json(indent=4, by_alias=True))
return save_path
def _render_jinja2(self, recipe: Recipe, j2_template: str = None) -> Path:
"""
Renders a Jinja2 Template in a temporary directory and returns
the path to the file.
"""
self.__check_temp(self._render_jinja2)
j2_template: Path = self.app_dirs.TEMPLATE_DIR / j2_template
if not j2_template.is_file():
raise FileNotFoundError(f"Template '{j2_template}' not found.")
with open(j2_template, "r") as f:
template_text = f.read()
template = Template(template_text)
rendered_text = template.render(recipe=recipe.dict(by_alias=True))
save_name = f"{recipe.slug}{j2_template.suffix}"
save_path = self.temp.joinpath(save_name)
with open(save_path, "w") as f:
f.write(rendered_text)
return save_path
def _render_zip(self, recipe: Recipe) -> Path:
self.__check_temp(self._render_jinja2)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
zip_temp = self.temp.joinpath(f"{recipe.slug}.zip")
with ZipFile(zip_temp, "w") as myzip:
myzip.writestr(f"{recipe.slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return zip_temp

View File

@ -0,0 +1,15 @@
# from fastapi.testclient import TestClient
# from tests.utils.fixture_schemas import TestUser
# class Routes:
# base = "/api/groups/manage/data" # Not sure if this is a good url?!?!?
# def test_recipe_export(api_client: TestClient, unique_user: TestUser) -> None:
# assert False
# def test_recipe_import(api_client: TestClient, unique_user: TestUser) -> None:
# assert False

View File

@ -0,0 +1,52 @@
from fastapi.testclient import TestClient
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
class Routes:
base = "/api/recipes"
exports = base + "/exports"
@staticmethod
def item(slug: str, file_name: str) -> str:
return f"/api/recipes/{slug}/exports?template_name={file_name}"
def test_get_available_exports(api_client: TestClient, unique_user: TestUser) -> None:
# Get Templates
response = api_client.get(Routes.exports, headers=unique_user.token)
# Assert Templates are Available
assert response.status_code == 200
as_json = response.json()
assert "recipes.md" in as_json["jinja2"]
assert "raw" in as_json["json"]
def test_render_jinja_template(api_client: TestClient, unique_user: TestUser) -> None:
# Create Recipe
recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=unique_user.token)
assert response.status_code == 201
slug = response.json()
# Render Template
response = api_client.get(Routes.item(slug, "recipes.md"), headers=unique_user.token)
assert response.status_code == 200
# Assert Template is Rendered Correctly
# TODO: More robust test
assert f"# {recipe_name}" in response.text
# TODO: Allow users to upload templates to their own directory
# def test_upload_template(api_client: TestClient, unique_user: TestUser) -> None:
# assert False
# # TODO: Allow users to upload templates to their own directory
# def test_delete_template(api_client: TestClient, unique_user: TestUser) -> None:
# assert False

View File

@ -16,9 +16,7 @@ USER_ID = 2
def test_ownership_on_new_with_admin(api_client: TestClient, admin_token):
recipe_name = random_string()
response = api_client.post(Routes.base, json={"name": recipe_name}, headers=admin_token)
assert response.status_code == 201
recipe = api_client.get(Routes.base + f"/{recipe_name}", headers=admin_token).json()

View File

@ -0,0 +1,8 @@
from mealie.services.recipe.template_service import TemplateService, TemplateType
def test_recipe_export_types() -> None:
ts = TemplateService()
assert ts.template_type("recipes.md") == TemplateType.jinja2.value
assert ts.template_type("raw") == TemplateType.json.value
assert ts.template_type("zip") == TemplateType.zip.value