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
26 changed files with 714 additions and 50 deletions

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