Feature/shopping lists second try (#927)

* generate types

* use generated types

* ui updates

* init button link for common styles

* add links

* setup label views

* add delete confirmation

* reset when not saved

* link label to foods and auto set when adding to shopping list

* generate types

* use inheritence to manage exception handling

* fix schema generation and add test for open_api generation

* add header to api docs

* move list consilidation to service

* split list and list items controller

* shopping list/list item tests - PARTIAL

* enable recipe add/remove in shopping lists

* generate types

* linting

* init global utility components

* update types and add list item api

* fix import cycle and database error

* add container and border classes

* new recipe list component

* fix tests

* breakout item editor

* refactor item editor

* update bulk actions

* update input / color contrast

* type generation

* refactor controller dependencies

* include food/unit editor

* remove console.logs

* fix and update type generation

* fix incorrect type for column

* fix postgres error

* fix delete by variable

* auto remove refs

* fix typo
This commit is contained in:
Hayden
2022-01-16 15:24:24 -09:00
committed by GitHub
parent f794208862
commit 92cf97e401
66 changed files with 2556 additions and 685 deletions

View File

@@ -15,9 +15,33 @@ from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks
logger = get_logger()
settings = get_app_settings()
description = f"""
Mealie is a web application for managing your recipes, meal plans, and shopping lists. This is the Restful
API interactive documentation that can be used to explore the API. If you're justing getting started with
the API and want to get started quickly, you can use the [API Usage | Mealie Docs](https://hay-kot.github.io/mealie/documentation/getting-started/api-usage/)
as a reference for how to get started.
As of this release <b>{APP_VERSION}</b>, Mealie is still in rapid development and therefore some of these APIs may change from version to version.
If you have any questions or comments about mealie, please use the discord server to talk to the developers or other community members.
If you'd like to file an issue, please use the [GitHub Issue Tracker | Mealie](https://github.com/hay-kot/mealie/issues/new/choose)
## Helpful Links
- [Home Page](https://mealie.io)
- [Documentation](https://hay-kot.github.io/mealie/)
- [Discord](https://discord.gg/QuStdQGSGK)
- [Demo](https://demo.mealie.io)
- [Beta](https://beta.mealie.io)
"""
app = FastAPI(
title="Mealie",
description="A place for all your recipes",
description=description,
version=APP_VERSION,
docs_url=settings.DOCS_URL,
redoc_url=settings.REDOC_URL,

View File

@@ -48,7 +48,7 @@ class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins):
id = Column(GUID, primary_key=True, default=GUID.generate)
name = Column(String, nullable=False)
enabled = Column(String, default=True, nullable=False)
enabled = Column(Boolean, default=True, nullable=False)
apprise_url = Column(String, nullable=False)
group = orm.relationship("Group", back_populates="group_event_notifiers", single_parent=True)

View File

@@ -8,6 +8,20 @@ from .._model_utils import GUID, auto_init
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference"
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 = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity = Column(Float, nullable=False)
@auto_init()
def __init__(self, **_) -> None:
pass
class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
@@ -16,7 +30,6 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"))
# 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)
@@ -36,8 +49,30 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id"))
label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="shopping_list_items")
# Recipe Reference
recipe_references = orm.relationship(ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan")
class Config:
exclude = {"id", "label"}
exclude = {"id", "label", "food", "unit"}
@auto_init()
def __init__(self, **_) -> None:
pass
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference"
id = Column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
recipe_id = Column(Integer, ForeignKey("recipes.id"))
recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs")
recipe_quantity = Column(Float, nullable=False)
class Config:
exclude = {"id", "recipe"}
@auto_init()
def __init__(self, **_) -> None:
@@ -59,6 +94,11 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
collection_class=ordering_list("position"),
)
recipe_references = orm.relationship(ShoppingListRecipeReference, cascade="all, delete, delete-orphan")
class Config:
exclude = {"id", "list_items"}
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -10,6 +10,7 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels"
id = Column(GUID, default=GUID.generate, primary_key=True)
name = Column(String(255), nullable=False)
color = Column(String(10), nullable=False, default="")
group_id = Column(GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="labels")

View File

