Feature: Shopping List Label Section Improvements (#2090)

* added backend for shopping list label config

* updated codegen

* refactored shopping list ops to service
removed unique contraint
removed label settings from main route/schema
added new route for label settings

* codegen

* made sure label settings output in position order

* implemented submenu for label order drag and drop

* removed redundant label and tweaked formatting

* added view by label to user preferences

* made items draggable within each label section

* moved reorder labels to its own button

* made dialog scrollable

* fixed broken model

* refactored labels to use a service
moved shopping list label logic to service
modified label seeder to use service

* added tests

* fix for first label missing the tag icon

* fixed wrong mapped type

* added statement to create existing relationships

* fix restore test, maybe
This commit is contained in:
Michael Genson
2023-02-21 21:58:41 -06:00
committed by GitHub
parent e14851531d
commit a6c46a7420
22 changed files with 715 additions and 61 deletions

View File

@@ -5,7 +5,11 @@ from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
from mealie.db.models.recipe.api_extras import (
ShoppingListExtras,
ShoppingListItemExtras,
api_extras,
)
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init
@@ -99,6 +103,26 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
pass
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists_multi_purpose_labels"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label: Mapped["MultiPurposeLabel"] = orm.relationship(
"MultiPurposeLabel", back_populates="shopping_lists_label_settings"
)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class Config:
exclude = {"label"}
@auto_init()
def __init__(self, **_) -> None:
pass
class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
@@ -117,6 +141,12 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
recipe_references: Mapped[ShoppingListRecipeReference] = orm.relationship(
ShoppingListRecipeReference, cascade="all, delete, delete-orphan"
)
label_settings: Mapped[list["ShoppingListMultiPurposeLabel"]] = orm.relationship(
ShoppingListMultiPurposeLabel,
cascade="all, delete, delete-orphan",
order_by="ShoppingListMultiPurposeLabel.position",
collection_class=ordering_list("position"),
)
extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan")
class Config:

View File

@@ -9,7 +9,8 @@ from ._model_utils import auto_init
from ._model_utils.guid import GUID
if TYPE_CHECKING:
from group import Group, ShoppingListItem
from group import Group
from group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from recipe import IngredientFoodModel
@@ -24,6 +25,9 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")
foods: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="label")
shopping_lists_label_settings: Mapped[list["ShoppingListMultiPurposeLabel"]] = orm.relationship(
"ShoppingListMultiPurposeLabel", back_populates="label", cascade="all, delete, delete-orphan"
)
@auto_init()
def __init__(self, **_) -> None:

View File

@@ -15,6 +15,7 @@ from mealie.db.models.group.shopping_list import (
ShoppingList,
ShoppingListItem,
ShoppingListItemRecipeReference,
ShoppingListMultiPurposeLabel,
ShoppingListRecipeReference,
)
from mealie.db.models.group.webhooks import GroupWebhooksModel
@@ -40,6 +41,7 @@ from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.group_shopping_list import (
ShoppingListItemOut,
ShoppingListItemRecipeRefOut,
ShoppingListMultiPurposeLabelOut,
ShoppingListOut,
ShoppingListRecipeRefOut,
)
@@ -222,6 +224,12 @@ class AllRepositories:
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
@cached_property
def shopping_list_multi_purpose_labels(
self,
) -> RepositoryGeneric[ShoppingListMultiPurposeLabelOut, ShoppingListMultiPurposeLabel]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListMultiPurposeLabel, ShoppingListMultiPurposeLabelOut)
@cached_property
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)

View File

