mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-24 22:05:34 -04: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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -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
1
mealie/pkgs/cache/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
from .cache_key import *
|
||||
@@ -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))
|
||||
7
mealie/pkgs/dev/__init__.py
Normal file
7
mealie/pkgs/dev/__init__.py
Normal 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
12
mealie/pkgs/dev/timer.py
Normal 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
|
||||
7
mealie/pkgs/img/__init__.py
Normal file
7
mealie/pkgs/img/__init__.py
Normal 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
130
mealie/pkgs/img/minify.py
Normal 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)
|
||||
1
mealie/pkgs/stats/__init__.py
Normal file
1
mealie/pkgs/stats/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .fs_stats import *
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import UUID4
|
||||
|
||||
|
||||
class Category(CamelModel):
|
||||
id: int
|
||||
id: UUID4
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
108
mealie/services/recipe/recipe_data_service.py
Normal file
108
mealie/services/recipe/recipe_data_service.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .cache_key import new_cache_key
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user