@@ -106,6 +106,18 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
date_added = sa.Column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime)
# Shopping List Refs
shopping_list_refs = orm.relationship(
"ShoppingListRecipeReference",
back_populates="recipe",
cascade="all, delete-orphan",
)
shopping_list_item_refs = orm.relationship(
"ShoppingListItemRecipeReference",
back_populates="recipe",
cascade="all, delete-orphan",
)
class Config:
get_attr = "slug"
exclude = {

View File

@@ -9,7 +9,12 @@ from mealie.db.models.group.events import GroupEventNotifierModel
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.shopping_list import (
ShoppingList,
ShoppingListItem,
ShoppingListItemRecipeReference,
ShoppingListRecipeReference,
)
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.category import Category
@@ -28,7 +33,12 @@ from mealie.schema.events import Event as EventSchema
from mealie.schema.group.group_events import GroupEventNotifierOut
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.group_shopping_list import (
ShoppingListItemOut,
ShoppingListItemRecipeRefOut,
ShoppingListOut,
ShoppingListRecipeRefOut,
)
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.labels import MultiPurposeLabelOut
@@ -188,6 +198,18 @@ class AllRepositories:
def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut)
@cached_property
def group_shopping_list_item_references(
self,
) -> RepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]:
return RepositoryGeneric(self.session, pk_id, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
@cached_property
def group_shopping_list_recipe_refs(
self,
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
return RepositoryGeneric(self.session, pk_id, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
@cached_property
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)

View File

@@ -311,3 +311,16 @@ class RepositoryGeneric(Generic[T, D]):
eff_schema.from_orm(x)
for x in self.session.query(self.sql_model).filter(attribute_name == attr_match).all() # noqa: 711
]
def create_many(self, documents: list[T]) -> list[T]:
new_documents = []
for document in documents:
document = document if isinstance(document, dict) else document.dict()
new_document = self.sql_model(session=self.session, **document)
new_documents.append(new_document)
self.session.add_all(new_documents)
self.session.commit()
self.session.refresh(new_documents)
return [self.schema.from_orm(x) for x in new_documents]

View File

@@ -1,59 +1,11 @@
from pydantic import UUID4
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
from mealie.db.models.group.shopping_list import ShoppingList
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)

View File

@@ -1,8 +1,10 @@
from abc import ABC
from functools import cached_property
from typing import Type
from fastapi import Depends
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.repos.all_repositories import AllRepositories
from mealie.routes._base.checks import OperationChecks
from mealie.routes._base.dependencies import SharedDependencies
@@ -27,6 +29,12 @@ class BaseUserController(ABC):
deps: SharedDependencies = Depends(SharedDependencies.user)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def repos(self):
return AllRepositories(self.deps.session)

View File

@@ -1,10 +1,8 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.schema.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper
from mealie.schema.query import GetAll
@@ -29,14 +27,6 @@ class AdminUserManagementRoutes(BaseAdminController):
return self.deps.repos.groups
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations

View File

@@ -1,10 +1,8 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base import BaseAdminController, controller
from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import CrudMixins
@@ -25,14 +23,6 @@ class AdminUserManagementRoutes(BaseAdminController):
return self.deps.repos.users
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations

View File

@@ -26,14 +26,6 @@ class RecipeCommentRoutes(BaseUserController):
def repo(self):
return self.deps.repos.comments
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations

View File

@@ -25,5 +25,6 @@ router.include_router(controller_invitations.router)
router.include_router(controller_migrations.router)
router.include_router(controller_group_reports.router)
router.include_router(controller_shopping_lists.router)
router.include_router(controller_shopping_lists.item_router)
router.include_router(controller_labels.router)
router.include_router(controller_group_notifications.router)

View File

