mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-12 02:43:12 -05:00
feat: ✨ (WIP) base-shoppinglist infra (#911)
* feat: ✨ base-shoppinglist infra (WIP) * add type checker * implement controllers * apply router fixes * add checked section hide/animation * add label support * formatting * fix overflow images * add experimental banner * fix #912 word break issue * remove any type errors * bump dependencies * remove templates * fix build errors * bump node version * fix template literal
This commit is contained in:
@@ -4,16 +4,14 @@ from mealie.db.db_setup import create_session, engine
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.repos.seed.init_units_foods import default_recipe_unit_init
|
||||
from mealie.repos.seed.init_users import default_user_init
|
||||
from mealie.repos.seed.seeders import IngredientFoodsSeeder, IngredientUnitsSeeder, MultiPurposeLabelSeeder
|
||||
from mealie.schema.user.user import GroupBase
|
||||
from mealie.services.events import create_general_event
|
||||
from mealie.services.group_services.group_utils import create_new_group
|
||||
|
||||
logger = root_logger.get_logger("init_db")
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
def create_all_models():
|
||||
import mealie.db.models._all_models # noqa: F401
|
||||
@@ -22,12 +20,25 @@ def create_all_models():
|
||||
|
||||
|
||||
def init_db(db: AllRepositories) -> None:
|
||||
# TODO: Port other seed data to use abstract seeder class
|
||||
default_group_init(db)
|
||||
default_user_init(db)
|
||||
default_recipe_unit_init(db)
|
||||
|
||||
group_id = db.groups.get_all()[0].id
|
||||
|
||||
seeders = [
|
||||
MultiPurposeLabelSeeder(db, group_id=group_id),
|
||||
IngredientFoodsSeeder(db, group_id=group_id),
|
||||
IngredientUnitsSeeder(db, group_id=group_id),
|
||||
]
|
||||
|
||||
for seeder in seeders:
|
||||
seeder.seed()
|
||||
|
||||
|
||||
def default_group_init(db: AllRepositories):
|
||||
settings = get_app_settings()
|
||||
|
||||
logger.info("Generating Default Group")
|
||||
create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .event import *
|
||||
from .group import *
|
||||
from .labels import *
|
||||
from .recipe.recipe import *
|
||||
from .server import *
|
||||
from .sign_up import *
|
||||
|
||||
@@ -78,6 +78,8 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
|
||||
elems_to_create: list[dict] = []
|
||||
updated_elems: list[dict] = []
|
||||
|
||||
cfg = _get_config(relation_cls)
|
||||
|
||||
for elem in all_elements:
|
||||
elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem
|
||||
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none()
|
||||
@@ -88,7 +90,8 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
|
||||
|
||||
elif isinstance(elem, dict):
|
||||
for key, value in elem.items():
|
||||
setattr(existing_elem, key, value)
|
||||
if key not in cfg.exclude:
|
||||
setattr(existing_elem, key, value)
|
||||
|
||||
updated_elems.append(existing_elem)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
@@ -47,6 +48,8 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
"single_parent": True,
|
||||
}
|
||||
|
||||
labels = orm.relationship(MultiPurposeLabel, **common_args)
|
||||
|
||||
mealplans = orm.relationship(GroupMealPlan, order_by="GroupMealPlan.date", **common_args)
|
||||
webhooks = orm.relationship(GroupWebhooksModel, **common_args)
|
||||
cookbooks = orm.relationship(CookBook, **common_args)
|
||||
|
||||
@@ -1,51 +1,64 @@
|
||||
import sqlalchemy.orm as orm
|
||||
from requests import Session
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, orm
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.guid import GUID
|
||||
from .group import Group
|
||||
from .._model_utils import GUID, auto_init
|
||||
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
|
||||
|
||||
class ShoppingListItem(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "shopping_list_items"
|
||||
id = Column(Integer, primary_key=True)
|
||||
parent_id = Column(Integer, ForeignKey("shopping_lists.id"))
|
||||
position = Column(Integer, nullable=False)
|
||||
|
||||
title = Column(String)
|
||||
text = Column(String)
|
||||
quantity = Column(Integer)
|
||||
checked = Column(Boolean)
|
||||
# Id's
|
||||
id = Column(GUID, primary_key=True, default=GUID.generate)
|
||||
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"))
|
||||
|
||||
def __init__(self, title, text, quantity, checked, **_) -> None:
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.quantity = quantity
|
||||
self.checked = checked
|
||||
# Meta
|
||||
recipe_id = Column(Integer, nullable=True)
|
||||
is_ingredient = Column(Boolean, default=True)
|
||||
position = Column(Integer, nullable=False, default=0)
|
||||
checked = Column(Boolean, default=False)
|
||||
|
||||
quantity = Column(Float, default=1)
|
||||
note = Column(String)
|
||||
|
||||
is_food = Column(Boolean, default=False)
|
||||
|
||||
# Scaling Items
|
||||
unit_id = Column(Integer, ForeignKey("ingredient_units.id"))
|
||||
unit = orm.relationship(IngredientUnitModel, uselist=False)
|
||||
|
||||
food_id = Column(Integer, ForeignKey("ingredient_foods.id"))
|
||||
food = orm.relationship(IngredientFoodModel, uselist=False)
|
||||
|
||||
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id"))
|
||||
label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="shopping_list_items")
|
||||
|
||||
class Config:
|
||||
exclude = {"id", "label"}
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ShoppingList(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "shopping_lists"
|
||||
id = Column(Integer, primary_key=True)
|
||||
id = Column(GUID, primary_key=True, default=GUID.generate)
|
||||
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="shopping_lists")
|
||||
|
||||
name = Column(String)
|
||||
items: list[ShoppingListItem] = orm.relationship(
|
||||
list_items = orm.relationship(
|
||||
ShoppingListItem,
|
||||
cascade="all, delete, delete-orphan",
|
||||
order_by="ShoppingListItem.position",
|
||||
collection_class=ordering_list("position"),
|
||||
)
|
||||
|
||||
def __init__(self, name, group, items, session=None, **_) -> None:
|
||||
self.name = name
|
||||
self.group = Group.get_ref(session, group)
|
||||
self.items = [ShoppingListItem(**i) for i in items]
|
||||
|
||||
@staticmethod
|
||||
def get_ref(session: Session, id: int):
|
||||
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
22
mealie/db/models/labels.py
Normal file
22
mealie/db/models/labels.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, ForeignKey, String, orm
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from ._model_utils import auto_init
|
||||
from ._model_utils.guid import GUID
|
||||
|
||||
|
||||
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "multi_purpose_labels"
|
||||
id = Column(GUID, default=GUID.generate, primary_key=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
|
||||
group_id = Column(GUID, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="labels")
|
||||
|
||||
shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label")
|
||||
foods = orm.relationship("IngredientFoodModel", back_populates="label")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
@@ -1,6 +1,7 @@
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
@@ -27,6 +28,9 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
description = Column(String)
|
||||
ingredients = orm.relationship("RecipeIngredient", back_populates="food")
|
||||
|
||||
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id"))
|
||||
label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods")
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
@@ -51,8 +55,6 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
reference_id = Column(GUID) # Reference Links
|
||||
|
||||
# Extras
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
@@ -26,6 +26,12 @@ class RecipeInstruction(SqlAlchemyBase):
|
||||
|
||||
ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan")
|
||||
|
||||
class Config:
|
||||
exclude = {
|
||||
"id",
|
||||
"ingredient_references",
|
||||
}
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
def __init__(self, ingredient_references, **_) -> None:
|
||||
self.ingredient_references = [RecipeIngredientRefLink(**ref) for ref in ingredient_references]
|
||||
|
||||
@@ -8,7 +8,9 @@ from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
|
||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.comment import RecipeComment
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
@@ -25,8 +27,10 @@ from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.events import EventNotificationIn
|
||||
from mealie.schema.group.group_exports import GroupDataExport
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
|
||||
from mealie.schema.group.invite_token import ReadInviteToken
|
||||
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.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
|
||||
@@ -40,6 +44,7 @@ from .repository_generic import RepositoryGeneric
|
||||
from .repository_group import RepositoryGroup
|
||||
from .repository_meals import RepositoryMeals
|
||||
from .repository_recipes import RepositoryRecipes
|
||||
from .repository_shopping_list import RepositoryShoppingList
|
||||
from .repository_users import RepositoryUsers
|
||||
|
||||
pk_id = "id"
|
||||
@@ -176,3 +181,15 @@ class AllRepositories:
|
||||
@cached_property
|
||||
def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, ReportEntryModel, ReportEntryOut)
|
||||
|
||||
@cached_property
|
||||
def group_shopping_lists(self) -> RepositoryShoppingList:
|
||||
return RepositoryShoppingList(self.session, pk_id, ShoppingList, ShoppingListOut)
|
||||
|
||||
@cached_property
|
||||
def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
|
||||
return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut)
|
||||
|
||||
@cached_property
|
||||
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
||||
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
|
||||
|
||||
@@ -146,7 +146,7 @@ class RepositoryGeneric(Generic[T, D]):
|
||||
filter = self._filter_builder(**{match_key: match_value})
|
||||
return self.session.query(self.sql_model).filter_by(**filter).one()
|
||||
|
||||
def get_one(self, value: str | int, key: str = None, any_case=False, override_schema=None) -> T:
|
||||
def get_one(self, value: str | int | UUID4, key: str = None, any_case=False, override_schema=None) -> T:
|
||||
key = key or self.primary_key
|
||||
|
||||
q = self.session.query(self.sql_model)
|
||||
|
||||
59
mealie/repos/repository_shopping_list.py
Normal file
59
mealie/repos/repository_shopping_list.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListOut, ShoppingListUpdate
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
class RepositoryShoppingList(RepositoryGeneric[ShoppingListOut, ShoppingList]):
|
||||
def _consolidate(self, item_list: list[ShoppingListItem]) -> ShoppingListItem:
|
||||
"""
|
||||
consolidate itterates through the shopping list provided and returns
|
||||
a consolidated list where all items that are matched against multiple values are
|
||||
de-duplicated and only the first item is kept where the quantity is updated accoridngly.
|
||||
"""
|
||||
|
||||
def can_merge(item1: ShoppingListItem, item2: ShoppingListItem) -> bool:
|
||||
"""
|
||||
can_merge checks if the two items can be merged together.
|
||||
"""
|
||||
can_merge_return = False
|
||||
|
||||
# If the items have the same food and unit they can be merged.
|
||||
if item1.unit == item2.unit and item1.food == item2.food:
|
||||
can_merge_return = True
|
||||
|
||||
# If no food or units are present check against the notes field.
|
||||
if not all([item1.food, item1.unit, item2.food, item2.unit]):
|
||||
can_merge_return = item1.note == item2.note
|
||||
|
||||
# Otherwise Assume They Can't Be Merged
|
||||
|
||||
return can_merge_return
|
||||
|
||||
consolidated_list: list[ShoppingListItem] = []
|
||||
checked_items: list[int] = []
|
||||
|
||||
for base_index, base_item in enumerate(item_list):
|
||||
if base_index in checked_items:
|
||||
continue
|
||||
|
||||
checked_items.append(base_index)
|
||||
for inner_index, inner_item in enumerate(item_list):
|
||||
if inner_index in checked_items:
|
||||
continue
|
||||
if can_merge(base_item, inner_item):
|
||||
base_item.quantity += inner_item.quantity
|
||||
checked_items.append(inner_index)
|
||||
|
||||
consolidated_list.append(base_item)
|
||||
|
||||
return consolidated_list
|
||||
|
||||
def update(self, item_id: UUID4, data: ShoppingListUpdate) -> ShoppingListOut:
|
||||
"""
|
||||
update updates the shopping list item with the provided data.
|
||||
"""
|
||||
data.list_items = self._consolidate(data.list_items)
|
||||
return super().update(item_id, data)
|
||||
29
mealie/repos/seed/_abstract_seeder.py
Normal file
29
mealie/repos/seed/_abstract_seeder.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
|
||||
|
||||
class AbstractSeeder(ABC):
|
||||
"""
|
||||
Abstract class for seeding data.
|
||||
"""
|
||||
|
||||
def __init__(self, db: AllRepositories, logger: Logger = None, group_id: UUID4 = None):
|
||||
"""
|
||||
Initialize the abstract seeder.
|
||||
:param db_conn: Database connection.
|
||||
:param logger: Logger.
|
||||
"""
|
||||
self.repos = db
|
||||
self.group_id = group_id
|
||||
self.logger = logger or get_logger("Data Seeder")
|
||||
self.resources = Path(__file__).parent / "resources"
|
||||
|
||||
@abstractmethod
|
||||
def seed(self):
|
||||
...
|
||||
@@ -1,40 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe import CreateIngredientFood, CreateIngredientUnit
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_default_foods():
|
||||
with open(CWD.joinpath("resources", "foods", "en-us.json"), "r") as f:
|
||||
foods = json.loads(f.read())
|
||||
return foods
|
||||
|
||||
|
||||
def get_default_units() -> dict[str, str]:
|
||||
with open(CWD.joinpath("resources", "units", "en-us.json"), "r") as f:
|
||||
units = json.loads(f.read())
|
||||
return units
|
||||
|
||||
|
||||
def default_recipe_unit_init(db: AllRepositories) -> None:
|
||||
for unit in get_default_units().values():
|
||||
try:
|
||||
db.ingredient_units.create(
|
||||
CreateIngredientUnit(
|
||||
name=unit["name"], description=unit["description"], abbreviation=unit["abbreviation"]
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
for food in get_default_foods():
|
||||
try:
|
||||
|
||||
db.ingredient_foods.create(CreateIngredientFood(name=food, description=""))
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
65
mealie/repos/seed/resources/labels/en-us.json
Normal file
65
mealie/repos/seed/resources/labels/en-us.json
Normal file
@@ -0,0 +1,65 @@
|
||||
[
|
||||
{
|
||||
"name": "Produce"
|
||||
},
|
||||
{
|
||||
"name": "Grains"
|
||||
},
|
||||
{
|
||||
"name": "Fruits"
|
||||
},
|
||||
{
|
||||
"name": "Vegetables"
|
||||
},
|
||||
{
|
||||
"name": "Meat"
|
||||
},
|
||||
{
|
||||
"name": "Seafood"
|
||||
},
|
||||
{
|
||||
"name": "Beverages"
|
||||
},
|
||||
{
|
||||
"name": "Baked Goods"
|
||||
},
|
||||
{
|
||||
"name": "Canned Goods"
|
||||
},
|
||||
{
|
||||
"name": "Condiments"
|
||||
},
|
||||
{
|
||||
"name": "Confectionary"
|
||||
},
|
||||
{
|
||||
"name": "Dairy Products"
|
||||
},
|
||||
{
|
||||
"name": "Frozen Foods"
|
||||
},
|
||||
{
|
||||
"name": "Health Foods"
|
||||
},
|
||||
{
|
||||
"name": "Household"
|
||||
},
|
||||
{
|
||||
"name": "Meat Products"
|
||||
},
|
||||
{
|
||||
"name": "Snacks"
|
||||
},
|
||||
{
|
||||
"name": "Spices"
|
||||
},
|
||||
{
|
||||
"name": "Sweets"
|
||||
},
|
||||
{
|
||||
"name": "Alcohol"
|
||||
},
|
||||
{
|
||||
"name": "Other"
|
||||
}
|
||||
]
|
||||
61
mealie/repos/seed/seeders.py
Normal file
61
mealie/repos/seed/seeders.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import Generator
|
||||
|
||||
from black import json
|
||||
|
||||
from mealie.schema.labels import MultiPurposeLabelSave
|
||||
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
|
||||
|
||||
from ._abstract_seeder import AbstractSeeder
|
||||
|
||||
|
||||
class MultiPurposeLabelSeeder(AbstractSeeder):
|
||||
def load_data(self) -> Generator[MultiPurposeLabelSave, None, None]:
|
||||
file = self.resources / "labels" / "en-us.json"
|
||||
|
||||
for label in json.loads(file.read_text()):
|
||||
yield MultiPurposeLabelSave(
|
||||
name=label["name"],
|
||||
group_id=self.group_id,
|
||||
)
|
||||
|
||||
def seed(self) -> None:
|
||||
self.logger.info("Seeding MultiPurposeLabel")
|
||||
for label in self.load_data():
|
||||
try:
|
||||
self.repos.group_multi_purpose_labels.create(label)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
|
||||
|
||||
class IngredientUnitsSeeder(AbstractSeeder):
|
||||
def load_data(self) -> Generator[CreateIngredientUnit, None, None]:
|
||||
file = self.resources / "units" / "en-us.json"
|
||||
for unit in json.loads(file.read_text()).values():
|
||||
yield CreateIngredientUnit(
|
||||
name=unit["name"],
|
||||
description=unit["description"],
|
||||
abbreviation=unit["abbreviation"],
|
||||
)
|
||||
|
||||
def seed(self) -> None:
|
||||
self.logger.info("Seeding Ingredient Units")
|
||||
for unit in self.load_data():
|
||||
try:
|
||||
self.repos.ingredient_units.create(unit)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
|
||||
|
||||
class IngredientFoodsSeeder(AbstractSeeder):
|
||||
def load_data(self) -> Generator[CreateIngredientFood, None, None]:
|
||||
file = self.resources / "foods" / "en-us.json"
|
||||
for food in json.loads(file.read_text()):
|
||||
yield CreateIngredientFood(name=food, description="")
|
||||
|
||||
def seed(self) -> None:
|
||||
self.logger.info("Seeding Ingredient Foods")
|
||||
for food in self.load_data():
|
||||
try:
|
||||
self.repos.ingredient_foods.create(food)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
@@ -1,21 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import (
|
||||
admin,
|
||||
app,
|
||||
auth,
|
||||
categories,
|
||||
comments,
|
||||
groups,
|
||||
parser,
|
||||
recipe,
|
||||
shared,
|
||||
shopping_lists,
|
||||
tags,
|
||||
tools,
|
||||
unit_and_foods,
|
||||
users,
|
||||
)
|
||||
from . import admin, app, auth, categories, comments, groups, parser, recipe, shared, tags, tools, unit_and_foods, users
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -31,5 +16,4 @@ 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(shopping_lists.router)
|
||||
router.include_router(admin.router)
|
||||
|
||||
182
mealie/routes/_base/controller.py
Normal file
182
mealie/routes/_base/controller.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
This file contains code taken from fastapi-utils project. The code is licensed under the MIT license.
|
||||
|
||||
See their repository for details -> https://github.com/dmontagu/fastapi-utils
|
||||
"""
|
||||
import inspect
|
||||
from typing import Any, Callable, List, Tuple, Type, TypeVar, Union, cast, get_type_hints
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic.typing import is_classvar
|
||||
from starlette.routing import Route, WebSocketRoute
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
CBV_CLASS_KEY = "__cbv_class__"
|
||||
INCLUDE_INIT_PARAMS_KEY = "__include_init_params__"
|
||||
RETURN_TYPES_FUNC_KEY = "__return_types_func__"
|
||||
|
||||
|
||||
def controller(router: APIRouter, *urls: str) -> Callable[[Type[T]], Type[T]]:
|
||||
"""
|
||||
This function returns a decorator that converts the decorated into a class-based view for the provided router.
|
||||
Any methods of the decorated class that are decorated as endpoints using the router provided to this function
|
||||
will become endpoints in the router. The first positional argument to the methods (typically `self`)
|
||||
will be populated with an instance created using FastAPI's dependency-injection.
|
||||
For more detail, review the documentation at
|
||||
https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/#the-cbv-decorator
|
||||
"""
|
||||
|
||||
def decorator(cls: Type[T]) -> Type[T]:
|
||||
# Define cls as cbv class exclusively when using the decorator
|
||||
return _cbv(router, cls, *urls)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _cbv(router: APIRouter, cls: Type[T], *urls: str, instance: Any = None) -> Type[T]:
|
||||
"""
|
||||
Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated
|
||||
function calls that will properly inject an instance of `cls`.
|
||||
"""
|
||||
_init_cbv(cls, instance)
|
||||
_register_endpoints(router, cls, *urls)
|
||||
return cls
|
||||
|
||||
|
||||
def _init_cbv(cls: Type[Any], instance: Any = None) -> None:
|
||||
"""
|
||||
Idempotently modifies the provided `cls`, performing the following modifications:
|
||||
* The `__init__` function is updated to set any class-annotated dependencies as instance attributes
|
||||
* The `__signature__` attribute is updated to indicate to FastAPI what arguments should be passed to the initializer
|
||||
"""
|
||||
if getattr(cls, CBV_CLASS_KEY, False): # pragma: no cover
|
||||
return # Already initialized
|
||||
old_init: Callable[..., Any] = cls.__init__
|
||||
old_signature = inspect.signature(old_init)
|
||||
old_parameters = list(old_signature.parameters.values())[1:] # drop `self` parameter
|
||||
new_parameters = [
|
||||
x for x in old_parameters if x.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
|
||||
dependency_names: List[str] = []
|
||||
for name, hint in get_type_hints(cls).items():
|
||||
if is_classvar(hint):
|
||||
continue
|
||||
parameter_kwargs = {"default": getattr(cls, name, Ellipsis)}
|
||||
dependency_names.append(name)
|
||||
new_parameters.append(
|
||||
inspect.Parameter(name=name, kind=inspect.Parameter.KEYWORD_ONLY, annotation=hint, **parameter_kwargs)
|
||||
)
|
||||
new_signature = inspect.Signature(())
|
||||
if not instance or hasattr(cls, INCLUDE_INIT_PARAMS_KEY):
|
||||
new_signature = old_signature.replace(parameters=new_parameters)
|
||||
|
||||
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
||||
for dep_name in dependency_names:
|
||||
dep_value = kwargs.pop(dep_name)
|
||||
setattr(self, dep_name, dep_value)
|
||||
if instance and not hasattr(cls, INCLUDE_INIT_PARAMS_KEY):
|
||||
self.__class__ = instance.__class__
|
||||
self.__dict__ = instance.__dict__
|
||||
else:
|
||||
old_init(self, *args, **kwargs)
|
||||
|
||||
setattr(cls, "__signature__", new_signature)
|
||||
setattr(cls, "__init__", new_init)
|
||||
setattr(cls, CBV_CLASS_KEY, True)
|
||||
|
||||
|
||||
def _register_endpoints(router: APIRouter, cls: Type[Any], *urls: str) -> None:
|
||||
cbv_router = APIRouter()
|
||||
function_members = inspect.getmembers(cls, inspect.isfunction)
|
||||
for url in urls:
|
||||
_allocate_routes_by_method_name(router, url, function_members)
|
||||
router_roles = []
|
||||
for route in router.routes:
|
||||
assert isinstance(route, APIRoute)
|
||||
route_methods: Any = route.methods
|
||||
cast(Tuple[Any], route_methods)
|
||||
router_roles.append((route.path, tuple(route_methods)))
|
||||
|
||||
if len(set(router_roles)) != len(router_roles):
|
||||
raise Exception("An identical route role has been implemented more then once")
|
||||
|
||||
numbered_routes_by_endpoint = {
|
||||
route.endpoint: (i, route)
|
||||
for i, route in enumerate(router.routes)
|
||||
if isinstance(route, (Route, WebSocketRoute))
|
||||
}
|
||||
|
||||
prefix_length = len(router.prefix)
|
||||
routes_to_append: List[Tuple[int, Union[Route, WebSocketRoute]]] = []
|
||||
for _, func in function_members:
|
||||
index_route = numbered_routes_by_endpoint.get(func)
|
||||
|
||||
if index_route is None:
|
||||
continue
|
||||
|
||||
_, route = index_route
|
||||
route.path = route.path[prefix_length:]
|
||||
routes_to_append.append(index_route)
|
||||
router.routes.remove(route)
|
||||
|
||||
_update_cbv_route_endpoint_signature(cls, route)
|
||||
routes_to_append.sort(key=lambda x: x[0])
|
||||
|
||||
cbv_router.routes = [route for _, route in routes_to_append]
|
||||
|
||||
# In order to use a "" as a router and utilize the prefix in the original router
|
||||
# we need to create an intermediate prefix variable to hold the prefix and pass it
|
||||
# into the original router when using "include_router" after we reeset the original
|
||||
# prefix. This limits the original routers usability to only the controller.
|
||||
#
|
||||
# This is sort of a hack and causes unexpected behavior. I'm unsure of a better solution.
|
||||
cbv_prefix = router.prefix
|
||||
router.prefix = ""
|
||||
router.include_router(cbv_router, prefix=cbv_prefix)
|
||||
|
||||
|
||||
def _allocate_routes_by_method_name(router: APIRouter, url: str, function_members: List[Tuple[str, Any]]) -> None:
|
||||
# sourcery skip: merge-nested-ifs
|
||||
existing_routes_endpoints: List[Tuple[Any, str]] = [
|
||||
(route.endpoint, route.path) for route in router.routes if isinstance(route, APIRoute)
|
||||
]
|
||||
for name, func in function_members:
|
||||
if hasattr(router, name) and not name.startswith("__") and not name.endswith("__"):
|
||||
if (func, url) not in existing_routes_endpoints:
|
||||
response_model = None
|
||||
responses = None
|
||||
kwargs = {}
|
||||
status_code = 200
|
||||
return_types_func = getattr(func, RETURN_TYPES_FUNC_KEY, None)
|
||||
if return_types_func:
|
||||
response_model, status_code, responses, kwargs = return_types_func()
|
||||
|
||||
api_resource = router.api_route(
|
||||
url,
|
||||
methods=[name.capitalize()],
|
||||
response_model=response_model,
|
||||
status_code=status_code,
|
||||
responses=responses,
|
||||
**kwargs,
|
||||
)
|
||||
api_resource(func)
|
||||
|
||||
|
||||
def _update_cbv_route_endpoint_signature(cls: Type[Any], route: Union[Route, WebSocketRoute]) -> None:
|
||||
"""
|
||||
Fixes the endpoint signature for a cbv route to ensure FastAPI performs dependency injection properly.
|
||||
"""
|
||||
old_endpoint = route.endpoint
|
||||
old_signature = inspect.signature(old_endpoint)
|
||||
old_parameters: List[inspect.Parameter] = list(old_signature.parameters.values())
|
||||
old_first_parameter = old_parameters[0]
|
||||
new_first_parameter = old_first_parameter.replace(default=Depends(cls))
|
||||
new_parameters = [new_first_parameter] + [
|
||||
parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) for parameter in old_parameters[1:]
|
||||
]
|
||||
|
||||
new_signature = old_signature.replace(parameters=new_parameters)
|
||||
setattr(route.endpoint, "__signature__", new_signature)
|
||||
58
mealie/routes/_base/dependencies.py
Normal file
58
mealie/routes/_base/dependencies.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from mealie.core.config import get_app_dirs, get_app_settings
|
||||
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.core.settings.directories import AppDirectories
|
||||
from mealie.core.settings.settings import AppSettings
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.lang import AbstractLocaleProvider, get_locale_provider
|
||||
from mealie.repos import AllRepositories
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
|
||||
|
||||
def _get_logger() -> Logger:
|
||||
return get_logger()
|
||||
|
||||
|
||||
class SharedDependencies:
|
||||
session: Session
|
||||
t: AbstractLocaleProvider
|
||||
logger: Logger
|
||||
acting_user: PrivateUser | None
|
||||
|
||||
def __init__(self, session: Session, acting_user: PrivateUser | None) -> None:
|
||||
self.t = get_locale_provider()
|
||||
self.logger = _get_logger()
|
||||
self.session = session
|
||||
self.acting_user = acting_user
|
||||
|
||||
@classmethod
|
||||
def user(
|
||||
cls, session: Session = Depends(generate_session), user: PrivateUser = Depends(get_current_user)
|
||||
) -> "SharedDependencies":
|
||||
return cls(session, user)
|
||||
|
||||
@classmethod
|
||||
def admin(
|
||||
cls, session: Session = Depends(generate_session), admin: PrivateUser = Depends(get_admin_user)
|
||||
) -> "SharedDependencies":
|
||||
return cls(session, admin)
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> AppSettings:
|
||||
return get_app_settings()
|
||||
|
||||
@cached_property
|
||||
def folders(self) -> AppDirectories:
|
||||
return get_app_dirs()
|
||||
|
||||
@cached_property
|
||||
def repos(self) -> AllRepositories:
|
||||
return AllRepositories(self.session)
|
||||
109
mealie/routes/_base/mixins.py
Normal file
109
mealie/routes/_base/mixins.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import Logger
|
||||
from typing import Callable, Type
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from mealie.repos.repository_generic import RepositoryGeneric
|
||||
from mealie.schema.response import ErrorResponse
|
||||
|
||||
|
||||
class CrudMixins:
|
||||
repo: RepositoryGeneric
|
||||
exception_msgs: Callable[[Type[Exception]], str] | None
|
||||
default_message: str = "An unexpected error occurred."
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: RepositoryGeneric,
|
||||
logger: Logger,
|
||||
exception_msgs: Callable[[Type[Exception]], str] = None,
|
||||
default_message: str = None,
|
||||
) -> None:
|
||||
"""
|
||||
The CrudMixins class is a mixin class that provides a common set of methods for CRUD operations.
|
||||
This class is inteded to be used in a composition pattern where a class has a mixin property. For example:
|
||||
|
||||
```
|
||||
class MyClass:
|
||||
def __init(self repo, logger):
|
||||
self.mixins = CrudMixins(repo, logger)
|
||||
```
|
||||
|
||||
"""
|
||||
self.repo = repo
|
||||
self.logger = logger
|
||||
self.exception_msgs = exception_msgs
|
||||
|
||||
if default_message:
|
||||
self.default_message = default_message
|
||||
|
||||
def set_default_message(self, default_msg: str) -> "CrudMixins":
|
||||
"""
|
||||
Use this method to set a lookup function for exception messages. When an exception is raised, and
|
||||
no custom message is set, the default message will be used.
|
||||
|
||||
IMPORTANT! The function returns the same instance of the CrudMixins class, so you can chain calls.
|
||||
"""
|
||||
self.default_msg = default_msg
|
||||
return self
|
||||
|
||||
def get_exception_message(self, ext: Exception) -> str:
|
||||
if self.exception_msgs:
|
||||
return self.exception_msgs(type(ext))
|
||||
return self.default_message
|
||||
|
||||
def handle_exception(self, ex: Exception) -> None:
|
||||
# Cleanup
|
||||
self.logger.exception(ex)
|
||||
self.repo.session.rollback()
|
||||
|
||||
# Respond
|
||||
msg = self.get_exception_message(ex)
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse.respond(message=msg, exception=str(ex)),
|
||||
)
|
||||
|
||||
def create_one(self, data):
|
||||
item = None
|
||||
try:
|
||||
item = self.repo.create(data)
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
return item
|
||||
|
||||
def update_one(self, data, item_id):
|
||||
item = self.repo.get(item_id)
|
||||
|
||||
if not item:
|
||||
return
|
||||
|
||||
try:
|
||||
item = self.repo.update(item.id, data) # type: ignore
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
return item
|
||||
|
||||
def patch_one(self, data, item_id) -> None:
|
||||
self.repo.get(item_id)
|
||||
|
||||
try:
|
||||
self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
def delete_one(self, item_id):
|
||||
item = self.repo.get(item_id)
|
||||
self.logger.info(f"Deleting item with id {item}")
|
||||
|
||||
try:
|
||||
item = self.repo.delete(item)
|
||||
except Exception as ex:
|
||||
self.handle_exception(ex)
|
||||
|
||||
return item
|
||||
@@ -8,7 +8,7 @@ from mealie.services.group_services import CookbookService, WebhookService
|
||||
from mealie.services.group_services.meal_service import MealService
|
||||
from mealie.services.group_services.reports_service import GroupReportService
|
||||
|
||||
from . import categories, invitations, migrations, preferences, self_service
|
||||
from . import categories, invitations, labels, migrations, preferences, self_service, shopping_lists
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -20,18 +20,18 @@ cookbook_router = RouterFactory(service=CookbookService, prefix="/groups/cookboo
|
||||
|
||||
|
||||
@router.get("/groups/mealplans/today", tags=["Groups: Mealplans"])
|
||||
def get_todays_meals(m_service: MealService = Depends(MealService.private)):
|
||||
return m_service.get_today()
|
||||
def get_todays_meals(ms: MealService = Depends(MealService.private)):
|
||||
return ms.get_today()
|
||||
|
||||
|
||||
meal_plan_router = RouterFactory(service=MealService, prefix="/groups/mealplans", tags=["Groups: Mealplans"])
|
||||
|
||||
|
||||
@meal_plan_router.get("")
|
||||
def get_all(start: date = None, limit: date = None, m_service: MealService = Depends(MealService.private)):
|
||||
def get_all(start: date = None, limit: date = None, ms: MealService = Depends(MealService.private)):
|
||||
start = start or date.today() - timedelta(days=999)
|
||||
limit = limit or date.today() + timedelta(days=999)
|
||||
return m_service.get_slice(start, limit)
|
||||
return ms.get_slice(start, limit)
|
||||
|
||||
|
||||
router.include_router(cookbook_router)
|
||||
@@ -47,9 +47,12 @@ report_router = RouterFactory(service=GroupReportService, prefix="/groups/report
|
||||
|
||||
@report_router.get("")
|
||||
def get_all_reports(
|
||||
report_type: ReportCategory = None, gm_service: GroupReportService = Depends(GroupReportService.private)
|
||||
report_type: ReportCategory = None,
|
||||
gs: GroupReportService = Depends(GroupReportService.private),
|
||||
):
|
||||
return gm_service._get_all(report_type)
|
||||
return gs._get_all(report_type)
|
||||
|
||||
|
||||
router.include_router(report_router)
|
||||
router.include_router(shopping_lists.router)
|
||||
router.include_router(labels.router)
|
||||
|
||||
71
mealie/routes/groups/labels.py
Normal file
71
mealie/routes/groups/labels.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from functools import cached_property
|
||||
from sqlite3 import IntegrityError
|
||||
from typing import Type
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.dependencies import SharedDependencies
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema.labels import (
|
||||
MultiPurposeLabelCreate,
|
||||
MultiPurposeLabelOut,
|
||||
MultiPurposeLabelSave,
|
||||
MultiPurposeLabelSummary,
|
||||
MultiPurposeLabelUpdate,
|
||||
)
|
||||
from mealie.schema.mapper import cast
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.services.group_services.shopping_lists import ShoppingListService
|
||||
|
||||
router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class ShoppingListRoutes:
|
||||
deps: SharedDependencies = Depends(SharedDependencies.user)
|
||||
service: ShoppingListService = Depends(ShoppingListService.private)
|
||||
|
||||
@cached_property
|
||||
def repo(self):
|
||||
if not self.deps.acting_user:
|
||||
raise Exception("No user is logged in.")
|
||||
|
||||
return self.deps.repos.group_multi_purpose_labels.by_group(self.deps.acting_user.group_id)
|
||||
|
||||
def registered_exceptions(self, ex: Type[Exception]) -> str:
|
||||
registered = {
|
||||
Exception: "An unexpected error occurred.",
|
||||
IntegrityError: "An unexpected error occurred.",
|
||||
}
|
||||
|
||||
return registered.get(ex, "An unexpected error occurred.")
|
||||
|
||||
# =======================================================================
|
||||
# CRUD Operations
|
||||
|
||||
@property
|
||||
def mixins(self) -> CrudMixins:
|
||||
return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
|
||||
|
||||
@router.get("", response_model=list[MultiPurposeLabelSummary])
|
||||
def get_all(self, q: GetAll = Depends(GetAll)):
|
||||
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=MultiPurposeLabelSummary)
|
||||
|
||||
@router.post("", response_model=MultiPurposeLabelOut)
|
||||
def create_one(self, data: MultiPurposeLabelCreate):
|
||||
save_data = cast(data, MultiPurposeLabelSave, group_id=self.deps.acting_user.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=MultiPurposeLabelOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.repo.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=MultiPurposeLabelOut)
|
||||
def update_one(self, item_id: UUID4, data: MultiPurposeLabelUpdate):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=MultiPurposeLabelOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id) # type: ignore
|
||||
82
mealie/routes/groups/shopping_lists.py
Normal file
82
mealie/routes/groups/shopping_lists.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from functools import cached_property
|
||||
from sqlite3 import IntegrityError
|
||||
from typing import Type
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.dependencies import SharedDependencies
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema.group.group_shopping_list import (
|
||||
ShoppingListCreate,
|
||||
ShoppingListOut,
|
||||
ShoppingListSave,
|
||||
ShoppingListSummary,
|
||||
ShoppingListUpdate,
|
||||
)
|
||||
from mealie.schema.mapper import cast
|
||||
from mealie.schema.query import GetAll
|
||||
from mealie.services.group_services.shopping_lists import ShoppingListService
|
||||
|
||||
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class ShoppingListRoutes:
|
||||
deps: SharedDependencies = Depends(SharedDependencies.user)
|
||||
service: ShoppingListService = Depends(ShoppingListService.private)
|
||||
|
||||
@cached_property
|
||||
def repo(self):
|
||||
if not self.deps.acting_user:
|
||||
raise Exception("No user is logged in.")
|
||||
|
||||
return self.deps.repos.group_shopping_lists.by_group(self.deps.acting_user.group_id)
|
||||
|
||||
def registered_exceptions(self, ex: Type[Exception]) -> str:
|
||||
registered = {
|
||||
Exception: "An unexpected error occurred.",
|
||||
IntegrityError: "An unexpected error occurred.",
|
||||
}
|
||||
|
||||
return registered.get(ex, "An unexpected error occurred.")
|
||||
|
||||
# =======================================================================
|
||||
# CRUD Operations
|
||||
|
||||
@property
|
||||
def mixins(self) -> CrudMixins:
|
||||
return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
|
||||
|
||||
@router.get("", response_model=list[ShoppingListSummary])
|
||||
def get_all(self, q: GetAll = Depends(GetAll)):
|
||||
return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ShoppingListSummary)
|
||||
|
||||
@router.post("", response_model=ShoppingListOut)
|
||||
def create_one(self, data: ShoppingListCreate):
|
||||
save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id)
|
||||
return self.mixins.create_one(save_data)
|
||||
|
||||
@router.get("/{item_id}", response_model=ShoppingListOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.repo.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=ShoppingListOut)
|
||||
def update_one(self, item_id: UUID4, data: ShoppingListUpdate):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=ShoppingListOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id) # type: ignore
|
||||
|
||||
# =======================================================================
|
||||
# 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):
|
||||
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):
|
||||
return self.service.remove_recipe_ingredients_from_list(item_id, recipe_id)
|
||||
@@ -1,45 +0,0 @@
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes.routers import UserAPIRouter
|
||||
from mealie.schema.meal_plan import ShoppingListIn, ShoppingListOut
|
||||
from mealie.schema.user import PrivateUser
|
||||
|
||||
router = UserAPIRouter(prefix="/shopping-lists", tags=["Shopping Lists: CRUD"])
|
||||
|
||||
|
||||
@router.post("", response_model=ShoppingListOut)
|
||||
async def create_shopping_list(
|
||||
list_in: ShoppingListIn,
|
||||
current_user: PrivateUser = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
"""Create Shopping List in the Database"""
|
||||
db = get_repositories(session)
|
||||
list_in.group = current_user.group
|
||||
|
||||
return db.shopping_lists.create(list_in)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=ShoppingListOut)
|
||||
async def get_shopping_list(id: int, session: Session = Depends(generate_session)):
|
||||
"""Get Shopping List from the Database"""
|
||||
db = get_repositories(session)
|
||||
return db.shopping_lists.get(id)
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=ShoppingListOut)
|
||||
async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)):
|
||||
"""Update Shopping List in the Database"""
|
||||
db = get_repositories(session)
|
||||
return db.shopping_lists.update(id, new_data)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_shopping_list(id: int, session: Session = Depends(generate_session)):
|
||||
"""Delete Shopping List from the Database"""
|
||||
db = get_repositories(session)
|
||||
return db.shopping_lists.delete(id)
|
||||
@@ -1 +1,2 @@
|
||||
from .group_shopping_list import *
|
||||
from .webhook import *
|
||||
|
||||
65
mealie/schema/group/group_shopping_list.py
Normal file
65
mealie/schema/group/group_shopping_list.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
|
||||
|
||||
|
||||
class ShoppingListItemCreate(CamelModel):
|
||||
shopping_list_id: UUID4
|
||||
checked: bool = False
|
||||
position: int = 0
|
||||
|
||||
is_food: bool = False
|
||||
|
||||
note: Optional[str] = ""
|
||||
quantity: float = 1
|
||||
unit_id: int = None
|
||||
unit: IngredientUnit = None
|
||||
food_id: int = None
|
||||
food: IngredientFood = None
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
label_id: Optional[UUID4] = None
|
||||
|
||||
|
||||
class ShoppingListItemOut(ShoppingListItemCreate):
|
||||
id: UUID4
|
||||
label: "Optional[MultiPurposeLabelSummary]" = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class ShoppingListCreate(CamelModel):
|
||||
"""
|
||||
Create Shopping List
|
||||
"""
|
||||
|
||||
name: str = None
|
||||
|
||||
|
||||
class ShoppingListSave(ShoppingListCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class ShoppingListSummary(ShoppingListSave):
|
||||
id: UUID4
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class ShoppingListUpdate(ShoppingListSummary):
|
||||
list_items: list[ShoppingListItemOut] = []
|
||||
|
||||
|
||||
class ShoppingListOut(ShoppingListUpdate):
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
from mealie.schema.labels import MultiPurposeLabelSummary
|
||||
|
||||
ShoppingListItemOut.update_forward_refs()
|
||||
36
mealie/schema/labels/__init__.py
Normal file
36
mealie/schema/labels/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.recipe import IngredientFood
|
||||
|
||||
|
||||
class MultiPurposeLabelCreate(CamelModel):
|
||||
name: str
|
||||
|
||||
|
||||
class MultiPurposeLabelSave(MultiPurposeLabelCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
|
||||
id: UUID4
|
||||
|
||||
|
||||
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
|
||||
pass
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
|
||||
shopping_list_items: "list[ShoppingListItemOut]" = []
|
||||
foods: list[IngredientFood] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemOut
|
||||
|
||||
MultiPurposeLabelOut.update_forward_refs()
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Generic, TypeVar
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -6,7 +6,7 @@ T = TypeVar("T", bound=BaseModel)
|
||||
U = TypeVar("U", bound=BaseModel)
|
||||
|
||||
|
||||
def mapper(source: U, dest: T, **kwargs) -> Generic[T]:
|
||||
def mapper(source: U, dest: T, **_) -> T:
|
||||
"""
|
||||
Map a source model to a destination model. Only top-level fields are mapped.
|
||||
"""
|
||||
@@ -16,3 +16,9 @@ def mapper(source: U, dest: T, **kwargs) -> Generic[T]:
|
||||
setattr(dest, field, getattr(source, field))
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def cast(source: U, dest: T, **kwargs) -> T:
|
||||
create_data = {field: getattr(source, field) for field in source.__fields__ if field in dest.__fields__}
|
||||
create_data.update(kwargs or {})
|
||||
return dest(**create_data)
|
||||
|
||||
6
mealie/schema/query.py
Normal file
6
mealie/schema/query.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from fastapi_camelcase import CamelModel
|
||||
|
||||
|
||||
class GetAll(CamelModel):
|
||||
start: int = 0
|
||||
limit: int = 999
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -92,32 +94,32 @@ class RecipeSummary(CamelModel):
|
||||
orm_mode = True
|
||||
|
||||
@validator("tags", always=True, pre=True)
|
||||
def validate_tags(cats: list[Any]):
|
||||
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 cats
|
||||
|
||||
@validator("recipe_category", always=True, pre=True)
|
||||
def validate_categories(cats: list[Any]):
|
||||
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 cats
|
||||
|
||||
@validator("group_id", always=True, pre=True)
|
||||
def validate_group_id(group_id: list[Any]):
|
||||
def validate_group_id(group_id: Any):
|
||||
if isinstance(group_id, int):
|
||||
return uuid4()
|
||||
return group_id
|
||||
|
||||
@validator("user_id", always=True, pre=True)
|
||||
def validate_user_id(user_id: list[Any]):
|
||||
def validate_user_id(user_id: Any):
|
||||
if isinstance(user_id, int):
|
||||
return uuid4()
|
||||
return user_id
|
||||
|
||||
|
||||
class Recipe(RecipeSummary):
|
||||
recipe_ingredient: Optional[list[RecipeIngredient]] = []
|
||||
recipe_ingredient: list[RecipeIngredient] = []
|
||||
recipe_instructions: Optional[list[RecipeStep]] = []
|
||||
nutrition: Optional[Nutrition]
|
||||
|
||||
@@ -155,7 +157,7 @@ class Recipe(RecipeSummary):
|
||||
orm_mode = True
|
||||
|
||||
@classmethod
|
||||
def getter_dict(_cls, name_orm: RecipeModel):
|
||||
def getter_dict(cls, name_orm: RecipeModel):
|
||||
return {
|
||||
**GetterDict(name_orm),
|
||||
# "recipe_ingredient": [x.note for x in name_orm.recipe_ingredient],
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
message: str
|
||||
error: bool = True
|
||||
exception: str = None
|
||||
exception: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def respond(cls, message: str, exception: Optional[str] = None) -> dict:
|
||||
"""
|
||||
This method is an helper to create an obect and convert to a dictionary
|
||||
in the same call, for use while providing details to a HTTPException
|
||||
"""
|
||||
return cls(message=message, exception=exception).dict()
|
||||
|
||||
@@ -13,7 +13,6 @@ from mealie.db.models.users import User
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
|
||||
from ..meal_plan import ShoppingListOut
|
||||
from ..recipe import CategoryBase
|
||||
|
||||
settings = get_app_settings()
|
||||
@@ -148,7 +147,6 @@ class UpdateGroup(GroupBase):
|
||||
|
||||
class GroupInDB(UpdateGroup):
|
||||
users: Optional[list[UserOut]]
|
||||
shopping_lists: Optional[list[ShoppingListOut]]
|
||||
preferences: Optional[ReadGroupPreferences] = None
|
||||
|
||||
class Config:
|
||||
|
||||
63
mealie/services/group_services/shopping_lists.py
Normal file
63
mealie/services/group_services/shopping_lists.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.schema.group import ShoppingListCreate, ShoppingListOut, ShoppingListSummary
|
||||
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate
|
||||
from mealie.services._base_http_service.crud_http_mixins import CrudHttpMixins
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
from mealie.services.events import create_group_event
|
||||
|
||||
|
||||
class ShoppingListService(
|
||||
CrudHttpMixins[ShoppingListOut, ShoppingListCreate, ShoppingListCreate],
|
||||
UserHttpService[int, ShoppingListOut],
|
||||
):
|
||||
event_func = create_group_event
|
||||
_restrict_by_group = True
|
||||
_schema = ShoppingListSummary
|
||||
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.db.group_shopping_lists
|
||||
|
||||
def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
|
||||
recipe = self.db.recipes.get_one(recipe_id, "id")
|
||||
shopping_list = self.repo.get_one(list_id)
|
||||
|
||||
to_create = []
|
||||
|
||||
for ingredient in recipe.recipe_ingredient:
|
||||
food_id = None
|
||||
try:
|
||||
food_id = ingredient.food.id
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
unit_id = None
|
||||
try:
|
||||
unit_id = ingredient.unit.id
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
to_create.append(
|
||||
ShoppingListItemCreate(
|
||||
shopping_list_id=list_id,
|
||||
is_food=True,
|
||||
food_id=food_id,
|
||||
unit_id=unit_id,
|
||||
quantity=ingredient.quantity,
|
||||
note=ingredient.note,
|
||||
recipe_id=recipe_id,
|
||||
)
|
||||
)
|
||||
|
||||
shopping_list.list_items.extend(to_create)
|
||||
return self.repo.update(shopping_list.id, shopping_list)
|
||||
|
||||
def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
|
||||
shopping_list = self.repo.get_one(list_id)
|
||||
shopping_list.list_items = [x for x in shopping_list.list_items if x.recipe_id != recipe_id]
|
||||
return self.repo.update(shopping_list.id, shopping_list)
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from mealie.core import root_logger
|
||||
|
||||
@@ -16,28 +16,35 @@ class SchedulerRegistry:
|
||||
_hourly: list[Callable] = []
|
||||
_minutely: list[Callable] = []
|
||||
|
||||
def _register(name: str, callbacks: list[Callable], callback: Callable):
|
||||
@staticmethod
|
||||
def _register(name: str, callbacks: list[Callable], callback: Iterable[Callable]):
|
||||
for cb in callback:
|
||||
logger.info(f"Registering {name} callback: {cb.__name__}")
|
||||
callbacks.append(cb)
|
||||
|
||||
@staticmethod
|
||||
def register_daily(*callbacks: Callable):
|
||||
SchedulerRegistry._register("daily", SchedulerRegistry._daily, callbacks)
|
||||
|
||||
@staticmethod
|
||||
def remove_daily(callback: Callable):
|
||||
logger.info(f"Removing daily callback: {callback.__name__}")
|
||||
SchedulerRegistry._daily.remove(callback)
|
||||
|
||||
@staticmethod
|
||||
def register_hourly(*callbacks: Callable):
|
||||
SchedulerRegistry._register("daily", SchedulerRegistry._hourly, callbacks)
|
||||
|
||||
@staticmethod
|
||||
def remove_hourly(callback: Callable):
|
||||
logger.info(f"Removing hourly callback: {callback.__name__}")
|
||||
SchedulerRegistry._hourly.remove(callback)
|
||||
|
||||
@staticmethod
|
||||
def register_minutely(*callbacks: Callable):
|
||||
SchedulerRegistry._register("minutely", SchedulerRegistry._minutely, callbacks)
|
||||
|
||||
@staticmethod
|
||||
def remove_minutely(callback: Callable):
|
||||
logger.info(f"Removing minutely callback: {callback.__name__}")
|
||||
SchedulerRegistry._minutely.remove(callback)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.core.config import get_app_dirs
|
||||
|
||||
from .scheduled_func import ScheduledFunc
|
||||
from .scheduler_registry import SchedulerRegistry
|
||||
@@ -13,8 +14,6 @@ logger = root_logger.get_logger()
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
app_dirs = get_app_dirs()
|
||||
TEMP_DATA = app_dirs.DATA_DIR / ".temp"
|
||||
SCHEDULER_DB = CWD / ".scheduler.db"
|
||||
SCHEDULER_DATABASE = f"sqlite:///{SCHEDULER_DB}"
|
||||
|
||||
@@ -31,17 +30,13 @@ class SchedulerService:
|
||||
SchedulerRegistry. See app.py for examples.
|
||||
"""
|
||||
|
||||
_scheduler: BackgroundScheduler = None
|
||||
# Not Sure if this is still needed?
|
||||
# _job_store: dict[str, ScheduledFunc] = {}
|
||||
_scheduler: BackgroundScheduler
|
||||
|
||||
@staticmethod
|
||||
def start():
|
||||
# Preclean
|
||||
SCHEDULER_DB.unlink(missing_ok=True)
|
||||
|
||||
# Scaffold
|
||||
TEMP_DATA.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Register Interval Jobs and Start Scheduler
|
||||
SchedulerService._scheduler = BackgroundScheduler(jobstores={"default": SQLAlchemyJobStore(SCHEDULER_DATABASE)})
|
||||
SchedulerService._scheduler.add_job(run_daily, "interval", minutes=MINUTES_DAY, id="Daily Interval Jobs")
|
||||
@@ -54,6 +49,7 @@ class SchedulerService:
|
||||
def scheduler(cls) -> BackgroundScheduler:
|
||||
return SchedulerService._scheduler
|
||||
|
||||
@staticmethod
|
||||
def add_cron_job(job_func: ScheduledFunc):
|
||||
SchedulerService.scheduler.add_job(
|
||||
job_func.callback,
|
||||
@@ -68,6 +64,7 @@ class SchedulerService:
|
||||
|
||||
# SchedulerService._job_store[job_func.id] = job_func
|
||||
|
||||
@staticmethod
|
||||
def update_cron_job(job_func: ScheduledFunc):
|
||||
SchedulerService.scheduler.reschedule_job(
|
||||
job_func.id,
|
||||
|
||||
Reference in New Issue
Block a user