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:
Hayden
2022-02-13 12:23:42 -09:00
committed by GitHub
parent 9a82a172cb
commit c617251f4c
157 changed files with 1866 additions and 1578 deletions

View File

@@ -9,8 +9,6 @@ DATA_DIR = determine_data_dir()
from .config import get_app_settings
settings = get_app_settings()
LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s"
@@ -27,6 +25,8 @@ class LoggerConfig:
@lru_cache
def get_logger_config():
settings = get_app_settings()
if not settings.PRODUCTION:
from rich.logging import RichHandler
@@ -69,7 +69,6 @@ def logger_init() -> logging.Logger:
root_logger = logger_init()
root_logger.info("Testing Root Logger")
def get_logger(module=None) -> logging.Logger:

View File

@@ -12,7 +12,12 @@ def sql_global_init(db_url: str):
if "sqlite" in db_url:
connect_args["check_same_thread"] = False
engine = sa.create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True)
engine = sa.create_engine(
db_url,
echo=False,
connect_args=connect_args,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@@ -58,6 +58,9 @@ class Group(SqlAlchemyBase, BaseMixins):
# Owned Models
ingredient_units = orm.relationship("IngredientUnitModel", **common_args)
ingredient_foods = orm.relationship("IngredientFoodModel", **common_args)
tools = orm.relationship("Tool", **common_args)
tags = orm.relationship("Tag", **common_args)
categories = orm.relationship("Category", **common_args)
class Config:
exclude = {

View File

@@ -1,5 +1,4 @@
from sqlalchemy import Column, Date, ForeignKey, String, orm
from sqlalchemy.sql.sqltypes import Integer
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
@@ -36,7 +35,7 @@ class GroupMealPlan(SqlAlchemyBase, BaseMixins):
group_id = Column(GUID, ForeignKey("groups.id"), index=True)
group = orm.relationship("Group", back_populates="mealplans")
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="meal_entries", uselist=False)
@auto_init()

View File

@@ -9,7 +9,7 @@ from .._model_utils import auto_init
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences"
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True)

View File

@@ -38,7 +38,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins):
entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan")
# Relationships
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="group_reports", single_parent=True)
class Config:

View File

@@ -13,7 +13,7 @@ class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
id = Column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item_id = Column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity = Column(Float, nullable=False)
@@ -40,10 +40,10 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
is_food = Column(Boolean, default=False)
# Scaling Items
unit_id = Column(Integer, ForeignKey("ingredient_units.id"))
unit_id = Column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(Integer, ForeignKey("ingredient_foods.id"))
food_id = Column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False)
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id"))
@@ -66,7 +66,7 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs")
recipe_quantity = Column(Float, nullable=False)
@@ -83,7 +83,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id = Column(GUID, primary_key=True, default=GUID.generate)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String)

View File

@@ -12,7 +12,7 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
name = Column(String(255), nullable=False)
color = Column(String(10), nullable=False, default="")
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="labels")
shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label")

View File

@@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class ApiExtras(SqlAlchemyBase):
__tablename__ = "api_extras"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipee_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
key_name = sa.Column(sa.String)
value = sa.Column(sa.String)

View File

@@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
name = sa.Column(sa.String)
icon = sa.Column(sa.String)
file_name = sa.Column(sa.String)

View File