@@ -1,12 +1,10 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
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_events import (
GroupEventNotifierCreate,
@@ -23,8 +21,7 @@ router = APIRouter(prefix="/groups/events/notifications", tags=["Group: Event No
@controller(router)
class GroupEventsNotifierController:
deps: SharedDependencies = Depends(SharedDependencies.user)
class GroupEventsNotifierController(BaseUserController):
event_bus: EventBusService = Depends(EventBusService)
@cached_property
@@ -34,14 +31,6 @@ class GroupEventsNotifierController:
return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id)
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations

View File

@@ -1,12 +1,10 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
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,
@@ -22,9 +20,7 @@ router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"]
@controller(router)
class MultiPurposeLabelsController:
deps: SharedDependencies = Depends(SharedDependencies.user)
class MultiPurposeLabelsController(BaseUserController):
@cached_property
def repo(self):
if not self.deps.acting_user:
@@ -32,14 +28,6 @@ class MultiPurposeLabelsController:
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 = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations

View File

@@ -1,15 +1,16 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import UUID4
from mealie.core.exceptions import mealie_registered_exceptions
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.group.group_shopping_list import (
ShoppingListCreate,
ShoppingListItemCreate,
ShoppingListItemOut,
ShoppingListItemUpdate,
ShoppingListOut,
ShoppingListSave,
ShoppingListSummary,
@@ -17,10 +18,75 @@ from mealie.schema.group.group_shopping_list import (
)
from mealie.schema.mapper import cast
from mealie.schema.query import GetAll
from mealie.schema.response.responses import SuccessResponse
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.message_types import EventTypes
from mealie.services.group_services.shopping_lists import ShoppingListService
item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"])
@controller(item_router)
class ShoppingListItemController(BaseUserController):
@cached_property
def service(self):
return ShoppingListService(self.repos)
@cached_property
def repo(self):
return self.deps.repos.group_shopping_list_item
@cached_property
def mixins(self):
return CrudMixins[ShoppingListItemCreate, ShoppingListItemOut, ShoppingListItemCreate](
self.repo,
self.deps.logger,
)
@item_router.put("", response_model=list[ShoppingListItemOut])
def update_many(self, data: list[ShoppingListItemUpdate]):
# TODO: Convert to update many with single call
all_updates = []
keep_ids = []
for item in self.service.consolidate_list_items(data):
updated_data = self.mixins.update_one(item, item.id)
all_updates.append(updated_data)
keep_ids.append(updated_data.id)
for item in data:
if item.id not in keep_ids:
self.mixins.delete_one(item.id)
return all_updates
@item_router.delete("", response_model=SuccessResponse)
def delete_many(self, ids: list[UUID4] = Query(None)):
x = 0
for item_id in ids:
self.mixins.delete_one(item_id)
x += 1
return SuccessResponse.respond(message=f"Successfully deleted {x} items")
@item_router.post("", response_model=ShoppingListItemOut, status_code=201)
def create_one(self, data: ShoppingListItemCreate):
return self.mixins.create_one(data)
@item_router.get("/{item_id}", response_model=ShoppingListItemOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@item_router.put("/{item_id}", response_model=ShoppingListItemOut)
def update_one(self, item_id: UUID4, data: ShoppingListItemUpdate):
return self.mixins.update_one(data, item_id)
@item_router.delete("/{item_id}", response_model=ShoppingListItemOut)
def delete_one(self, item_id: UUID4):
return self.mixins.delete_one(item_id) # type: ignore
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
@@ -34,23 +100,12 @@ class ShoppingListController(BaseUserController):
@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 = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
# =======================================================================
# CRUD Operations
@property
@cached_property
def mixins(self) -> CrudMixins:
return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
@@ -58,7 +113,7 @@ class ShoppingListController(BaseUserController):
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)
@router.post("", response_model=ShoppingListOut, status_code=201)
def create_one(self, data: ShoppingListCreate):
save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id)
val = self.mixins.create_one(save_data)
@@ -74,7 +129,7 @@ class ShoppingListController(BaseUserController):
@router.get("/{item_id}", response_model=ShoppingListOut)
def get_one(self, item_id: UUID4):
return self.repo.get_one(item_id)
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=ShoppingListOut)
def update_one(self, item_id: UUID4, data: ShoppingListUpdate):

View File

@@ -1,14 +1,12 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from mealie.core.exceptions import mealie_registered_exceptions
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.query import GetAll
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
@@ -19,38 +17,30 @@ class IngredientFoodsController(BaseUserController):
def repo(self):
return self.deps.repos.ingredient_foods
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit](
return CrudMixins[CreateIngredientFood, IngredientFood, CreateIngredientFood](
self.repo,
self.deps.logger,
self.registered_exceptions,
)
@router.get("", response_model=list[IngredientUnit])
@router.get("", response_model=list[IngredientFood])
def get_all(self, q: GetAll = Depends(GetAll)):
return self.repo.get_all(start=q.start, limit=q.limit)
@router.post("", response_model=IngredientUnit, status_code=201)
def create_one(self, data: CreateIngredientUnit):
@router.post("", response_model=IngredientFood, status_code=201)
def create_one(self, data: CreateIngredientFood):
return self.mixins.create_one(data)
@router.get("/{item_id}", response_model=IngredientUnit)
@router.get("/{item_id}", response_model=IngredientFood)
def get_one(self, item_id: int):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=IngredientUnit)
def update_one(self, item_id: int, data: CreateIngredientUnit):
@router.put("/{item_id}", response_model=IngredientFood)
def update_one(self, item_id: int, data: CreateIngredientFood):
return self.mixins.update_one(data, item_id)
@router.delete("/{item_id}", response_model=IngredientUnit)
@router.delete("/{item_id}", response_model=IngredientFood)
def delete_one(self, item_id: int):
return self.mixins.delete_one(item_id)