@@ -1,15 +1,24 @@
import json
import pathlib
from collections.abc import Generator
from functools import cached_property
from mealie.schema.labels import MultiPurposeLabelSave
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood, SaveIngredientUnit
from mealie.schema.recipe.recipe_ingredient import (
SaveIngredientFood,
SaveIngredientUnit,
)
from mealie.services.group_services.labels_service import MultiPurposeLabelService
from ._abstract_seeder import AbstractSeeder
from .resources import foods, labels, units
class MultiPurposeLabelSeeder(AbstractSeeder):
@cached_property
def service(self):
return MultiPurposeLabelService(self.repos, self.group_id)
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else labels.en_US
@@ -27,7 +36,7 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
self.logger.info("Seeding MultiPurposeLabel")
for label in self.load_data(locale):
try:
self.repos.group_multi_purpose_labels.create(label)
self.service.create_one(label)
except Exception as e:
self.logger.error(e)

View File

@@ -10,25 +10,28 @@ from mealie.routes._base.routers import MealieCrudRoute
from mealie.schema.labels import (
MultiPurposeLabelCreate,
MultiPurposeLabelOut,
MultiPurposeLabelSave,
MultiPurposeLabelSummary,
MultiPurposeLabelUpdate,
)
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
from mealie.schema.mapper import cast
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.group_services.labels_service import MultiPurposeLabelService
router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"], route_class=MealieCrudRoute)
@controller(router)
class MultiPurposeLabelsController(BaseUserController):
@cached_property
def service(self):
return MultiPurposeLabelService(self.repos, self.group.id)
@cached_property
def repo(self):
if not self.user:
raise Exception("No user is logged in.")
return self.repos.group_multi_purpose_labels.by_group(self.user.group_id)
return self.repos.group_multi_purpose_labels
# =======================================================================
# CRUD Operations
@@ -49,8 +52,7 @@ class MultiPurposeLabelsController(BaseUserController):
@router.post("", response_model=MultiPurposeLabelOut)
def create_one(self, data: MultiPurposeLabelCreate):
save_data = cast(data, MultiPurposeLabelSave, group_id=self.user.group_id)
return self.mixins.create_one(save_data)
return self.service.create_one(data)
@router.get("/{item_id}", response_model=MultiPurposeLabelOut)
def get_one(self, item_id: UUID4):

View File

@@ -1,7 +1,7 @@
from collections.abc import Callable
from functools import cached_property
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseCrudController
@@ -16,6 +16,7 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListMultiPurposeLabelUpdate,
ShoppingListOut,
ShoppingListPagination,
ShoppingListRemoveRecipeParams,
@@ -23,7 +24,6 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListSummary,
ShoppingListUpdate,
)
from mealie.schema.mapper import cast
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import SuccessResponse
from mealie.services.event_bus_service.event_types import (
@@ -89,7 +89,7 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
class ShoppingListItemController(BaseCrudController):
@cached_property
def service(self):
return ShoppingListService(self.repos)
return ShoppingListService(self.repos, self.user, self.group)
@cached_property
def repo(self):
@@ -154,7 +154,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
class ShoppingListController(BaseCrudController):
@cached_property
def service(self):
return ShoppingListService(self.repos)
return ShoppingListService(self.repos, self.user, self.group)
@cached_property
def repo(self):
@@ -179,9 +179,7 @@ class ShoppingListController(BaseCrudController):
@router.post("", response_model=ShoppingListOut, status_code=201)
def create_one(self, data: ShoppingListCreate):
save_data = cast(data, ShoppingListSave, group_id=self.user.group_id)
shopping_list = self.mixins.create_one(save_data)
shopping_list = self.service.create_one_list(data)
if shopping_list:
self.publish_event(
event_type=EventTypes.shopping_list_created,
@@ -197,14 +195,12 @@ class ShoppingListController(BaseCrudController):
@router.put("/{item_id}", response_model=ShoppingListOut)
def update_one(self, item_id: UUID4, data: ShoppingListUpdate):
shopping_list = self.mixins.update_one(data, item_id) # type: ignore
if shopping_list:
self.publish_event(
event_type=EventTypes.shopping_list_updated,
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=shopping_list.id),
message=self.t("notifications.generic-updated", name=shopping_list.name),
)
shopping_list = self.mixins.update_one(data, item_id)
self.publish_event(
event_type=EventTypes.shopping_list_updated,
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=shopping_list.id),
message=self.t("notifications.generic-updated", name=shopping_list.name),
)
return shopping_list
@@ -244,3 +240,23 @@ class ShoppingListController(BaseCrudController):
publish_list_item_events(self.publish_event, items)
return shopping_list
@router.put("/{item_id}/label-settings", response_model=ShoppingListOut)
def update_label_settings(self, item_id: UUID4, data: list[ShoppingListMultiPurposeLabelUpdate]):
for setting in data:
if setting.shopping_list_id != item_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"object {setting.id} has an invalid shopping list id",
)
self.repos.shopping_list_multi_purpose_labels.update_many(data)
updated_list = self.get_one(item_id)
self.publish_event(
event_type=EventTypes.shopping_list_updated,
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=updated_list.id),
message=self.t("notifications.generic-updated", name=updated_list.name),
)
return updated_list