@@ -15,47 +15,51 @@ group2categories = sa.Table(
"group2categories",
SqlAlchemyBase.metadata,
sa.Column("group_id", GUID, sa.ForeignKey("groups.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
plan_rules_to_categories = sa.Table(
"plan_rules_to_categories",
SqlAlchemyBase.metadata,
sa.Column("group_plan_rule_id", GUID, sa.ForeignKey("group_meal_plan_rules.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
recipes2categories = sa.Table(
"recipes2categories",
recipes_to_categories = sa.Table(
"recipes_to_categories",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("recipe_id", GUID, sa.ForeignKey("recipes.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
cookbooks_to_categories = sa.Table(
"cookbooks_to_categories",
SqlAlchemyBase.metadata,
sa.Column("cookbook_id", sa.Integer, sa.ForeignKey("cookbooks.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
sa.Column("category_id", GUID, sa.ForeignKey("categories.id")),
)
class Category(SqlAlchemyBase, BaseMixins):
__tablename__ = "categories"
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes2categories, back_populates="recipe_category")
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
class Config:
get_attr = "slug"
# ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category")
@validates("name")
def validate_name(self, key, name):
assert name != ""
return name
def __init__(self, name, **_) -> None:
def __init__(self, name, group_id, **_) -> None:
self.group_id = group_id
self.name = name.strip()
self.slug = slugify(name)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm
from sqlalchemy import Column, ForeignKey, String, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
@@ -11,7 +11,7 @@ class RecipeComment(SqlAlchemyBase, BaseMixins):
text = Column(String)
# Recipe Link
recipe_id = Column(Integer, ForeignKey("recipes.id"), nullable=False)
recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments")
# User Link

View File

@@ -9,12 +9,12 @@ from .._model_utils.guid import GUID
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units"
id = Column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
abbreviation = Column(String)
@@ -28,12 +28,12 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods"
id = Column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
ingredients = orm.relationship("RecipeIngredient", back_populates="food")
@@ -50,16 +50,16 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients"
id = Column(Integer, primary_key=True)
position = Column(Integer)
parent_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"))
title = Column(String) # Section Header - Shows if Present
note = Column(String) # Force Show Text - Overrides Concat
# Scaling Items
unit_id = Column(Integer, ForeignKey("ingredient_units.id"))
unit_id = Column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(Integer, ForeignKey("ingredient_foods.id"))
food_id = Column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False)
quantity = Column(Integer)

View File

@@ -18,7 +18,7 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions"
id = Column(GUID, primary_key=True, default=GUID.generate)
parent_id = Column(Integer, ForeignKey("recipes.id"))
recipe_id = Column(GUID, ForeignKey("recipes.id"))
position = Column(Integer)
type = Column(String, default="")
title = Column(String)

View File

@@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class Note(SqlAlchemyBase):
__tablename__ = "notes"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
title = sa.Column(sa.String)
text = sa.Column(sa.String)

View File

@@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
calories = sa.Column(sa.String)
fat_content = sa.Column(sa.String)
fiber_content = sa.Column(sa.String)

View File

@@ -13,14 +13,14 @@ from .._model_utils import auto_init
from ..users import users_to_favorites
from .api_extras import ApiExtras
from .assets import RecipeAsset
from .category import recipes2categories
from .category import recipes_to_categories
from .ingredient import RecipeIngredient
from .instruction import RecipeInstruction
from .note import Note
from .nutrition import Nutrition
from .settings import RecipeSettings
from .shared import RecipeShareTokenModel
from .tag import Tag, recipes2tags
from .tag import Tag, recipes_to_tags
from .tool import recipes_to_tools
@@ -43,13 +43,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
id = sa.Column(GUID, primary_key=True, default=GUID.generate)
slug = sa.Column(sa.String, index=True)
# ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
user_id = sa.Column(GUID, sa.ForeignKey("users.id"))
user_id = sa.Column(GUID, sa.ForeignKey("users.id"), index=True)
user = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan")
@@ -72,7 +73,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
recipe_category = orm.relationship("Category", secondary=recipes_to_categories, back_populates="recipes")
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship(
@@ -96,7 +97,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Mealie Specific
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
rating = sa.Column(sa.Integer)
org_url = sa.Column(sa.String)

View File

@@ -1,12 +1,13 @@
import sqlalchemy as sa
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"))
public = sa.Column(sa.Boolean)
show_nutrition = sa.Column(sa.Boolean)
show_assets = sa.Column(sa.Boolean)

View File

@@ -15,9 +15,9 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens"
id = sa.Column(GUID, primary_key=True, default=uuid4)
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"))
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"), nullable=False)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), nullable=False)
recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at = sa.Column(sa.DateTime, nullable=False)

View File

@@ -9,27 +9,33 @@ from mealie.db.models._model_utils import guid
logger = root_logger.get_logger()
recipes2tags = sa.Table(
"recipes2tags",
recipes_to_tags = sa.Table(
"recipes_to_tags",
SqlAlchemyBase.metadata,
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
sa.Column("recipe_id", guid.GUID, sa.ForeignKey("recipes.id")),
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
)
plan_rules_to_tags = sa.Table(
"plan_rules_to_tags",
SqlAlchemyBase.metadata,
sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id")),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id")),
)
class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags"
id = sa.Column(sa.Integer, primary_key=True)
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
# ID Relationships
group_id = sa.Column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
id = sa.Column(guid.GUID, primary_key=True, default=guid.GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, unique=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes2tags, back_populates="tags")
slug = sa.Column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tags, back_populates="tags")
class Config:
get_attr = "slug"
@@ -39,7 +45,8 @@ class Tag(SqlAlchemyBase, BaseMixins):
assert name != ""
return name
def __init__(self, name, **_) -> None:
def __init__(self, name, group_id, **_) -> None:
self.group_id = group_id
self.name = name.strip()
self.slug = slugify(self.name)

View File

@@ -1,19 +1,27 @@
from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Table, orm
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init
from mealie.db.models._model_utils.guid import GUID
recipes_to_tools = Table(
"recipes_to_tools",
SqlAlchemyBase.metadata,
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("tool_id", Integer, ForeignKey("tools.id")),
Column("recipe_id", GUID, ForeignKey("recipes.id")),
Column("tool_id", GUID, ForeignKey("tools.id")),
)
class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools"
id = Column(GUID, primary_key=True, default=GUID.generate)
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
# ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name = Column(String, index=True, unique=True, nullable=False)
slug = Column(String, index=True, unique=True, nullable=False)
on_hand = Column(Boolean, default=False)

View File

@@ -13,7 +13,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins):
status = Column(String, nullable=False)
log = Column(String, nullable=True)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="server_tasks")
@auto_init()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer, Table
from sqlalchemy import Column, ForeignKey, Table
from .._model_base import SqlAlchemyBase
from .._model_utils import GUID
@@ -7,5 +7,5 @@ users_to_favorites = Table(
"users_to_favorites",
SqlAlchemyBase.metadata,
Column("user_id", GUID, ForeignKey("users.id")),
Column("recipe_id", Integer, ForeignKey("recipes.id")),
Column("recipe_id", GUID, ForeignKey("recipes.id")),
)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from sqlalchemy import Boolean, Column, ForeignKey, String, orm
from mealie.core.config import get_app_settings
from mealie.db.models._model_utils.guid import GUID
@@ -33,7 +33,7 @@ class User(SqlAlchemyBase, BaseMixins):
admin = Column(Boolean, default=False)
advanced = Column(Boolean, default=False)
group_id = Column(GUID, ForeignKey("groups.id"))
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234")
@@ -53,7 +53,7 @@ class User(SqlAlchemyBase, BaseMixins):
comments = orm.relationship("RecipeComment", **sp_args)
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args)
owned_recipes_id = Column(Integer, ForeignKey("recipes.id"))
owned_recipes_id = Column(GUID, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id])
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")

1
mealie/pkgs/cache/__init__.py vendored Normal file
View File

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

View File

@@ -2,8 +2,7 @@ import random
import string
def new_cache_key(length=4) -> str:
def new_key(length=4) -> str:
"""returns a 4 character string to be used as a cache key for frontend data"""
options = string.ascii_letters + string.digits
return "".join(random.choices(options, k=length))

View File

@@ -0,0 +1,7 @@
"""
This package containers helpful development tools to be used for development and testing. It shouldn't be used for or imported
in production
"""
from .lifespan_tracker import *
from .timer import *

12
mealie/pkgs/dev/timer.py Normal file
View File

@@ -0,0 +1,12 @@
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start} seconds") # noqa: T001
return result
return wrapper

View File

@@ -0,0 +1,7 @@
"""
The img package is a collection of utilities for working with images. While it offers some Mealie specific functionality, libraries
within the img package should not be tightly coupled to Mealie.
"""
from .minify import *

130
mealie/pkgs/img/minify.py Normal file
View File

@@ -0,0 +1,130 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from logging import Logger
from pathlib import Path
from PIL import Image
WEBP = ".webp"
FORMAT = "WEBP"
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
def get_format(image: Path) -> str:
img = Image.open(image)
return img.format
def sizeof_fmt(file_path: Path, decimal_places=2):
if not file_path.exists():
return "(File Not Found)"
size = file_path.stat().st_size
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
@dataclass
class MinifierOptions:
original: bool = True
minature: bool = True
tiny: bool = True
class ABCMinifier(ABC):
def __init__(self, purge=False, opts: MinifierOptions = None, logger: Logger = None):
self._purge = purge
self._opts = opts or MinifierOptions()
self._logger = logger or Logger("Minifier")
def get_image_sizes(self, org_img: Path, min_img: Path, tiny_img: Path):
self._logger.info(
f"{org_img.name} Minified: {sizeof_fmt(org_img)} -> {sizeof_fmt(min_img)} -> {sizeof_fmt(tiny_img)}"
)
@abstractmethod
def minify(self, image: Path, force=True):
...
def purge(self, image: Path):
if not self._purge:
return
for file in image.parent.glob("*.*"):
if file.suffix != WEBP:
file.unlink()
class PillowMinifier(ABCMinifier):
@staticmethod
def to_webp(image_file: Path, dest: Path = None, quality: int = 100) -> Path:
"""
Converts an image to the webp format in-place. The original image is not
removed By default, the quality is set to 100.
"""
if image_file.suffix == WEBP:
return image_file
img = Image.open(image_file)
dest = dest or image_file.with_suffix(WEBP)
img.save(dest, FORMAT, quality=quality)
return dest
@staticmethod
def crop_center(pil_img: Image, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
return pil_img.crop(
(
(img_width - crop_width) // 2,
(img_height - crop_height) // 2,
(img_width + crop_width) // 2,
(img_height + crop_height) // 2,
)
)
def minify(self, image_file: Path, force=True):
if not image_file.exists():
raise FileNotFoundError(f"{image_file.name} does not exist")
org_dest = image_file.parent.joinpath("original.webp")
min_dest = image_file.parent.joinpath("min-original.webp")
tiny_dest = image_file.parent.joinpath("tiny-original.webp")
if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists():
self._logger.info(f"{image_file.name} already minified")
return
success = False
if self._opts.original:
if not force and org_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
PillowMinifier.to_webp(image_file, org_dest, quality=70)
success = True
if self._opts.minature:
if not force and min_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
PillowMinifier.to_webp(image_file, min_dest, quality=70)
self._logger.info(f"{image_file.name} minified")
success = True
if self._opts.tiny:
if not force and tiny_dest.exists():
self._logger.info(f"{image_file.name} already minified")
else:
img = Image.open(image_file)
tiny_image = PillowMinifier.crop_center(img)
tiny_image.save(tiny_dest, FORMAT, quality=70)
self._logger.info("Tiny image saved")
success = True
if self._purge and success:
self.purge(image_file)

View File

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

View File

@@ -46,7 +46,8 @@ from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.labels import MultiPurposeLabelOut
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.meal_plan.plan_rules import PlanRulesOut
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
from mealie.schema.recipe import Recipe, RecipeCommentOut, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
@@ -67,12 +68,12 @@ PK_TOKEN = "token"
PK_GROUP_ID = "group_id"
class RepositoryCategories(RepositoryGeneric):
class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]):
def get_empty(self):
return self.session.query(Category).filter(~Category.recipes.any()).all()
class RepositoryTags(RepositoryGeneric):
class RepositoryTags(RepositoryGeneric[TagOut, Tag]):
def get_empty(self):
return self.session.query(Tag).filter(~Tag.recipes.any()).all()
@@ -114,11 +115,11 @@ class AllRepositories:
@cached_property
def categories(self) -> RepositoryCategories:
# TODO: Fix Typing for Category Repository
return RepositoryCategories(self.session, PK_SLUG, Category, RecipeCategoryResponse)
return RepositoryCategories(self.session, PK_ID, Category, CategoryOut)
@cached_property
def tags(self) -> RepositoryTags:
return RepositoryTags(self.session, PK_SLUG, Tag, RecipeTagResponse)
return RepositoryTags(self.session, PK_ID, Tag, TagOut)
@cached_property
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:

View File

@@ -11,7 +11,7 @@ from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag
from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag
from .repository_generic import RepositoryGeneric
@@ -89,7 +89,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.all()
)
def get_by_categories(self, categories: list[RecipeCategory]) -> list[Recipe]:
def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSummary]:
"""
get_by_categories returns all the Recipes that contain every category provided in the list
"""
@@ -97,7 +97,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
ids = [x.id for x in categories]
return [
self.schema.from_orm(x)
RecipeSummary.from_orm(x)
for x in self.session.query(RecipeModel)
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
@@ -120,13 +120,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
if categories:
cat_ids = [x.id for x in categories]
for cat_id in cat_ids:
filters.append(RecipeModel.recipe_category.any(Category.id.is_(cat_id)))
filters.extend(RecipeModel.recipe_category.any(Category.id.is_(cat_id)) for cat_id in cat_ids)
if tags:
tag_ids = [x.id for x in tags]
for tag_id in tag_ids:
filters.append(RecipeModel.tags.any(Tag.id.is_(tag_id)))
filters.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids)
return [
self.schema.from_orm(x)

View File

@@ -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)

View File

@@ -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()

View File

@@ -1,6 +0,0 @@
from fastapi import APIRouter
from . import categories
router = APIRouter()
router.include_router(categories.router)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View 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)

