diff --git a/.vscode/settings.json b/.vscode/settings.json index 43cbef30715d..0d5970787eb7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } diff --git a/frontend/components/Layout/AppSidebar.vue b/frontend/components/Layout/AppSidebar.vue index 338fd7ed8719..0e545a6f5337 100644 --- a/frontend/components/Layout/AppSidebar.vue +++ b/frontend/components/Layout/AppSidebar.vue @@ -15,6 +15,8 @@ + + @@ -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, diff --git a/frontend/pages/recipe/create.vue b/frontend/pages/recipe/create.vue new file mode 100644 index 000000000000..4e7db52d1753 --- /dev/null +++ b/frontend/pages/recipe/create.vue @@ -0,0 +1,244 @@ + + + + + + diff --git a/frontend/static/svgs/recipes-create.svg b/frontend/static/svgs/recipes-create.svg new file mode 100644 index 000000000000..124e7643b548 --- /dev/null +++ b/frontend/static/svgs/recipes-create.svg @@ -0,0 +1 @@ +diet \ No newline at end of file diff --git a/mealie/core/dependencies/dependencies.py b/mealie/core/dependencies/dependencies.py index bbe8ad13d89d..23182d66284e 100644 --- a/mealie/core/dependencies/dependencies.py +++ b/mealie/core/dependencies/dependencies.py @@ -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) diff --git a/mealie/routes/about/events.py b/mealie/routes/about/events.py index 842c06658706..f8ebf2177af1 100644 --- a/mealie/routes/about/events.py +++ b/mealie/routes/about/events.py @@ -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) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 291a389d83e5..23896b3a76f7 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -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( diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index fc9023f6fa3f..754dc7d02c97 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -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"]) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 4b9bfe1562a3..338c5e5cf9b5 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -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) diff --git a/mealie/routes/recipe/recipe_export.py b/mealie/routes/recipe/recipe_export.py new file mode 100644 index 000000000000..62cfa62a7ddb --- /dev/null +++ b/mealie/routes/recipe/recipe_export.py @@ -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) diff --git a/mealie/schema/admin/backup.py b/mealie/schema/admin/backup.py index 4ce669cfed18..3b557e8fc11c 100644 --- a/mealie/schema/admin/backup.py +++ b/mealie/schema/admin/backup.py @@ -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: diff --git a/mealie/services/_base_service/__init__.py b/mealie/services/_base_service/__init__.py new file mode 100644 index 000000000000..433f6d7d3be4 --- /dev/null +++ b/mealie/services/_base_service/__init__.py @@ -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() diff --git a/mealie/services/admin/__init__.py b/mealie/services/admin/__init__.py new file mode 100644 index 000000000000..c2f3d40c2fdd --- /dev/null +++ b/mealie/services/admin/__init__.py @@ -0,0 +1 @@ +from .backup_service import * diff --git a/mealie/services/admin/backup_service.py b/mealie/services/admin/backup_service.py new file mode 100644 index 000000000000..a5fd16d958a8 --- /dev/null +++ b/mealie/services/admin/backup_service.py @@ -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 diff --git a/mealie/services/admin/exporter.py b/mealie/services/admin/exporter.py new file mode 100644 index 000000000000..9742dee34d91 --- /dev/null +++ b/mealie/services/admin/exporter.py @@ -0,0 +1,2 @@ +class Exporter: + pass diff --git a/mealie/services/admin/import_service.py b/mealie/services/admin/import_service.py new file mode 100644 index 000000000000..e0ae870380d3 --- /dev/null +++ b/mealie/services/admin/import_service.py @@ -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 diff --git a/mealie/services/admin/importer.py b/mealie/services/admin/importer.py new file mode 100644 index 000000000000..ec96b8fdbef4 --- /dev/null +++ b/mealie/services/admin/importer.py @@ -0,0 +1,2 @@ +class Importer: + pass diff --git a/mealie/services/backups/exports.py b/mealie/services/backups/exports.py index af20a3779d25..a78d83946a13 100644 --- a/mealie/services/backups/exports.py +++ b/mealie/services/backups/exports.py @@ -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. diff --git a/mealie/services/group_services/group_service.py b/mealie/services/group_services/group_service.py index 77ff1144a0ca..1f05f1343de6 100644 --- a/mealie/services/group_services/group_service.py +++ b/mealie/services/group_services/group_service.py @@ -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 diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 7da5753e0d49..cd68ef0cb0a5 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -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) diff --git a/mealie/services/recipe/template_service.py b/mealie/services/recipe/template_service.py new file mode 100644 index 000000000000..849bd8345563 --- /dev/null +++ b/mealie/services/recipe/template_service.py @@ -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 diff --git a/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py b/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py new file mode 100644 index 000000000000..de83795baebf --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_recipe_import_export.py @@ -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 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_export_as.py b/tests/integration_tests/user_recipe_tests/test_recipe_export_as.py new file mode 100644 index 000000000000..42fdeac5377c --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_export_as.py @@ -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 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index 676f176a39b4..2832c6e0140f 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -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() diff --git a/tests/unit_tests/test_recipe_export_types.py b/tests/unit_tests/test_recipe_export_types.py new file mode 100644 index 000000000000..5c40031473a0 --- /dev/null +++ b/tests/unit_tests/test_recipe_export_types.py @@ -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