View File

@@ -14,7 +14,11 @@ from .group_events import (
from .group_exports import GroupDataExport
from .group_migration import DataMigrationCreate, SupportedMigrations
from .group_permissions import SetPermissions
from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
from .group_preferences import (
CreateGroupPreferences,
ReadGroupPreferences,
UpdateGroupPreferences,
)
from .group_seeder import SeederConfig
from .group_shopping_list import (
ShoppingListAddRecipeParams,
@@ -28,6 +32,9 @@ from .group_shopping_list import (
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListMultiPurposeLabelCreate,
ShoppingListMultiPurposeLabelOut,
ShoppingListMultiPurposeLabelUpdate,
ShoppingListOut,
ShoppingListPagination,
ShoppingListRecipeRefOut,
@@ -37,8 +44,20 @@ from .group_shopping_list import (
ShoppingListUpdate,
)
from .group_statistics import GroupStatistics, GroupStorage
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
from .invite_token import (
CreateInviteToken,
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from .webhook import (
CreateWebhook,
ReadWebhook,
SaveWebhook,
WebhookPagination,
WebhookType,
)
__all__ = [
"CreateGroupPreferences",
@@ -73,6 +92,9 @@ __all__ = [
"ShoppingListItemUpdate",
"ShoppingListItemUpdateBulk",
"ShoppingListItemsCollectionOut",
"ShoppingListMultiPurposeLabelCreate",
"ShoppingListMultiPurposeLabelOut",
"ShoppingListMultiPurposeLabelUpdate",
"ShoppingListOut",
"ShoppingListPagination",
"ShoppingListRecipeRefOut",

View File

@@ -9,6 +9,8 @@ from pydantic.utils import GetterDict
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_ingredient import (
INGREDIENT_QTY_PRECISION,
MAX_INGREDIENT_DENOMINATOR,
@@ -186,6 +188,23 @@ class ShoppingListItemsCollectionOut(MealieModel):
deleted_items: list[ShoppingListItemOut] = []
class ShoppingListMultiPurposeLabelCreate(MealieModel):
shopping_list_id: UUID4
label_id: UUID4
position: int = 0
class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate):
id: UUID4
class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate):
label: MultiPurposeLabelSummary
class Config:
orm_mode = True
class ShoppingListItemPagination(PaginationBase):
items: list[ShoppingListItemOut]
@@ -217,6 +236,8 @@ class ShoppingListSave(ShoppingListCreate):
class ShoppingListSummary(ShoppingListSave):
id: UUID4
recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut]
class Config:
orm_mode = True
@@ -233,16 +254,25 @@ class ShoppingListPagination(PaginationBase):
items: list[ShoppingListSummary]
class ShoppingListUpdate(ShoppingListSummary):
class ShoppingListUpdate(ShoppingListSave):
id: UUID4
list_items: list[ShoppingListItemOut] = []
class ShoppingListOut(ShoppingListUpdate):
recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut]
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, name_orm: ShoppingList):
return {
**GetterDict(name_orm),
"extras": {x.key_name: x.value for x in name_orm.extras},
}
class ShoppingListAddRecipeParams(MealieModel):
recipe_increment_quantity: float = 1
@@ -252,10 +282,3 @@ class ShoppingListAddRecipeParams(MealieModel):
class ShoppingListRemoveRecipeParams(MealieModel):
recipe_decrement_quantity: float = 1
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402
ShoppingListRecipeRefOut.update_forward_refs()
ShoppingListItemOut.update_forward_refs()

View File

@@ -0,0 +1,45 @@
from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_shopping_list import ShoppingListMultiPurposeLabelCreate
from mealie.schema.labels.multi_purpose_label import (
MultiPurposeLabelCreate,
MultiPurposeLabelOut,
MultiPurposeLabelSave,
)
from mealie.schema.response.pagination import PaginationQuery
class MultiPurposeLabelService:
def __init__(self, repos: AllRepositories, group_id: UUID4):
self.repos = repos
self.group_id = group_id
self.labels = repos.group_multi_purpose_labels
def _update_shopping_list_label_references(self, new_labels: list[MultiPurposeLabelOut]) -> None:
shopping_lists_repo = self.repos.group_shopping_lists.by_group(self.group_id)
shopping_list_multi_purpose_labels_repo = self.repos.shopping_list_multi_purpose_labels
shopping_lists = shopping_lists_repo.page_all(PaginationQuery(page=1, per_page=-1))
new_shopping_list_labels: list[ShoppingListMultiPurposeLabelCreate] = []
for label in new_labels:
new_shopping_list_labels.extend(
[
ShoppingListMultiPurposeLabelCreate(
shopping_list_id=shopping_list.id, label_id=label.id, position=len(shopping_list.label_settings)
)
for shopping_list in shopping_lists.items
]
)
shopping_list_multi_purpose_labels_repo.create_many(new_shopping_list_labels)
def create_one(self, data: MultiPurposeLabelCreate) -> MultiPurposeLabelOut:
label = self.labels.create(data.cast(MultiPurposeLabelSave, group_id=self.group_id))
self._update_shopping_list_label_references([label])
return label
def create_many(self, data: list[MultiPurposeLabelCreate]) -> list[MultiPurposeLabelOut]:
labels = self.labels.create_many([label.cast(MultiPurposeLabelSave, group_id=self.group_id) for label in data])
self._update_shopping_list_label_references(labels)
return labels

View File

@@ -6,6 +6,7 @@ from mealie.core.exceptions import UnexpectedNone
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
from mealie.schema.group.group_shopping_list import (
ShoppingListCreate,
ShoppingListItemBase,
ShoppingListItemOut,
ShoppingListItemRecipeRefCreate,
@@ -13,18 +14,23 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListMultiPurposeLabelCreate,
ShoppingListSave,
)
from mealie.schema.recipe.recipe_ingredient import (
IngredientFood,
IngredientUnit,
RecipeIngredient,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.schema.user.user import GroupInDB, PrivateUser
class ShoppingListService:
def __init__(self, repos: AllRepositories):
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
self.repos = repos
self.user = user
self.group = group
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
@@ -463,3 +469,18 @@ class ShoppingListService:
break
return self.shopping_lists.get_one(shopping_list.id), items # type: ignore
def create_one_list(self, data: ShoppingListCreate):
create_data = data.cast(ShoppingListSave, group_id=self.group.id)
new_list = self.shopping_lists.create(create_data) # type: ignore
labels = self.repos.group_multi_purpose_labels.by_group(self.group.id).page_all(
PaginationQuery(page=1, per_page=-1, order_by="name", order_direction=OrderDirection.asc)
)
label_settings = [
ShoppingListMultiPurposeLabelCreate(shopping_list_id=new_list.id, label_id=label.id, position=i)
for i, label in enumerate(labels.items)
]
self.repos.shopping_list_multi_purpose_labels.create_many(label_settings)
return self.shopping_lists.get_one(new_list.id)