View 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]),
)

View 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)

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -28,7 +28,6 @@ init() {
# Initialize Database Prerun
poetry run python /app/mealie/db/init_db.py
poetry run python /app/mealie/services/image/minify.py
}
# Migrations

View File

@@ -9,7 +9,7 @@ from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUni
class ShoppingListItemRecipeRef(CamelModel):
recipe_id: int
recipe_id: UUID4
recipe_quantity: float
@@ -30,9 +30,9 @@ class ShoppingListItemCreate(CamelModel):
note: Optional[str] = ""
quantity: float = 1
unit_id: int = None
unit_id: UUID4 = None
unit: Optional[IngredientUnit]
food_id: int = None
food_id: UUID4 = None
food: Optional[IngredientFood]
label_id: Optional[UUID4] = None
@@ -58,7 +58,7 @@ class ShoppingListCreate(CamelModel):
class ShoppingListRecipeRefOut(CamelModel):
id: UUID4
shopping_list_id: UUID4
recipe_id: int
recipe_id: UUID4
recipe_quantity: float
recipe: RecipeSummary

View File

@@ -26,7 +26,7 @@ class CreatePlanEntry(CamelModel):
entry_type: PlanEntryType = PlanEntryType.breakfast
title: str = ""
text: str = ""
recipe_id: Optional[int]
recipe_id: Optional[UUID]
@validator("recipe_id", always=True)
@classmethod