View File

@@ -1,9 +1,7 @@
from functools import cached_property
from typing import Type
from fastapi import APIRouter, Depends
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.routes._base.abc_controller import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import CrudMixins
@@ -19,14 +17,6 @@ class IngredientUnitsController(BaseUserController):
def repo(self):
return self.deps.repos.ingredient_units
def registered_exceptions(self, ex: Type[Exception]) -> str:
registered = {
**mealie_registered_exceptions(self.deps.t),
}
return registered.get(ex, "An unexpected error occurred.")
@cached_property
def mixins(self):
return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit](

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from typing import Optional
from fastapi_camelcase import CamelModel
@@ -6,6 +8,19 @@ from pydantic import UUID4
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
class ShoppingListItemRecipeRef(CamelModel):
recipe_id: int
recipe_quantity: float
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRef):
id: UUID4
shopping_list_item_id: UUID4
class Config:
orm_mode = True
class ShoppingListItemCreate(CamelModel):
shopping_list_id: UUID4
checked: bool = False
@@ -16,30 +31,41 @@ class ShoppingListItemCreate(CamelModel):
note: Optional[str] = ""
quantity: float = 1
unit_id: int = None
unit: IngredientUnit = None
unit: Optional[IngredientUnit]
food_id: int = None
food: IngredientFood = None
recipe_id: Optional[int] = None
food: Optional[IngredientFood]
label_id: Optional[UUID4] = None
recipe_references: list[ShoppingListItemRecipeRef] = []
class ShoppingListItemOut(ShoppingListItemCreate):
class ShoppingListItemUpdate(ShoppingListItemCreate):
id: UUID4
label: "Optional[MultiPurposeLabelSummary]" = None
class ShoppingListItemOut(ShoppingListItemUpdate):
label: Optional[MultiPurposeLabelSummary]
recipe_references: list[ShoppingListItemRecipeRefOut] = []
class Config:
orm_mode = True
class ShoppingListCreate(CamelModel):
"""
Create Shopping List
"""
name: str = None
class ShoppingListRecipeRefOut(CamelModel):
id: UUID4
shopping_list_id: UUID4
recipe_id: int
recipe_quantity: float
recipe: RecipeSummary
class Config:
orm_mode = True
class ShoppingListSave(ShoppingListCreate):
group_id: UUID4
@@ -56,10 +82,14 @@ class ShoppingListUpdate(ShoppingListSummary):
class ShoppingListOut(ShoppingListUpdate):
recipe_references: list[ShoppingListRecipeRefOut]
class Config:
orm_mode = True
from mealie.schema.labels import MultiPurposeLabelSummary
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
from mealie.schema.recipe.recipe import RecipeSummary
ShoppingListRecipeRefOut.update_forward_refs()
ShoppingListItemOut.update_forward_refs()

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
from fastapi_camelcase import CamelModel
from pydantic import UUID4
from mealie.schema.recipe import IngredientFood
class MultiPurposeLabelCreate(CamelModel):
name: str
color: str = ""
class MultiPurposeLabelSave(MultiPurposeLabelCreate):
@@ -24,13 +25,14 @@ class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
shopping_list_items: "list[ShoppingListItemOut]" = []
foods: list[IngredientFood] = []
# shopping_list_items: list[ShoppingListItemOut] = []
# foods: list[IngredientFood] = []
class Config:
orm_mode = True
from mealie.schema.group.group_shopping_list import ShoppingListItemOut
# from mealie.schema.recipe.recipe_ingredient import IngredientFood
# from mealie.schema.group.group_shopping_list import ShoppingListItemOut
MultiPurposeLabelOut.update_forward_refs()
# MultiPurposeLabelOut.update_forward_refs()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Any, Optional
@@ -13,7 +15,6 @@ from mealie.db.models.recipe.recipe import RecipeModel
from .recipe_asset import RecipeAsset
from .recipe_comments import RecipeCommentOut
from .recipe_ingredient import RecipeIngredient
from .recipe_notes import RecipeNote
from .recipe_nutrition import Nutrition
from .recipe_settings import RecipeSettings
@@ -91,25 +92,25 @@ class RecipeSummary(CamelModel):
class Config:
orm_mode = True
@validator("tags", always=True, pre=True)
@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 cats
@validator("recipe_category", always=True, pre=True)
@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 cats
@validator("group_id", always=True, pre=True)
@validator("group_id", always=True, pre=True, allow_reuse=True)
def validate_group_id(group_id: Any):
if isinstance(group_id, int):
return uuid4()
return group_id
@validator("user_id", always=True, pre=True)
@validator("user_id", always=True, pre=True, allow_reuse=True)
def validate_user_id(user_id: Any):
if isinstance(user_id, int):
return uuid4()
@@ -164,14 +165,14 @@ class Recipe(RecipeSummary):
"extras": {x.key_name: x.value for x in name_orm.extras},
}
@validator("slug", always=True, pre=True)
@validator("slug", always=True, pre=True, allow_reuse=True)
def validate_slug(slug: str, values):
if not values.get("name"):
return slug
return slugify(values["name"])
@validator("recipe_ingredient", always=True, pre=True)
@validator("recipe_ingredient", always=True, pre=True, allow_reuse=True)
def validate_ingredients(recipe_ingredient, values):
if not recipe_ingredient or not isinstance(recipe_ingredient, list):
return recipe_ingredient
@@ -180,3 +181,9 @@ class Recipe(RecipeSummary):
return [RecipeIngredient(note=x) for x in recipe_ingredient]
return recipe_ingredient
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
RecipeSummary.update_forward_refs()
Recipe.update_forward_refs()

