mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-07 16:33:10 -05:00
feature: proper multi-tenant-support (#969)(WIP)
* update naming * refactor tests to use shared structure * shorten names * add tools test case * refactor to support multi-tenant * set group_id on creation * initial refactor for multitenant tags/cats * spelling * additional test case for same valued resources * fix recipe update tests * apply indexes to foreign keys * fix performance regressions * handle unknown exception * utility decorator for function debugging * migrate recipe_id to UUID * GUID for recipes * remove unused import * move image functions into package * move utilities to packages dir * update import * linter * image image and asset routes * update assets and images to use UUIDs * fix migration base * image asset test coverage * use ids for categories and tag crud functions * refactor recipe organizer test suite to reduce duplication * add uuid serlization utility * organizer base router * slug routes testing and fixes * fix postgres error * adopt UUIDs * move tags, categories, and tools under "organizers" umbrella * update composite label * generate ts types * fix import error * update frontend types * fix type errors * fix postgres errors * fix #978 * add null check for title validation * add note in docs on multi-tenancy
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users
|
||||
from . import admin, app, auth, comments, groups, organizers, parser, recipe, shared, unit_and_foods, users
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -9,11 +9,9 @@ router.include_router(auth.router)
|
||||
router.include_router(users.router)
|
||||
router.include_router(groups.router)
|
||||
router.include_router(recipe.router)
|
||||
router.include_router(organizers.router)
|
||||
router.include_router(shared.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(parser.router)
|
||||
router.include_router(unit_and_foods.router)
|
||||
router.include_router(tools.router)
|
||||
router.include_router(categories.router)
|
||||
router.include_router(tags.router)
|
||||
router.include_router(admin.router)
|
||||
|
||||
@@ -10,13 +10,13 @@ from mealie.core.dependencies import get_current_user
|
||||
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.pkgs.stats.fs_stats import pretty_size
|
||||
from mealie.routes._base.routers import AdminAPIRouter
|
||||
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
|
||||
from mealie.services.events import create_backup_event
|
||||
from mealie.utils.fs_stats import pretty_size
|
||||
|
||||
router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"])
|
||||
logger = get_logger()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import categories
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(categories.router)
|
||||
@@ -1,69 +0,0 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@controller(router)
|
||||
class RecipeCategoryController(BaseUserController):
|
||||
# =========================================================================
|
||||
# CRUD Operations
|
||||
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return CrudMixins(self.repos.categories, self.deps.logger)
|
||||
|
||||
@router.get("", response_model=list[CategorySummary])
|
||||
def get_all(self):
|
||||
"""Returns a list of available categories in the database"""
|
||||
return self.repos.categories.get_all_limit_columns(fields=["slug", "name"])
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_one(self, category: CategoryIn):
|
||||
"""Creates a Category in the database"""
|
||||
return self.mixins.create_one(category)
|
||||
|
||||
@router.get("/{slug}", response_model=RecipeCategoryResponse)
|
||||
def get_all_recipes_by_category(self, slug: str):
|
||||
"""Returns a list of recipes associated with the provided category."""
|
||||
category_obj = self.repos.categories.get(slug)
|
||||
category_obj = RecipeCategoryResponse.from_orm(category_obj)
|
||||
return category_obj
|
||||
|
||||
@router.put("/{slug}", response_model=RecipeCategoryResponse)
|
||||
def update_one(self, slug: str, update_data: CategoryIn):
|
||||
"""Updates an existing Tag in the database"""
|
||||
return self.mixins.update_one(update_data, slug)
|
||||
|
||||
@router.delete("/{slug}")
|
||||
def delete_one(self, slug: str):
|
||||
"""
|
||||
Removes a recipe category from the database. Deleting a
|
||||
category does not impact a recipe. The category will be removed
|
||||
from any recipes that contain it
|
||||
"""
|
||||
self.mixins.delete_one(slug)
|
||||
|
||||
# =========================================================================
|
||||
# Read All Operations
|
||||
|
||||
@router.get("/empty", response_model=list[CategoryBase])
|
||||
def get_all_empty(self):
|
||||
"""Returns a list of categories that do not contain any recipes"""
|
||||
return self.repos.categories.get_empty()
|
||||
@@ -143,9 +143,9 @@ class ShoppingListController(BaseUserController):
|
||||
# Other Operations
|
||||
|
||||
@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
||||
def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: int):
|
||||
def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: UUID4):
|
||||
return self.service.add_recipe_ingredients_to_list(item_id, recipe_id)
|
||||
|
||||
@router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
||||
def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: int):
|
||||
def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: UUID4):
|
||||
return self.service.remove_recipe_ingredients_from_list(item_id, recipe_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import UUID4
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from mealie.schema.recipe import Recipe
|
||||
@@ -19,11 +20,13 @@ class ImageType(str, Enum):
|
||||
tiny = "tiny-original.webp"
|
||||
|
||||
|
||||
@router.get("/{slug}/images/{file_name}")
|
||||
async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
|
||||
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production"""
|
||||
recipe_image = Recipe(slug=slug).image_dir.joinpath(file_name.value)
|
||||
@router.get("/{recipe_id}/images/{file_name}")
|
||||
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original):
|
||||
"""
|
||||
Takes in a recipe recipe_id, returns the static image. This route is proxied in the docker image
|
||||
and should not hit the API in production
|
||||
"""
|
||||
recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value)
|
||||
|
||||
if recipe_image.exists():
|
||||
return FileResponse(recipe_image)
|
||||
@@ -31,10 +34,10 @@ async def get_recipe_img(slug: str, file_name: ImageType = ImageType.original):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get("/{slug}/assets/{file_name}")
|
||||
async def get_recipe_asset(slug: str, file_name: str):
|
||||
@router.get("/{recipe_id}/assets/{file_name}")
|
||||
async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
||||
"""Returns a recipe asset"""
|
||||
file = Recipe(slug=slug).asset_dir.joinpath(file_name)
|
||||
file = Recipe.directory_from_id(recipe_id).joinpath("assets", file_name)
|
||||
|
||||
try:
|
||||
return FileResponse(file)
|
||||
|
||||
8
mealie/routes/organizers/__init__.py
Normal file
8
mealie/routes/organizers/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import controller_categories, controller_tags, controller_tools
|
||||
|
||||
router = APIRouter(prefix="/organizers")
|
||||
router.include_router(controller_categories.router)
|
||||
router.include_router(controller_tags.router)
|
||||
router.include_router(controller_tools.router)
|
||||
87
mealie/routes/organizers/controller_categories.py
Normal file
87
mealie/routes/organizers/controller_categories.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import UUID4, BaseModel
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
|
||||
from mealie.schema.recipe.recipe import RecipeCategory
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["Organizer: Categories"])
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
id: UUID4
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
@controller(router)
|
||||
class RecipeCategoryController(BaseUserController):
|
||||
# =========================================================================
|
||||
# CRUD Operations
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.categories.by_group(self.group_id)
|
||||
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return CrudMixins(self.repo, self.deps.logger)
|
||||
|
||||
@router.get("", response_model=list[CategorySummary])
|
||||
def get_all(self):
|
||||
"""Returns a list of available categories in the database"""
|
||||
return self.repo.get_all(override_schema=CategorySummary)
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_one(self, category: CategoryIn):
|
||||
"""Creates a Category in the database"""
|
||||
save_data = mapper.cast(category, CategorySave, group_id=self.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=CategorySummary)
|
||||
def get_one(self, item_id: UUID4):
|
||||
"""Returns a list of recipes associated with the provided category."""
|
||||
category_obj = self.mixins.get_one(item_id)
|
||||
category_obj = CategorySummary.from_orm(category_obj)
|
||||
return category_obj
|
||||
|
||||
@router.put("/{item_id}", response_model=CategorySummary)
|
||||
def update_one(self, item_id: UUID4, update_data: CategoryIn):
|
||||
"""Updates an existing Tag in the database"""
|
||||
save_data = mapper.cast(update_data, CategorySave, group_id=self.group_id)
|
||||
return self.mixins.update_one(save_data, item_id)
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def delete_one(self, item_id: UUID4):
|
||||
"""
|
||||
Removes a recipe category from the database. Deleting a
|
||||
category does not impact a recipe. The category will be removed
|
||||
from any recipes that contain it
|
||||
"""
|
||||
self.mixins.delete_one(item_id)
|
||||
|
||||
# =========================================================================
|
||||
# Read All Operations
|
||||
|
||||
@router.get("/empty", response_model=list[CategoryBase])
|
||||
def get_all_empty(self):
|
||||
"""Returns a list of categories that do not contain any recipes"""
|
||||
return self.repos.categories.get_empty()
|
||||
|
||||
@router.get("/slug/{category_slug}")
|
||||
def get_one_by_slug(self, category_slug: str):
|
||||
"""Returns a category object with the associated recieps relating to the category"""
|
||||
category: RecipeCategory = self.mixins.get_one(category_slug, "slug")
|
||||
return RecipeCategoryResponse.construct(
|
||||
id=category.id,
|
||||
slug=category.slug,
|
||||
name=category.name,
|
||||
recipes=self.repos.recipes.by_group(self.group_id).get_by_categories([category]),
|
||||
)
|
||||
66
mealie/routes/organizers/controller_tags.py
Normal file
66
mealie/routes/organizers/controller_tags.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.recipe import RecipeTagResponse, TagIn
|
||||
from mealie.schema.recipe.recipe import RecipeTag
|
||||
from mealie.schema.recipe.recipe_category import TagSave
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["Organizer: Tags"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class TagController(BaseUserController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.tags.by_group(self.group_id)
|
||||
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return CrudMixins(self.repo, self.deps.logger)
|
||||
|
||||
@router.get("")
|
||||
async def get_all(self):
|
||||
"""Returns a list of available tags in the database"""
|
||||
return self.repo.get_all(override_schema=RecipeTag)
|
||||
|
||||
@router.get("/empty")
|
||||
def get_empty_tags(self):
|
||||
"""Returns a list of tags that do not contain any recipes"""
|
||||
return self.repo.get_empty()
|
||||
|
||||
@router.get("/{item_id}", response_model=RecipeTagResponse)
|
||||
def get_one(self, item_id: UUID4):
|
||||
"""Returns a list of recipes associated with the provided tag."""
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_one(self, tag: TagIn):
|
||||
"""Creates a Tag in the database"""
|
||||
save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
|
||||
return self.repo.create(save_data)
|
||||
|
||||
@router.put("/{item_id}", response_model=RecipeTagResponse)
|
||||
def update_one(self, item_id: UUID4, new_tag: TagIn):
|
||||
"""Updates an existing Tag in the database"""
|
||||
save_data = mapper.cast(new_tag, TagSave, group_id=self.group_id)
|
||||
return self.repo.update(item_id, save_data)
|
||||
|
||||
@router.delete("/{item_id}")
|
||||
def delete_recipe_tag(self, item_id: UUID4):
|
||||
"""Removes a recipe tag from the database. Deleting a
|
||||
tag does not impact a recipe. The tag will be removed
|
||||
from any recipes that contain it"""
|
||||
|
||||
try:
|
||||
self.repo.delete(item_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST) from e
|
||||
|
||||
@router.get("/slug/{tag_slug}", response_model=RecipeTagResponse)
|
||||
async def get_one_by_slug(self, tag_slug: str):
|
||||
return self.repo.get_one(tag_slug, "slug", override_schema=RecipeTagResponse)
|
||||
@@ -1,22 +1,24 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.abc_controller import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.schema.recipe.recipe import RecipeTool
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["Recipes: Tools"])
|
||||
router = APIRouter(prefix="/tools", tags=["Organizer: Tools"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class RecipeToolController(BaseUserController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.tools
|
||||
return self.repos.tools.by_group(self.group_id)
|
||||
|
||||
@property
|
||||
def mixins(self) -> CrudMixins:
|
||||
@@ -28,18 +30,19 @@ class RecipeToolController(BaseUserController):
|
||||
|
||||
@router.post("", response_model=RecipeTool, status_code=201)
|
||||
def create_one(self, data: RecipeToolCreate):
|
||||
return self.mixins.create_one(data)
|
||||
save_data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=RecipeTool)
|
||||
def get_one(self, item_id: int):
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=RecipeTool)
|
||||
def update_one(self, item_id: int, data: RecipeToolCreate):
|
||||
def update_one(self, item_id: UUID4, data: RecipeToolCreate):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=RecipeTool)
|
||||
def delete_one(self, item_id: int):
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id) # type: ignore
|
||||
|
||||
@router.get("/slug/{tool_slug}", response_model=RecipeToolResponse)
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import all_recipe_routes, bulk_actions, comments, image_and_assets, recipe_crud_routes, shared_routes
|
||||
from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes
|
||||
|
||||
prefix = "/recipes"
|
||||
|
||||
@@ -9,7 +9,6 @@ router = APIRouter()
|
||||
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
|
||||
router.include_router(recipe_crud_routes.router_exports)
|
||||
router.include_router(recipe_crud_routes.router)
|
||||
router.include_router(image_and_assets.router, prefix=prefix, tags=["Recipe: Images and Assets"])
|
||||
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
router.include_router(bulk_actions.router, prefix=prefix)
|
||||
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Exports"])
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
from shutil import copyfileobj
|
||||
|
||||
from fastapi import Depends, File, Form, HTTPException, status
|
||||
from fastapi.datastructures import UploadFile
|
||||
from pydantic import BaseModel
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeAsset
|
||||
from mealie.services.image.image import scrape_image, write_image
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
class UpdateImageResponse(BaseModel):
|
||||
image: str
|
||||
|
||||
|
||||
@router.post("/{slug}/image")
|
||||
def scrape_image_url(slug: str, url: CreateRecipeByUrl):
|
||||
"""Removes an existing image and replaces it with the incoming file."""
|
||||
scrape_image(url.url, slug)
|
||||
|
||||
|
||||
@router.put("/{slug}/image", response_model=UpdateImageResponse)
|
||||
def update_recipe_image(
|
||||
slug: str,
|
||||
image: bytes = File(...),
|
||||
extension: str = Form(...),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Removes an existing image and replaces it with the incoming file."""
|
||||
db = get_repositories(session)
|
||||
write_image(slug, image, extension)
|
||||
new_version = db.recipes.update_image(slug, extension)
|
||||
|
||||
return UpdateImageResponse(image=new_version)
|
||||
|
||||
|
||||
@router.post("/{slug}/assets", response_model=RecipeAsset)
|
||||
def upload_recipe_asset(
|
||||
slug: str,
|
||||
name: str = Form(...),
|
||||
icon: str = Form(...),
|
||||
extension: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Upload a file to store as a recipe asset"""
|
||||
file_name = slugify(name) + "." + extension
|
||||
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
|
||||
dest = Recipe(slug=slug).asset_dir.joinpath(file_name)
|
||||
|
||||
with dest.open("wb") as buffer:
|
||||
copyfileobj(file.file, buffer)
|
||||
|
||||
if not dest.is_file():
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
db = get_repositories(session)
|
||||
|
||||
recipe: Recipe = db.recipes.get(slug)
|
||||
recipe.assets.append(asset_in)
|
||||
db.recipes.update(slug, recipe.dict())
|
||||
return asset_in
|
||||
@@ -1,12 +1,14 @@
|
||||
from functools import cached_property
|
||||
from shutil import copyfileobj
|
||||
from zipfile import ZipFile
|
||||
|
||||
import sqlalchemy
|
||||
from fastapi import BackgroundTasks, Depends, File, HTTPException
|
||||
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, status
|
||||
from fastapi.datastructures import UploadFile
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from slugify import slugify
|
||||
from sqlalchemy.orm.session import Session
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
@@ -22,8 +24,10 @@ from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
|
||||
from mealie.schema.recipe.recipe_asset import RecipeAsset
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
from mealie.schema.server.tasks import ServerTaskNames
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
from mealie.services.recipe.recipe_service import RecipeService
|
||||
from mealie.services.recipe.template_service import TemplateService
|
||||
from mealie.services.scraper.scraper import create_from_url
|
||||
@@ -49,6 +53,10 @@ class RecipeGetAll(GetAll):
|
||||
load_food: bool = False
|
||||
|
||||
|
||||
class UpdateImageResponse(BaseModel):
|
||||
image: str
|
||||
|
||||
|
||||
class FormatResponse(BaseModel):
|
||||
jjson: list[str] = Field(..., alias="json")
|
||||
zip: list[str]
|
||||
@@ -158,10 +166,9 @@ class RecipeController(BaseRecipeController):
|
||||
@router.post("/test-scrape-url")
|
||||
def test_parse_recipe_url(self, url: CreateRecipeByUrl):
|
||||
# Debugger should produce the same result as the scraper sees before cleaning
|
||||
scraped_data = RecipeScraperPackage(url.url).scrape_url()
|
||||
|
||||
if scraped_data:
|
||||
if scraped_data := RecipeScraperPackage(url.url).scrape_url():
|
||||
return scraped_data.schema.data
|
||||
|
||||
return "recipe_scrapers was unable to scrape this URL"
|
||||
|
||||
@router.post("/create-from-zip", status_code=201)
|
||||
@@ -217,6 +224,12 @@ class RecipeController(BaseRecipeController):
|
||||
self.deps.logger.error("SQL Integrity Error on recipe controller action")
|
||||
raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists"))
|
||||
|
||||
case _:
|
||||
self.deps.logger.error("Unknown Error on recipe controller action")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=ErrorResponse.respond(message="Unknown Error", exception=ex)
|
||||
)
|
||||
|
||||
@router.put("/{slug}")
|
||||
def update_one(self, slug: str, data: Recipe):
|
||||
"""Updates a recipe by existing slug and data."""
|
||||
@@ -243,3 +256,51 @@ class RecipeController(BaseRecipeController):
|
||||
return self.service.delete_one(slug)
|
||||
except Exception as e:
|
||||
self.handle_exceptions(e)
|
||||
|
||||
# ==================================================================================================================
|
||||
# Image and Assets
|
||||
|
||||
@router.post("/{slug}/image", tags=["Recipe: Images and Assets"])
|
||||
def scrape_image_url(self, slug: str, url: CreateRecipeByUrl) -> str:
|
||||
recipe = self.mixins.get_one(slug)
|
||||
data_service = RecipeDataService(recipe.id)
|
||||
data_service.scrape_image(url.url)
|
||||
|
||||
@router.put("/{slug}/image", response_model=UpdateImageResponse, tags=["Recipe: Images and Assets"])
|
||||
def update_recipe_image(self, slug: str, image: bytes = File(...), extension: str = Form(...)):
|
||||
recipe = self.mixins.get_one(slug)
|
||||
data_service = RecipeDataService(recipe.id)
|
||||
data_service.write_image(image, extension)
|
||||
|
||||
new_version = self.repo.update_image(slug, extension)
|
||||
return UpdateImageResponse(image=new_version)
|
||||
|
||||
@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
|
||||
def upload_recipe_asset(
|
||||
self,
|
||||
slug: str,
|
||||
name: str = Form(...),
|
||||
icon: str = Form(...),
|
||||
extension: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Upload a file to store as a recipe asset"""
|
||||
file_name = slugify(name) + "." + extension
|
||||
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
|
||||
|
||||
recipe = self.mixins.get_one(slug)
|
||||
|
||||
dest = recipe.asset_dir / file_name
|
||||
|
||||
with dest.open("wb") as buffer:
|
||||
copyfileobj(file.file, buffer)
|
||||
|
||||
if not dest.is_file():
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
recipe: Recipe = self.mixins.get_one(slug)
|
||||
recipe.assets.append(asset_in)
|
||||
|
||||
self.mixins.update_one(recipe, slug)
|
||||
|
||||
return asset_in
|
||||
|
||||
@@ -22,7 +22,7 @@ class RecipeSharedController(BaseUserController):
|
||||
return CrudMixins[RecipeShareTokenSave, RecipeShareToken, RecipeShareTokenCreate](self.repo, self.deps.logger)
|
||||
|
||||
@router.get("", response_model=list[RecipeShareTokenSummary])
|
||||
def get_all(self, recipe_id: int = None):
|
||||
def get_all(self, recipe_id: UUID4 = None):
|
||||
if recipe_id:
|
||||
return self.repo.multi_query({"recipe_id": recipe_id}, override_schema=RecipeShareTokenSummary)
|
||||
else:
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.schema.recipe import RecipeTagResponse, TagIn
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["Tags: CRUD"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class TagController(BaseUserController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.tags
|
||||
|
||||
@router.get("")
|
||||
async def get_all_recipe_tags(self):
|
||||
"""Returns a list of available tags in the database"""
|
||||
return self.repo.get_all_limit_columns(["slug", "name"])
|
||||
|
||||
@router.get("/empty")
|
||||
def get_empty_tags(self):
|
||||
"""Returns a list of tags that do not contain any recipes"""
|
||||
return self.repo.get_empty()
|
||||
|
||||
@router.get("/{tag_slug}", response_model=RecipeTagResponse)
|
||||
def get_all_recipes_by_tag(self, tag_slug: str):
|
||||
"""Returns a list of recipes associated with the provided tag."""
|
||||
return self.repo.get_one(tag_slug, override_schema=RecipeTagResponse)
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_recipe_tag(self, tag: TagIn):
|
||||
"""Creates a Tag in the database"""
|
||||
return self.repo.create(tag)
|
||||
|
||||
@router.put("/{tag_slug}", response_model=RecipeTagResponse)
|
||||
def update_recipe_tag(self, tag_slug: str, new_tag: TagIn):
|
||||
"""Updates an existing Tag in the database"""
|
||||
return self.repo.update(tag_slug, new_tag)
|
||||
|
||||
@router.delete("/{tag_slug}")
|
||||
def delete_recipe_tag(self, tag_slug: str):
|
||||
"""Removes a recipe tag from the database. Deleting a
|
||||
tag does not impact a recipe. The tag will be removed
|
||||
from any recipes that contain it"""
|
||||
|
||||
try:
|
||||
self.repo.delete(tag_slug)
|
||||
except Exception:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
@@ -1,6 +1,7 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.abc_controller import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
@@ -36,13 +37,13 @@ class IngredientFoodsController(BaseUserController):
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=IngredientFood)
|
||||
def get_one(self, item_id: int):
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=IngredientFood)
|
||||
def update_one(self, item_id: int, data: CreateIngredientFood):
|
||||
def update_one(self, item_id: UUID4, data: CreateIngredientFood):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=IngredientFood)
|
||||
def delete_one(self, item_id: int):
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.abc_controller import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
@@ -36,13 +37,13 @@ class IngredientUnitsController(BaseUserController):
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=IngredientUnit)
|
||||
def get_one(self, item_id: int):
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=IngredientUnit)
|
||||
def update_one(self, item_id: int, data: CreateIngredientUnit):
|
||||
def update_one(self, item_id: UUID4, data: CreateIngredientUnit):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=IngredientUnit)
|
||||
def delete_one(self, item_id: int):
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id) # type: ignore
|
||||
|
||||
@@ -4,13 +4,12 @@ from pathlib import Path
|
||||
from fastapi import Depends, File, HTTPException, UploadFile, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie import utils
|
||||
from mealie.core.dependencies.dependencies import temporary_dir
|
||||
from mealie.pkgs import cache, img
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.user import PrivateUser
|
||||
from mealie.services.image import minify
|
||||
|
||||
router = UserAPIRouter(prefix="", tags=["Users: Images"])
|
||||
|
||||
@@ -31,12 +30,12 @@ class UserImageController(BaseUserController):
|
||||
with temp_img.open("wb") as buffer:
|
||||
shutil.copyfileobj(profile.file, buffer)
|
||||
|
||||
image = minify.to_webp(temp_img)
|
||||
image = img.PillowMinifier.to_webp(temp_img)
|
||||
dest = PrivateUser.get_directory(id) / "profile.webp"
|
||||
|
||||
shutil.copyfile(image, dest)
|
||||
|
||||
self.repos.users.patch(id, {"cache_key": utils.new_cache_key()})
|
||||
self.repos.users.patch(id, {"cache_key": cache.new_key()})
|
||||
|
||||
if not dest.is_file:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
Reference in New Issue
Block a user