View File

@@ -6,7 +6,7 @@ from pydantic import UUID4
class Category(CamelModel):
id: int
id: UUID4
name: str
slug: str

View File

@@ -24,7 +24,7 @@ app_dirs = get_app_dirs()
class RecipeTag(CamelModel):
id: int = 0
id: UUID4 = None
name: str
slug: str
@@ -37,7 +37,7 @@ class RecipeCategory(RecipeTag):
class RecipeTool(RecipeTag):
id: int = 0
id: UUID4
on_hand: bool = False
@@ -63,7 +63,7 @@ class CreateRecipe(CamelModel):
class RecipeSummary(CamelModel):
id: Optional[int]
id: Optional[UUID4]
user_id: UUID4 = Field(default_factory=uuid4)
group_id: UUID4 = Field(default_factory=uuid4)
@@ -96,13 +96,13 @@ class RecipeSummary(CamelModel):
@validator("tags", always=True, pre=True, allow_reuse=True)
def validate_tags(cats: list[Any]): # type: ignore
if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeTag(name=c, slug=slugify(c)) for c in cats]
return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats
@validator("recipe_category", always=True, pre=True, allow_reuse=True)
def validate_categories(cats: list[Any]): # type: ignore
if isinstance(cats, list) and cats and isinstance(cats[0], str):
return [RecipeCategory(name=c, slug=slugify(c)) for c in cats]
return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats
@validator("group_id", always=True, pre=True, allow_reuse=True)
@@ -132,12 +132,15 @@ class Recipe(RecipeSummary):
comments: Optional[list[RecipeCommentOut]] = []
@staticmethod
def directory_from_slug(slug) -> Path:
return app_dirs.RECIPE_DATA_DIR.joinpath(slug)
def directory_from_id(recipe_id: UUID4 | str) -> Path:
return app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id))
@property
def directory(self) -> Path:
dir = app_dirs.RECIPE_DATA_DIR.joinpath(self.slug)
if not self.id:
raise ValueError("Recipe has no ID")
dir = app_dirs.RECIPE_DATA_DIR.joinpath(str(self.id))
dir.mkdir(exist_ok=True, parents=True)
return dir

View File

@@ -1,4 +1,5 @@
from fastapi_camelcase import CamelModel
from pydantic import UUID4
from pydantic.utils import GetterDict
@@ -6,8 +7,12 @@ class CategoryIn(CamelModel):
name: str
class CategorySave(CategoryIn):
group_id: UUID4
class CategoryBase(CategoryIn):
id: int
id: UUID4
slug: str
class Config:
@@ -20,27 +25,45 @@ class CategoryBase(CategoryIn):
}
class RecipeCategoryResponse(CategoryBase):
recipes: "list[Recipe]" = []
class CategoryOut(CategoryBase):
slug: str
class Config:
orm_mode = True
class RecipeCategoryResponse(CategoryBase):
recipes: "list[RecipeSummary]" = []
class Config:
orm_mode = True
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}
class TagIn(CategoryIn):
pass
class TagSave(TagIn):
group_id: UUID4
class TagBase(CategoryBase):
pass
class TagOut(TagSave):
id: UUID4
slug: str
class Config:
orm_mode = True
class RecipeTagResponse(RecipeCategoryResponse):
pass
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeSummary
RecipeCategoryResponse.update_forward_refs()
RecipeTagResponse.update_forward_refs()

