mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
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:
parent
476aefeeb0
commit
4bdba9f3af
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -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"
|
||||
}
|
||||
|
@ -15,6 +15,8 @@
|
||||
<v-divider></v-divider>
|
||||
</template>
|
||||
|
||||
<slot></slot>
|
||||
|
||||
<!-- Primary Links -->
|
||||
<template v-if="topLink">
|
||||
<v-list nav dense>
|
||||
|
@ -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,
|
||||
|
244
frontend/pages/recipe/create.vue
Normal file
244
frontend/pages/recipe/create.vue
Normal 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>
|
1
frontend/static/svgs/recipes-create.svg
Normal file
1
frontend/static/svgs/recipes-create.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 8.3 KiB |
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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"])
|
||||
|
@ -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)
|
||||
|
40
mealie/routes/recipe/recipe_export.py
Normal file
40
mealie/routes/recipe/recipe_export.py
Normal 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)
|
@ -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:
|
||||
|
7
mealie/services/_base_service/__init__.py
Normal file
7
mealie/services/_base_service/__init__.py
Normal 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()
|
1
mealie/services/admin/__init__.py
Normal file
1
mealie/services/admin/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .backup_service import *
|
36
mealie/services/admin/backup_service.py
Normal file
36
mealie/services/admin/backup_service.py
Normal 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
|
2
mealie/services/admin/exporter.py
Normal file
2
mealie/services/admin/exporter.py
Normal file
@ -0,0 +1,2 @@
|
||||
class Exporter:
|
||||
pass
|
21
mealie/services/admin/import_service.py
Normal file
21
mealie/services/admin/import_service.py
Normal 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
|
2
mealie/services/admin/importer.py
Normal file
2
mealie/services/admin/importer.py
Normal file
@ -0,0 +1,2 @@
|
||||
class Importer:
|
||||
pass
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
133
mealie/services/recipe/template_service.py
Normal file
133
mealie/services/recipe/template_service.py
Normal 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
|
@ -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
|
@ -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
|
@ -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()
|
||||
|
8
tests/unit_tests/test_recipe_export_types.py
Normal file
8
tests/unit_tests/test_recipe_export_types.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user