View File

@@ -9,23 +9,23 @@ class ExportTypes(str, enum.Enum):
JSON = "json"
class _ExportBase(CamelModel):
class ExportBase(CamelModel):
recipes: list[str]
class ExportRecipes(_ExportBase):
class ExportRecipes(ExportBase):
export_type: ExportTypes = ExportTypes.JSON
class AssignCategories(_ExportBase):
class AssignCategories(ExportBase):
categories: list[CategoryBase]
class AssignTags(_ExportBase):
class AssignTags(ExportBase):
tags: list[TagBase]
class DeleteRecipes(_ExportBase):
class DeleteRecipes(ExportBase):
pass

View File

@@ -1,21 +1,21 @@
from __future__ import annotations
import enum
from typing import Optional, Union
from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel
from pydantic import Field
uuid4()
from pydantic import UUID4, Field
class CreateIngredientFood(CamelModel):
class UnitFoodBase(CamelModel):
name: str
description: str = ""
class CreateIngredientUnit(CreateIngredientFood):
fraction: bool = True
abbreviation: str = ""
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 = None
label: MultiPurposeLabelSummary = None
class IngredientFood(CreateIngredientFood):
@@ -25,6 +25,11 @@ class IngredientFood(CreateIngredientFood):
orm_mode = True
class CreateIngredientUnit(UnitFoodBase):
fraction: bool = True
abbreviation: str = ""
class IngredientUnit(CreateIngredientUnit):
id: int
@@ -77,3 +82,9 @@ class IngredientsRequest(CamelModel):
class IngredientRequest(CamelModel):
parser: RegisteredParser = RegisteredParser.nlp
ingredient: str
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
CreateIngredientFood.update_forward_refs()
IngredientFood.update_forward_refs()

View File

@@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .recipe_keys import *

View File