View File

@@ -16,7 +16,7 @@ class UserBase(CamelModel):
class RecipeCommentCreate(CamelModel):
recipe_id: int
recipe_id: UUID4
text: str
@@ -31,7 +31,7 @@ class RecipeCommentUpdate(CamelModel):
class RecipeCommentOut(RecipeCommentCreate):
id: UUID
recipe_id: int
recipe_id: UUID4
created_at: datetime
update_at: datetime
user_id: UUID4

View File

@@ -14,7 +14,7 @@ class UnitFoodBase(CamelModel):
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 = None
label_id: Optional[UUID4] = None
class SaveIngredientFood(CreateIngredientFood):
@@ -22,7 +22,7 @@ class SaveIngredientFood(CreateIngredientFood):
class IngredientFood(CreateIngredientFood):
id: int
id: UUID4
label: MultiPurposeLabelSummary = None
class Config:
@@ -39,7 +39,7 @@ class SaveIngredientUnit(CreateIngredientUnit):
class IngredientUnit(CreateIngredientUnit):
id: int
id: UUID4
class Config:
orm_mode = True

View File

@@ -11,7 +11,7 @@ def defaut_expires_at_time() -> datetime:
class RecipeShareTokenCreate(CamelModel):
recipe_id: int
recipe_id: UUID4
expires_at: datetime = Field(default_factory=defaut_expires_at_time)

View File

@@ -1,6 +1,7 @@
from typing import List
from fastapi_camelcase import CamelModel
from pydantic import UUID4
class RecipeToolCreate(CamelModel):
@@ -8,8 +9,12 @@ class RecipeToolCreate(CamelModel):
on_hand: bool = False
class RecipeToolSave(RecipeToolCreate):
group_id: UUID4
class RecipeTool(RecipeToolCreate):
id: int
id: UUID4
slug: str
class Config:

View File

@@ -12,7 +12,6 @@ from mealie.repos.all_repositories import get_repositories
from mealie.schema.admin import CommentImport, GroupImport, RecipeImport, UserImport
from mealie.schema.recipe import Recipe, RecipeCommentOut
from mealie.schema.user import PrivateUser, UpdateGroup
from mealie.services.image import minify
app_dirs = get_app_dirs()
@@ -156,8 +155,6 @@ class ImportDatabase:
recipe_dir = self.import_dir.joinpath("recipes")
shutil.copytree(recipe_dir, app_dirs.RECIPE_DATA_DIR, dirs_exist_ok=True)
minify.migrate_images()
def import_settings(self):
return []

View File

@@ -4,10 +4,10 @@ import zipfile
from pathlib import Path
from uuid import UUID, uuid4
from mealie.pkgs.stats.fs_stats import pretty_size
from mealie.repos.all_repositories import AllRepositories
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.user import GroupInDB
from mealie.utils.fs_stats import pretty_size
from .._base_service import BaseService
from ._abc_exporter import ABCExporter

View File

@@ -100,7 +100,7 @@ class ShoppingListService:
# =======================================================================
# Methods
def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: UUID4) -> ShoppingListOut:
recipe = self.repos.recipes.get_one(recipe_id, "id")
to_create = []
@@ -161,7 +161,7 @@ class ShoppingListService:
return updated_list
def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: UUID4) -> ShoppingListOut:
shopping_list = self.shopping_lists.get_one(list_id)
for item in shopping_list.list_items:

View File

@@ -1,81 +0,0 @@
import shutil
from pathlib import Path
import requests
from mealie.core import root_logger
from mealie.schema.recipe import Recipe
from mealie.services.image import minify
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path:
image_dir = Recipe(slug=recipe_slug).image_dir
extension = extension.replace(".", "")
image_path = image_dir.joinpath(f"original.{extension}")
image_path.unlink(missing_ok=True)
if isinstance(file_data, Path):
shutil.copy2(file_data, image_path)
elif isinstance(file_data, bytes):
with open(image_path, "ab") as f:
f.write(file_data)
else:
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
minify.minify_image(image_path, force=True)
return image_path
def scrape_image(image_url: str, slug: str) -> Path:
logger = root_logger.get_logger()
logger.info(f"Image URL: {image_url}")
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"
if isinstance(image_url, str): # Handles String Types
pass
if isinstance(image_url, list): # Handles List Types
# Multiple images have been defined in the schema - usually different resolutions
# Typically would be in smallest->biggest order, but can't be certain so test each.
# 'Google will pick the best image to display in Search results based on the aspect ratio and resolution.'
all_image_requests = []
for url in image_url:
if isinstance(url, dict):
url = url.get("url", "")
try:
r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA})
except Exception:
logger.exception("Image {url} could not be requested")
continue
if r.status_code == 200:
all_image_requests.append((url, r))
image_url, _ = max(all_image_requests, key=lambda url_r: len(url_r[1].content), default=("", 0))
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = Recipe(slug=slug).image_dir.joinpath(filename)
try:
r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA})
except Exception:
logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
logger.info(f"File Name Suffix {filename.suffix}")
write_image(slug, r.raw, filename.suffix)
filename.unlink(missing_ok=True)
return Path(slug)
return None

View File

