-
+
-
+
{{ $globals.icons.tags }}
{{ key }}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $globals.icons.tags }}
+ {{ $t('shopping-list.reorder-labels') }}
+
@@ -192,11 +211,13 @@ import { useIdle, useToggle } from "@vueuse/core";
import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
+import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
-import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/group";
+import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
+import { useShoppingListPreferences } from "~/composables/use-users/preferences";
type CopyTypes = "plain" | "markdown";
@@ -208,18 +229,21 @@ interface PresentLabel {
export default defineComponent({
components: {
draggable,
+ MultiPurposeLabelSection,
ShoppingListItem,
RecipeList,
ShoppingListItemEditor,
},
setup() {
+ const preferences = useShoppingListPreferences();
+
const { idle } = useIdle(5 * 60 * 1000) // 5 minutes
const loadingCounter = ref(1);
const recipeReferenceLoading = ref(false);
const userApi = useUserApi();
const edit = ref(false);
- const byLabel = ref(false);
+ const reorderLabelsDialog = ref(false);
const route = useRoute();
const id = route.value.params.id;
@@ -395,7 +419,33 @@ export default defineComponent({
const { foods: allFoods } = useFoodStore();
function sortByLabels() {
- byLabel.value = !byLabel.value;
+ preferences.value.viewByLabel = !preferences.value.viewByLabel;
+ }
+
+ function toggleReorderLabelsDialog() {
+ reorderLabelsDialog.value = !reorderLabelsDialog.value
+ }
+
+ async function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
+ if (!shoppingList.value) {
+ return;
+ }
+
+ labelSettings.forEach((labelSetting, index) => {
+ labelSetting.position = index;
+ return labelSetting;
+ });
+
+ // setting this doesn't have any effect on the data since it's refreshed automatically, but it makes the ux feel smoother
+ shoppingList.value.labelSettings = labelSettings;
+
+ loadingCounter.value += 1;
+ const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, labelSettings);
+ loadingCounter.value -= 1;
+
+ if (data) {
+ refresh();
+ }
}
const presentLabels = computed(() => {
@@ -442,7 +492,25 @@ export default defineComponent({
items[noLabelText] = noLabel;
}
- itemsByLabel.value = items;
+ // sort the map by label order
+ const orderedLabelNames = shoppingList.value?.labelSettings?.map((labelSetting) => { return labelSetting.label.name; })
+ if (!orderedLabelNames) {
+ itemsByLabel.value = items;
+ return;
+ }
+
+ const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {};
+ if (noLabelText in items) {
+ itemsSorted[noLabelText] = items[noLabelText];
+ }
+
+ orderedLabelNames.forEach(labelName => {
+ if (labelName in items) {
+ itemsSorted[labelName] = items[labelName];
+ }
+ });
+
+ itemsByLabel.value = itemsSorted;
}
watch(shoppingList, () => {
@@ -588,6 +656,24 @@ export default defineComponent({
updateListItems();
}
+ function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) {
+ if (!itemsByLabel.value[labelName]) {
+ return;
+ }
+
+ // update this label's item order
+ itemsByLabel.value[labelName] = labeledUncheckedItems;
+
+ // reset list order of all items
+ const allUncheckedItems: ShoppingListItemOut[] = [];
+ for (labelName in itemsByLabel.value) {
+ allUncheckedItems.push(...itemsByLabel.value[labelName]);
+ }
+
+ // save changes
+ return updateIndexUnchecked(allUncheckedItems);
+ }
+
async function deleteListItems(items: ShoppingListItemOut[]) {
if (!shoppingList.value) {
return;
@@ -626,7 +712,6 @@ export default defineComponent({
addRecipeReferenceToList,
updateListItems,
allLabels,
- byLabel,
contextMenu,
contextMenuAction,
copyListItems,
@@ -640,8 +725,12 @@ export default defineComponent({
listItems,
listRecipes,
loadingCounter,
+ preferences,
presentLabels,
removeRecipeReferenceToList,
+ reorderLabelsDialog,
+ toggleReorderLabelsDialog,
+ updateLabelOrder,
saveListItem,
shoppingList,
showChecked,
@@ -649,6 +738,7 @@ export default defineComponent({
toggleShowChecked,
uncheckAll,
updateIndexUnchecked,
+ updateIndexUncheckedByLabel,
allUnits,
allFoods,
};
diff --git a/mealie/db/models/group/shopping_list.py b/mealie/db/models/group/shopping_list.py
index a81be8f2ec17..01c0086bdbd7 100644
--- a/mealie/db/models/group/shopping_list.py
+++ b/mealie/db/models/group/shopping_list.py
@@ -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:
diff --git a/mealie/db/models/labels.py b/mealie/db/models/labels.py
index eb3181c8dea1..7162943b19bf 100644
--- a/mealie/db/models/labels.py
+++ b/mealie/db/models/labels.py
@@ -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:
diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py
index feb3bec14d89..f0a07d9ca19d 100644
--- a/mealie/repos/repository_factory.py
+++ b/mealie/repos/repository_factory.py
@@ -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)
diff --git a/mealie/repos/seed/seeders.py b/mealie/repos/seed/seeders.py
index 9065ea640389..49668bacca02 100644
--- a/mealie/repos/seed/seeders.py
+++ b/mealie/repos/seed/seeders.py
@@ -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)
diff --git a/mealie/routes/groups/controller_labels.py b/mealie/routes/groups/controller_labels.py
index 6b6220516a42..2b0db90da306 100644
--- a/mealie/routes/groups/controller_labels.py
+++ b/mealie/routes/groups/controller_labels.py
@@ -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):
diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py
index 7c2d86536d74..bfe66680a40f 100644
--- a/mealie/routes/groups/controller_shopping_lists.py
+++ b/mealie/routes/groups/controller_shopping_lists.py
@@ -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
diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py
index 7439a38423f1..87b5dd3a7ddb 100644
--- a/mealie/schema/group/__init__.py
+++ b/mealie/schema/group/__init__.py
@@ -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",
diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py
index bad36edce1a2..39487722cd64 100644
--- a/mealie/schema/group/group_shopping_list.py
+++ b/mealie/schema/group/group_shopping_list.py
@@ -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()
diff --git a/mealie/services/group_services/labels_service.py b/mealie/services/group_services/labels_service.py
new file mode 100644
index 000000000000..44e1f9b93e47
--- /dev/null
+++ b/mealie/services/group_services/labels_service.py
@@ -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
diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py
index 95542269d3d4..99e088d2b97a 100644
--- a/mealie/services/group_services/shopping_lists.py
+++ b/mealie/services/group_services/shopping_lists.py
@@ -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)
diff --git a/tests/integration_tests/user_group_tests/test_shopping_list_labels.py b/tests/integration_tests/user_group_tests/test_shopping_list_labels.py
new file mode 100644
index 000000000000..12bbdec1e5af
--- /dev/null
+++ b/tests/integration_tests/user_group_tests/test_shopping_list_labels.py
@@ -0,0 +1,191 @@
+import random
+
+from fastapi.testclient import TestClient
+
+from mealie.repos.repository_factory import AllRepositories
+from mealie.schema.group.group_shopping_list import ShoppingListOut
+from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelOut
+from mealie.services.seeder.seeder_service import SeederService
+from tests.utils import api_routes, jsonify
+from tests.utils.factories import random_int, random_string
+from tests.utils.fixture_schemas import TestUser
+
+
+def create_labels(api_client: TestClient, unique_user: TestUser, count: int = 10) -> list[MultiPurposeLabelOut]:
+ labels: list[MultiPurposeLabelOut] = []
+ for _ in range(count):
+ response = api_client.post(api_routes.groups_labels, json={"name": random_string()}, headers=unique_user.token)
+ labels.append(MultiPurposeLabelOut.parse_obj(response.json()))
+
+ return labels
+
+
+def test_new_list_creates_list_labels(api_client: TestClient, unique_user: TestUser):
+ labels = create_labels(api_client, unique_user)
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+
+ assert len(new_list.label_settings) == len(labels)
+ label_settings_label_ids = [setting.label_id for setting in new_list.label_settings]
+ for label in labels:
+ assert label.id in label_settings_label_ids
+
+
+def test_new_label_creates_list_labels(api_client: TestClient, unique_user: TestUser):
+ # create a list with some labels
+ create_labels(api_client, unique_user)
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+ existing_label_settings = new_list.label_settings
+
+ # create more labels and make sure they were added to the list's label settings
+ new_labels = create_labels(api_client, unique_user)
+ response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token)
+ updated_list = ShoppingListOut.parse_obj(response.json())
+ updated_label_settings = updated_list.label_settings
+ assert len(updated_label_settings) == len(existing_label_settings) + len(new_labels)
+
+ label_settings_ids = [setting.id for setting in updated_list.label_settings]
+ for label_setting in existing_label_settings:
+ assert label_setting.id in label_settings_ids
+
+ label_settings_label_ids = [setting.label_id for setting in updated_list.label_settings]
+ for label in new_labels:
+ assert label.id in label_settings_label_ids
+
+
+def test_seed_label_creates_list_labels(database: AllRepositories, api_client: TestClient, unique_user: TestUser):
+ CREATED_LABELS = 21
+
+ # create a list with some labels
+ create_labels(api_client, unique_user)
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+ existing_label_settings = new_list.label_settings
+
+ # seed labels and make sure they were added to the list's label settings
+ group = database.groups.get_one(unique_user.group_id)
+ seeder = SeederService(database, None, group) # type: ignore
+ seeder.seed_labels("en-US")
+
+ response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token)
+ updated_list = ShoppingListOut.parse_obj(response.json())
+ updated_label_settings = updated_list.label_settings
+ assert len(updated_label_settings) == len(existing_label_settings) + CREATED_LABELS
+
+ label_settings_ids = [setting.id for setting in updated_list.label_settings]
+ for label_setting in existing_label_settings:
+ assert label_setting.id in label_settings_ids
+
+
+def test_delete_label_deletes_list_labels(api_client: TestClient, unique_user: TestUser):
+ new_labels = create_labels(api_client, unique_user)
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+
+ existing_label_settings = new_list.label_settings
+ label_to_delete = random.choice(new_labels)
+ api_client.delete(api_routes.groups_labels_item_id(label_to_delete.id), headers=unique_user.token)
+
+ response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token)
+ updated_list = ShoppingListOut.parse_obj(response.json())
+ assert len(updated_list.label_settings) == len(existing_label_settings) - 1
+
+ label_settings_label_ids = [setting.label_id for setting in updated_list.label_settings]
+ for label in new_labels:
+ if label.id == label_to_delete.id:
+ assert label.id not in label_settings_label_ids
+
+ else:
+ assert label.id in label_settings_label_ids
+
+
+def test_update_list_doesnt_change_list_labels(api_client: TestClient, unique_user: TestUser):
+ create_labels(api_client, unique_user)
+ original_name = random_string()
+ updated_name = random_string()
+
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": original_name}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+ assert new_list.name == original_name
+ assert new_list.label_settings
+
+ updated_list_data = new_list.dict()
+ updated_list_data.pop("created_at", None)
+ updated_list_data.pop("update_at", None)
+
+ updated_list_data["name"] = updated_name
+ updated_list_data["label_settings"][0]["position"] = random_int(999, 9999)
+
+ response = api_client.put(
+ api_routes.groups_shopping_lists_item_id(new_list.id),
+ json=jsonify(updated_list_data),
+ headers=unique_user.token,
+ )
+ updated_list = ShoppingListOut.parse_obj(response.json())
+ assert updated_list.name == updated_name
+ assert updated_list.label_settings == new_list.label_settings
+
+
+def test_update_list_labels(api_client: TestClient, unique_user: TestUser):
+ create_labels(api_client, unique_user)
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+ changed_setting = random.choice(new_list.label_settings)
+ changed_setting.position = random_int(999, 9999)
+
+ response = api_client.put(
+ api_routes.groups_shopping_lists_item_id_label_settings(new_list.id),
+ json=jsonify(new_list.label_settings),
+ headers=unique_user.token,
+ )
+ updated_list = ShoppingListOut.parse_obj(response.json())
+
+ original_settings_by_id = {setting.id: setting for setting in new_list.label_settings}
+ for setting in updated_list.label_settings:
+ assert setting.id in original_settings_by_id
+ assert original_settings_by_id[setting.id].shopping_list_id == setting.shopping_list_id
+ assert original_settings_by_id[setting.id].label_id == setting.label_id
+
+ if setting.id == changed_setting.id:
+ assert setting.position == changed_setting.position
+
+ else:
+ assert original_settings_by_id[setting.id].position == setting.position
+
+
+def test_list_label_order(api_client: TestClient, unique_user: TestUser):
+ response = api_client.post(
+ api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token
+ )
+ new_list = ShoppingListOut.parse_obj(response.json())
+ for i, setting in enumerate(new_list.label_settings):
+ if not i:
+ continue
+
+ assert setting.position > new_list.label_settings[i - 1].position
+
+ random.shuffle(new_list.label_settings)
+ response = api_client.put(
+ api_routes.groups_shopping_lists_item_id_label_settings(new_list.id),
+ json=jsonify(new_list.label_settings),
+ headers=unique_user.token,
+ )
+ updated_list = ShoppingListOut.parse_obj(response.json())
+ for i, setting in enumerate(updated_list.label_settings):
+ if not i:
+ continue
+
+ assert setting.position > updated_list.label_settings[i - 1].position
diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
index ef3f7c1000f6..ddee9a46bdb1 100644
--- a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
+++ b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
@@ -11,7 +11,7 @@ from mealie.services.backups_v2.backup_v2 import BackupV2
def dict_sorter(d: dict) -> Any:
possible_keys = {"created_at", "id"}
- return next((d[key] for key in possible_keys if key in d), 1)
+ return next((d[key] for key in possible_keys if key in d and d[key]), 1)
# For Future Use
diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py
index 5957dc1a0375..8d8cebf17347 100644
--- a/tests/utils/api_routes/__init__.py
+++ b/tests/utils/api_routes/__init__.py
@@ -276,6 +276,11 @@ def groups_shopping_lists_item_id(item_id):
return f"{prefix}/groups/shopping/lists/{item_id}"
+def groups_shopping_lists_item_id_label_settings(item_id):
+ """`/api/groups/shopping/lists/{item_id}/label-settings`"""
+ return f"{prefix}/groups/shopping/lists/{item_id}/label-settings"
+
+
def groups_shopping_lists_item_id_recipe_recipe_id(item_id, recipe_id):
"""`/api/groups/shopping/lists/{item_id}/recipe/{recipe_id}`"""
return f"{prefix}/groups/shopping/lists/{item_id}/recipe/{recipe_id}"