@@ -1,19 +1,96 @@
from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group import ShoppingListOut
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate
from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
from mealie.schema.group.group_shopping_list import (
ShoppingListItemOut,
ShoppingListItemRecipeRef,
ShoppingListItemUpdate,
)
class ShoppingListService:
def __init__(self, repos: AllRepositories):
self.repos = repos
self.repo = repos.group_shopping_lists
self.shopping_lists = repos.group_shopping_lists
self.list_items = repos.group_shopping_list_item
self.list_item_refs = repos.group_shopping_list_item_references
self.list_refs = repos.group_shopping_list_recipe_refs
@staticmethod
def can_merge(item1: ShoppingListItemOut, item2: ShoppingListItemOut) -> bool:
"""
can_merge checks if the two items can be merged together.
"""
# If no food or units are present check against the notes field.
if not all([item1.food, item1.unit, item2.food, item2.unit]):
return item1.note == item2.note
# If the items have the same food and unit they can be merged.
if item1.unit == item2.unit and item1.food == item2.food:
return True
# Otherwise Assume They Can't Be Merged
return False
def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]:
"""
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.
"""
consolidated_list: list[ShoppingListItemOut] = []
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 ShoppingListService.can_merge(base_item, inner_item):
# Set Quantity
base_item.quantity += inner_item.quantity
# Set References
new_refs = []
for ref in inner_item.recipe_references:
ref.shopping_list_item_id = base_item.id
new_refs.append(ref)
base_item.recipe_references.extend(new_refs)
checked_items.append(inner_index)
consolidated_list.append(base_item)
return consolidated_list
def consolidate_and_save(self, data: list[ShoppingListItemUpdate]):
# TODO: Convert to update many with single call
all_updates = []
keep_ids = []
for item in self.consolidate_list_items(data):
updated_data = self.list_items.update(item.id, item)
all_updates.append(updated_data)
keep_ids.append(updated_data.id)
for item in data:
if item.id not in keep_ids:
self.list_items.delete(item.id)
return all_updates
# =======================================================================
# Methods
def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut:
recipe = self.repos.recipes.get_one(recipe_id, "id")
shopping_list = self.repo.get_one(list_id)
to_create = []
for ingredient in recipe.recipe_ingredient:
@@ -23,6 +100,12 @@ class ShoppingListService:
except AttributeError:
pass
label_id = None
try:
label_id = ingredient.food.label.id
except AttributeError:
pass
unit_id = None
try:
unit_id = ingredient.unit.id
@@ -32,19 +115,77 @@ class ShoppingListService:
to_create.append(
ShoppingListItemCreate(
shopping_list_id=list_id,
is_food=True,
is_food=not recipe.settings.disable_amount,
food_id=food_id,
unit_id=unit_id,
quantity=ingredient.quantity,
note=ingredient.note,
label_id=label_id,
recipe_id=recipe_id,
recipe_references=[
ShoppingListItemRecipeRef(
recipe_id=recipe_id,
recipe_quantity=ingredient.quantity,
)
],
)
)
shopping_list.list_items.extend(to_create)
return self.repo.update(shopping_list.id, shopping_list)
for item in to_create:
self.repos.group_shopping_list_item.create(item)
updated_list = self.shopping_lists.get_one(list_id)
updated_list.list_items = self.consolidate_and_save(updated_list.list_items)
not_found = True
for refs in updated_list.recipe_references:
if refs.recipe_id == recipe_id:
refs.recipe_quantity += 1
not_found = False
if not_found:
updated_list.recipe_references.append(ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=1))
updated_list = self.shopping_lists.update(updated_list.id, updated_list)
return updated_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)
shopping_list = self.shopping_lists.get_one(list_id)
for item in shopping_list.list_items:
found = False
for ref in item.recipe_references:
remove_qty = 0
if ref.recipe_id == recipe_id:
self.list_item_refs.delete(ref.id)
item.recipe_references.remove(ref)
found = True
remove_qty = ref.recipe_quantity
break # only remove one instance of the recipe for each item
# If the item was found decrement the quantity by the remove_qty
if found:
item.quantity = item.quantity - remove_qty
if item.quantity <= 0:
self.list_items.delete(item.id)
else:
self.list_items.update(item.id, item)
# Decrament the list recipe reference count
for ref in shopping_list.recipe_references:
if ref.recipe_id == recipe_id:
ref.recipe_quantity -= 1
if ref.recipe_quantity <= 0:
self.list_refs.delete(ref.id)
else:
self.list_refs.update(ref.id, ref)
break
# Save Changes
return self.shopping_lists.get(shopping_list.id)