@@ -1,149 +0,0 @@
import shutil
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
from mealie.core import root_logger
from mealie.core.config import get_app_dirs
from mealie.schema.recipe import Recipe
logger = root_logger.get_logger()
app_dirs = get_app_dirs()
@dataclass
class ImageSizes:
org: str
min: str
tiny: str
def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes:
return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img))
def to_webp(image_file: Path, quality: int = 100) -> Path:
"""
Converts an image to the webp format in-place. The original image is not
removed By default, the quality is set to 100.
"""
if image_file.suffix == ".webp":
return image_file
img = Image.open(image_file)
dest = image_file.with_suffix(".webp")
img.save(dest, "WEBP", quality=quality)
return dest
def minify_image(image_file: Path, force=False) -> ImageSizes:
"""Minifies an image in it's original file format. Quality is lost
Args:
my_path (Path): Source Files
min_dest (Path): FULL Destination File Path
tiny_dest (Path): FULL Destination File Path
"""
def cleanup(dir: Path) -> None:
for file in dir.glob("*.*"):
if file.suffix != ".webp":
file.unlink()
org_dest = image_file.parent.joinpath("original.webp")
min_dest = image_file.parent.joinpath("min-original.webp")
tiny_dest = image_file.parent.joinpath("tiny-original.webp")
cleanup_images = False
if min_dest.exists() and tiny_dest.exists() and org_dest.exists() and not force:
return
try:
img = Image.open(image_file)
img.save(org_dest, "WEBP")
basewidth = 720
wpercent = basewidth / float(img.size[0])
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
img.save(min_dest, "WEBP", quality=70)
tiny_image = crop_center(img)
tiny_image.save(tiny_dest, "WEBP", quality=70)
cleanup_images = True
except Exception as e:
logger.error(e)
shutil.copy(image_file, min_dest)
shutil.copy(image_file, tiny_dest)
image_sizes = get_image_sizes(image_file, min_dest, tiny_dest)
logger.info(f"{image_file.name} Minified: {image_sizes.org} -> {image_sizes.min} -> {image_sizes.tiny}")
if cleanup_images:
cleanup(image_file.parent)
return image_sizes
def crop_center(pil_img, crop_width=300, crop_height=300):
img_width, img_height = pil_img.size
return pil_img.crop(
(
(img_width - crop_width) // 2,
(img_height - crop_height) // 2,
(img_width + crop_width) // 2,
(img_height + crop_height) // 2,
)
)
def sizeof_fmt(file_path: Path, decimal_places=2):
if not file_path.exists():
return "(File Not Found)"
size = file_path.stat().st_size
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
if size < 1024.0 or unit == "PiB":
break
size /= 1024.0
return f"{size:.{decimal_places}f} {unit}"
def move_all_images():
if not app_dirs.IMG_DIR.exists():
return
for image_file in app_dirs.IMG_DIR.iterdir():
if image_file.is_file():
if image_file.name == ".DS_Store":
continue
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
new_folder.mkdir(parents=True, exist_ok=True)
new_file = new_folder.joinpath(f"original{image_file.suffix}")
if new_file.is_file():
new_file.unlink()
image_file.rename(new_file)
if image_file.is_dir():
slug = image_file.name
image_file.rename(Recipe(slug=slug).image_dir)
def migrate_images():
logger.info("Checking for Images to Minify...")
move_all_images()
for image in app_dirs.RECIPE_DATA_DIR.glob("**/original.*"):
minify_image(image)
logger.info("Finished Minification Check")
if __name__ == "__main__":
migrate_images()

View File

@@ -29,6 +29,8 @@ class BaseMigrator(BaseService):
report_id: int
report: ReportOut
helpers: DatabaseMigrationHelpers
def __init__(
self, archive: Path, db: AllRepositories, session, user_id: UUID4, group_id: UUID, add_migration_tag: bool
):
@@ -94,7 +96,7 @@ class BaseMigrator(BaseService):
self._save_all_entries()
return self.db.group_reports.get(self.report_id)
def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> list[Tuple[str, bool]]:
def import_recipes_to_database(self, validated_recipes: list[Recipe]) -> list[Tuple[str, UUID4, bool]]:
"""
Used as a single access point to process a list of Recipe objects into the
database in a predictable way. If an error occurs the session is rolled back
@@ -114,13 +116,19 @@ class BaseMigrator(BaseService):
recipe.user_id = self.user_id
recipe.group_id = self.group_id
if recipe.tags:
recipe.tags = self.helpers.get_or_set_tags(x.name for x in recipe.tags)
if recipe.recipe_category:
recipe.recipe_category = self.helpers.get_or_set_category(x.name for x in recipe.recipe_category)
if self.add_migration_tag:
recipe.tags.append(migration_tag)
exception = ""
status = False
try:
self.db.recipes.create(recipe)
recipe = self.db.recipes.create(recipe)
status = True
except Exception as inst:
@@ -133,7 +141,7 @@ class BaseMigrator(BaseService):
else:
message = f"Failed to import {recipe.name}"
return_vars.append((recipe.slug, status))
return_vars.append((recipe.slug, recipe.id, status))
self.report_entries.append(
ReportEntryCreate(
@@ -181,16 +189,11 @@ class BaseMigrator(BaseService):
"""
recipe_dict = self.rewrite_alias(recipe_dict)
# Temporary hold out of recipe_dict
# temp_categories = recipe_dict["recipeCategory"]
# temp_tools = recipe_dict["tools"]
# temp_tasg = recipe_dict["tags"]
try:
del recipe_dict["id"]
except KeyError:
pass
recipe_dict = cleaner.clean(recipe_dict, url=recipe_dict.get("org_url", None))
# Reassign after cleaning
# recipe_dict["recipeCategory"] = temp_categories
# recipe_dict["tools"] = temp_tools
# recipe_dict["tags"] = temp_tasg
return Recipe(**recipe_dict)

View File

@@ -39,7 +39,7 @@ class ChowdownMigrator(BaseMigrator):
recipe_lookup = {r.slug: r for r in recipes}
for slug, status in results:
for slug, recipe_id, status in results:
if status:
try:
original_image = recipe_lookup.get(slug).image
@@ -47,4 +47,4 @@ class ChowdownMigrator(BaseMigrator):
except StopIteration:
continue
if cd_image:
import_image(cd_image, slug)
import_image(cd_image, recipe_id)

View File

@@ -32,6 +32,7 @@ class MealieAlphaMigrator(BaseMigrator):
del recipe["date_added"]
except Exception:
pass
# Migration from list to Object Type Data
try:
if "" in recipe["tags"]:
@@ -42,7 +43,6 @@ class MealieAlphaMigrator(BaseMigrator):
try:
if "" in recipe["categories"]:
recipe["categories"] = [cat for cat in recipe["categories"] if cat != ""]
except Exception:
pass
@@ -76,14 +76,11 @@ class MealieAlphaMigrator(BaseMigrator):
results = self.import_recipes_to_database(recipes)
recipe_model_lookup = {x.slug: x for x in recipes}
for slug, status in results:
for slug, recipe_id, status in results:
if not status:
continue
model = recipe_model_lookup.get(slug)
dest_dir = model.directory
dest_dir = Recipe.directory_from_id(recipe_id)
source_dir = recipe_lookup.get(slug)
if dest_dir.exists():

View File

@@ -65,8 +65,8 @@ class NextcloudMigrator(BaseMigrator):
all_statuses = self.import_recipes_to_database(all_recipes)
for slug, status in all_statuses:
for slug, recipe_id, status in all_statuses:
if status:
nc_dir: NextcloudDir = nextcloud_dirs[slug]
if nc_dir.image:
import_image(nc_dir.image, nc_dir.slug)
import_image(nc_dir.image, recipe_id)

View File

@@ -78,7 +78,7 @@ class PaprikaMigrator(BaseMigrator):
results = self.import_recipes_to_database(recipes)
for slug, status in results:
for slug, recipe_id, status in results:
if not status:
continue
@@ -88,6 +88,6 @@ class PaprikaMigrator(BaseMigrator):
with tempfile.NamedTemporaryFile(suffix=".jpeg") as temp_file:
temp_file.write(image.read())
path = Path(temp_file.name)
import_image(path, slug)
import_image(path, recipe_id)
except Exception as e:
self.logger.error(f"Failed to download image for {slug}: {e}")

View File

@@ -8,6 +8,7 @@ from mealie.repos.all_repositories import AllRepositories
from mealie.repos.repository_factory import RepositoryGeneric
from mealie.schema.recipe import RecipeCategory
from mealie.schema.recipe.recipe import RecipeTag
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave
T = TypeVar("T", bound=BaseModel)
@@ -19,7 +20,9 @@ class DatabaseMigrationHelpers:
self.session = session
self.db = db
def _get_or_set_generic(self, accessor: RepositoryGeneric, items: list[str], out_model: T) -> list[T]:
def _get_or_set_generic(
self, accessor: RepositoryGeneric, items: list[str], create_model: T, out_model: T
) -> list[T]:
"""
Utility model for getting or setting categories or tags. This will only work for those two cases.
@@ -30,22 +33,32 @@ class DatabaseMigrationHelpers:
for item_name in items:
slug_lookup = slugify(item_name)
item_model = accessor.get_one(slug_lookup, "slug", override_schema=out_model)
item_model = accessor.get_one(value=slug_lookup, key="slug", override_schema=out_model)
if not item_model:
item_model = accessor.create(
out_model(
create_model(
group_id=self.group_id,
name=item_name,
slug=slug_lookup,
)
)
items_out.append(item_model.dict())
return items_out
def get_or_set_category(self, categories: list[str]) -> list[RecipeCategory]:
return self._get_or_set_generic(self.db.categories, categories, RecipeCategory)
return self._get_or_set_generic(
self.db.categories.by_group(self.group_id),
categories,
CategorySave,
CategoryOut,
)
def get_or_set_tags(self, tags: list[str]) -> list[RecipeTag]:
return self._get_or_set_generic(self.db.tags, tags, RecipeTag)
return self._get_or_set_generic(
self.db.tags.by_group(self.group_id),
tags,
TagSave,
TagOut,
)

View File

@@ -2,8 +2,9 @@ import json
from pathlib import Path
import yaml
from pydantic import UUID4
from mealie.services.image import image
from mealie.services.recipe.recipe_data_service import RecipeDataService
class MigrationReaders:
@@ -26,8 +27,7 @@ class MigrationReaders:
with open(yaml_file, "r") as f:
contents = f.read().split("---")
recipe_data = {}
for _, document in enumerate(contents):
for document in contents:
# Check if None or Empty String
if document is None or document == "":
continue
@@ -81,9 +81,10 @@ def glob_walker(directory: Path, glob_str: str, return_parent=True) -> list[Path
return matches
def import_image(src: Path, dest_slug: str):
def import_image(src: Path, recipe_id: UUID4):
"""Read the successful migrations attribute and for each import the image
appropriately into the image directory. Minification is done in mass
after the migration occurs.
"""
image.write_image(dest_slug, src, extension=src.suffix)
data_service = RecipeDataService(recipe_id=recipe_id)
data_service.write_image(src, src.suffix)

View File

@@ -0,0 +1,108 @@
import shutil
from pathlib import Path
import requests
from pydantic import UUID4
from mealie.pkgs import img
from mealie.schema.recipe.recipe import Recipe
from mealie.services._base_service import BaseService
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"
class RecipeDataService(BaseService):
minifier: img.ABCMinifier
def __init__(self, recipe_id: UUID4, group_id: UUID4 = None) -> None:
"""
RecipeDataService is a service that consolidates the reading/writing actions related
to assets, and images for a recipe.
"""
super().__init__()
self.recipe_id = recipe_id
self.slug = group_id
self.minifier = img.PillowMinifier(purge=True, logger=self.logger)
self.dir_data = Recipe.directory_from_id(self.recipe_id)
self.dir_image = self.dir_data.joinpath("images")
self.dir_assets = self.dir_data.joinpath("assets")
self.dir_image.mkdir(parents=True, exist_ok=True)
self.dir_assets.mkdir(parents=True, exist_ok=True)
def delete_all_data(self) -> None:
try:
shutil.rmtree(self.dir_data)
except Exception as e:
self.logger.exception(f"Failed to delete recipe data: {e}")
def write_image(self, file_data: bytes, extension: str) -> Path:
extension = extension.replace(".", "")
image_path = self.dir_image.joinpath(f"original.{extension}")
image_path.unlink(missing_ok=True)
if isinstance(file_data, Path):
shutil.copy2(file_data, image_path)
elif isinstance(file_data, bytes):
with open(image_path, "ab") as f:
f.write(file_data)
else:
with open(image_path, "ab") as f:
shutil.copyfileobj(file_data, f)
self.minifier.minify(image_path)
return image_path
def scrape_image(self, image_url) -> None:
self.logger.info(f"Image URL: {image_url}")
if isinstance(image_url, str): # Handles String Types
pass
elif isinstance(image_url, list): # Handles List Types
# Multiple images have been defined in the schema - usually different resolutions
# Typically would be in smallest->biggest order, but can't be certain so test each.
# 'Google will pick the best image to display in Search results based on the aspect ratio and resolution.'
all_image_requests = []
for url in image_url:
if isinstance(url, dict):
url = url.get("url", "")
try:
r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA})
except Exception:
self.logger.exception("Image {url} could not be requested")
continue
if r.status_code == 200:
all_image_requests.append((url, r))
image_url, _ = max(all_image_requests, key=lambda url_r: len(url_r[1].content), default=("", 0))
elif isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
ext = image_url.split(".")[-1]
if ext not in img.IMAGE_EXTENSIONS:
ext = "jpg" # Guess the extension
filename = str(self.recipe_id) + "." + ext
filename = Recipe.directory_from_id(self.recipe_id).joinpath("images", filename)
try:
r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA})
except Exception:
self.logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
self.logger.info(f"File Name Suffix {filename.suffix}")
self.write_image(r.raw, filename.suffix)
filename.unlink(missing_ok=True)

View File

@@ -15,7 +15,7 @@ from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services._base_service import BaseService
from mealie.services.image.image import write_image
from mealie.services.recipe.recipe_data_service import RecipeDataService
from .template_service import TemplateService
@@ -142,7 +142,8 @@ class RecipeService(BaseService):
recipe = self.create_one(Recipe(**recipe_dict))
if recipe:
write_image(recipe.slug, recipe_image, "webp")
data_service = RecipeDataService(recipe.id)
data_service.write_image(recipe_image, "webp")
return recipe

View File

@@ -43,6 +43,9 @@ def clean_string(text: str) -> str:
if isinstance(text, list):
text = text[0]
if isinstance(text, int):
text = str(text)
if text == "" or text is None:
return ""

View File

@@ -5,8 +5,9 @@ from fastapi import HTTPException, status
from slugify import slugify
from mealie.core.root_logger import get_logger
from mealie.pkgs import cache
from mealie.schema.recipe import Recipe
from mealie.services.image.image import scrape_image
from mealie.services.recipe.recipe_data_service import RecipeDataService
from .recipe_scraper import RecipeScraper
@@ -29,29 +30,26 @@ def create_from_url(url: str) -> Recipe:
"""
scraper = RecipeScraper()
new_recipe = scraper.scrape(url)
new_recipe.id = uuid4()
if not new_recipe:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": ParserErrors.BAD_RECIPE_DATA.value})
logger = get_logger()
logger.info(f"Image {new_recipe.image}")
new_recipe.image = download_image_for_recipe(new_recipe.slug, new_recipe.image)
recipe_data_service = RecipeDataService(new_recipe.id)
try:
recipe_data_service.scrape_image(new_recipe.image)
new_recipe.name = slugify(new_recipe.name)
new_recipe.image = cache.new_key(4)
except Exception as e:
recipe_data_service.logger.exception(f"Error Scraping Image: {e}")
new_recipe.image = "no image"
if new_recipe.name is None or new_recipe.name == "":
new_recipe.name = "No Recipe Found - " + uuid4().hex
new_recipe.name = "No Recipe Name Found - " + str(uuid4())
new_recipe.slug = slugify(new_recipe.name)
return new_recipe
def download_image_for_recipe(slug, image_url) -> str | None:
img_name = None
try:
img_path = scrape_image(image_url, slug)
img_name = img_path.name
except Exception as e:
logger = get_logger()
logger.error(f"Error Scraping Image: {e}")
img_name = None
return img_name or "no image"

View File

@@ -1 +0,0 @@
from .cache_key import new_cache_key

View File

@@ -1,7 +0,0 @@
from asyncio.log import logger
from sqlalchemy.orm.session import Session
def post_webhooks(group: int, session: Session = None, force=True):
logger.error("post_webhooks is depreciated")