mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-31 12:15:42 -04:00
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:
parent
e14851531d
commit
a6c46a7420
@ -0,0 +1,69 @@
|
|||||||
|
"""added shopping list label settings
|
||||||
|
|
||||||
|
Revision ID: b04a08da2108
|
||||||
|
Revises: 5ab195a474eb
|
||||||
|
Create Date: 2023-21-02 22:03:19.837244
|
||||||
|
|
||||||
|
"""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
import mealie.db.migration_types
|
||||||
|
from alembic import op
|
||||||
|
from mealie.db.models.group.shopping_list import ShoppingList
|
||||||
|
from mealie.db.models.labels import MultiPurposeLabel
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b04a08da2108"
|
||||||
|
down_revision = "5ab195a474eb"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table: sa.Table, session: Session):
|
||||||
|
shopping_lists = session.query(ShoppingList).all()
|
||||||
|
labels = session.query(MultiPurposeLabel).all()
|
||||||
|
|
||||||
|
shopping_lists_labels_data: list[dict] = []
|
||||||
|
for shopping_list in shopping_lists:
|
||||||
|
for i, label in enumerate(labels):
|
||||||
|
shopping_lists_labels_data.append(
|
||||||
|
{"id": uuid4(), "shopping_list_id": shopping_list.id, "label_id": label.id, "position": i}
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(shopping_lists_multi_purpose_labels_table, shopping_lists_labels_data)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
shopping_lists_multi_purpose_labels_table = op.create_table(
|
||||||
|
"shopping_lists_multi_purpose_labels",
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("shopping_list_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("label_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["label_id"],
|
||||||
|
["multi_purpose_labels.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["shopping_list_id"],
|
||||||
|
["shopping_lists.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", "shopping_list_id", "label_id"),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
session = Session(bind=op.get_bind())
|
||||||
|
populate_shopping_lists_multi_purpose_labels(shopping_lists_multi_purpose_labels_table, session)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("shopping_lists_multi_purpose_labels")
|
||||||
|
# ### end Alembic commands ###
|
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex justify-space-between align-center mx-2">
|
||||||
|
<div class="handle">
|
||||||
|
<span class="mr-2">
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.tags }}
|
||||||
|
</v-icon>
|
||||||
|
</span>
|
||||||
|
{{ value.label.name }}
|
||||||
|
</div>
|
||||||
|
<div style="min-width: 72px" class="ml-auto text-right">
|
||||||
|
<v-menu offset-x left min-width="125px">
|
||||||
|
<template #activator="{ on, attrs }">
|
||||||
|
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list dense>
|
||||||
|
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
|
||||||
|
<v-list-item-title>{{ action.text }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group";
|
||||||
|
|
||||||
|
interface actions {
|
||||||
|
text: string;
|
||||||
|
event: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, context) {
|
||||||
|
const { i18n } = useContext();
|
||||||
|
const contextMenu: actions[] = [
|
||||||
|
{
|
||||||
|
text: i18n.t("general.transfer") as string,
|
||||||
|
event: "transfer",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function contextHandler(event: string) {
|
||||||
|
context.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextHandler,
|
||||||
|
contextMenu,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
@ -2,6 +2,7 @@
|
|||||||
<div v-if="!edit" class="d-flex justify-space-between align-center">
|
<div v-if="!edit" class="d-flex justify-space-between align-center">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="listItem.checked"
|
v-model="listItem.checked"
|
||||||
|
class="mt-0"
|
||||||
color="null"
|
color="null"
|
||||||
hide-details
|
hide-details
|
||||||
dense
|
dense
|
||||||
@ -14,11 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
<MultiPurposeLabel v-if="label" :label="label" class="ml-auto mt-2" small />
|
<MultiPurposeLabel v-if="label && showLabel" :label="label" class="ml-auto" small />
|
||||||
<div style="min-width: 72px">
|
<div style="min-width: 72px">
|
||||||
<v-menu offset-x left min-width="125px">
|
<v-menu offset-x left min-width="125px">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ on, attrs }">
|
||||||
<v-btn small class="ml-2 mt-2 handle" icon v-bind="attrs" v-on="on">
|
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.arrowUpDown }}
|
{{ $globals.icons.arrowUpDown }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@ -30,7 +31,7 @@
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<v-btn small class="ml-2 mt-2 handle" icon @click="toggleEdit(true)">
|
<v-btn small class="ml-2 handle" icon @click="toggleEdit(true)">
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@ -71,6 +72,10 @@ export default defineComponent({
|
|||||||
type: Object as () => ShoppingListItemOut,
|
type: Object as () => ShoppingListItemOut,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
showLabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
labels: {
|
labels: {
|
||||||
type: Array as () => MultiPurposeLabelOut[],
|
type: Array as () => MultiPurposeLabelOut[],
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -21,6 +21,10 @@ export interface UserRecipePreferences {
|
|||||||
useMobileCards: boolean;
|
useMobileCards: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserShoppingListPreferences {
|
||||||
|
viewByLabel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
|
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
|
||||||
const fromStorage = useLocalStorage(
|
const fromStorage = useLocalStorage(
|
||||||
"recipe-print-preferences",
|
"recipe-print-preferences",
|
||||||
@ -56,3 +60,18 @@ export function useUserSortPreferences(): Ref<UserRecipePreferences> {
|
|||||||
|
|
||||||
return fromStorage;
|
return fromStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> {
|
||||||
|
const fromStorage = useLocalStorage(
|
||||||
|
"shopping-list-preferences",
|
||||||
|
{
|
||||||
|
viewByLabel: false,
|
||||||
|
},
|
||||||
|
{ mergeDefaults: true }
|
||||||
|
// we cast to a Ref because by default it will return an optional type ref
|
||||||
|
// but since we pass defaults we know all properties are set.
|
||||||
|
) as unknown as Ref<UserShoppingListPreferences>;
|
||||||
|
|
||||||
|
return fromStorage;
|
||||||
|
}
|
||||||
|
@ -603,6 +603,7 @@
|
|||||||
"copy-as-markdown": "Copy as Markdown",
|
"copy-as-markdown": "Copy as Markdown",
|
||||||
"delete-checked": "Delete Checked",
|
"delete-checked": "Delete Checked",
|
||||||
"toggle-label-sort": "Toggle Label Sort",
|
"toggle-label-sort": "Toggle Label Sort",
|
||||||
|
"reorder-labels": "Reorder Labels",
|
||||||
"uncheck-all-items": "Uncheck All Items",
|
"uncheck-all-items": "Uncheck All Items",
|
||||||
"check-all-items": "Check All Items",
|
"check-all-items": "Check All Items",
|
||||||
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
|
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
|
||||||
|
@ -399,6 +399,24 @@ export interface ShoppingListItemsCollectionOut {
|
|||||||
updatedItems?: ShoppingListItemOut[];
|
updatedItems?: ShoppingListItemOut[];
|
||||||
deletedItems?: ShoppingListItemOut[];
|
deletedItems?: ShoppingListItemOut[];
|
||||||
}
|
}
|
||||||
|
export interface ShoppingListMultiPurposeLabelCreate {
|
||||||
|
shoppingListId: string;
|
||||||
|
labelId: string;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
export interface ShoppingListMultiPurposeLabelOut {
|
||||||
|
shoppingListId: string;
|
||||||
|
labelId: string;
|
||||||
|
position?: number;
|
||||||
|
id: string;
|
||||||
|
label: MultiPurposeLabelSummary;
|
||||||
|
}
|
||||||
|
export interface ShoppingListMultiPurposeLabelUpdate {
|
||||||
|
shoppingListId: string;
|
||||||
|
labelId: string;
|
||||||
|
position?: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
export interface ShoppingListOut {
|
export interface ShoppingListOut {
|
||||||
name?: string;
|
name?: string;
|
||||||
extras?: {
|
extras?: {
|
||||||
@ -410,6 +428,7 @@ export interface ShoppingListOut {
|
|||||||
id: string;
|
id: string;
|
||||||
listItems?: ShoppingListItemOut[];
|
listItems?: ShoppingListItemOut[];
|
||||||
recipeReferences: ShoppingListRecipeRefOut[];
|
recipeReferences: ShoppingListRecipeRefOut[];
|
||||||
|
labelSettings: ShoppingListMultiPurposeLabelOut[];
|
||||||
}
|
}
|
||||||
export interface ShoppingListRecipeRefOut {
|
export interface ShoppingListRecipeRefOut {
|
||||||
id: string;
|
id: string;
|
||||||
@ -479,6 +498,8 @@ export interface ShoppingListSummary {
|
|||||||
updateAt?: string;
|
updateAt?: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
recipeReferences: ShoppingListRecipeRefOut[];
|
||||||
|
labelSettings: ShoppingListMultiPurposeLabelOut[];
|
||||||
}
|
}
|
||||||
export interface ShoppingListUpdate {
|
export interface ShoppingListUpdate {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
ShoppingListItemCreate,
|
ShoppingListItemCreate,
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
ShoppingListItemUpdateBulk,
|
ShoppingListItemUpdateBulk,
|
||||||
|
ShoppingListMultiPurposeLabelUpdate,
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
ShoppingListUpdate,
|
ShoppingListUpdate,
|
||||||
} from "~/lib/api/types/group";
|
} from "~/lib/api/types/group";
|
||||||
@ -17,6 +18,7 @@ const routes = {
|
|||||||
shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`,
|
shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`,
|
||||||
shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
|
shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
|
||||||
shoppingListIdRemoveRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}/delete`,
|
shoppingListIdRemoveRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}/delete`,
|
||||||
|
shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`,
|
||||||
|
|
||||||
shoppingListItems: `${prefix}/groups/shopping/items`,
|
shoppingListItems: `${prefix}/groups/shopping/items`,
|
||||||
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
|
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
|
||||||
@ -33,6 +35,10 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
|
|||||||
async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {
|
async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {
|
||||||
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), { recipeDecrementQuantity });
|
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), { recipeDecrementQuantity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateLabelSettings(itemId: string, listSettings: ShoppingListMultiPurposeLabelUpdate[]) {
|
||||||
|
return await this.requests.put(routes.shoppingListIdUpdateLabelSettings(itemId), listSettings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ShoppingListItemsApi extends BaseCRUDAPI<
|
export class ShoppingListItemsApi extends BaseCRUDAPI<
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
mdiContentSaveEdit,
|
mdiContentSaveEdit,
|
||||||
mdiSquareEditOutline,
|
mdiSquareEditOutline,
|
||||||
mdiClose,
|
mdiClose,
|
||||||
|
mdiTagArrowUpOutline,
|
||||||
mdiTagMultipleOutline,
|
mdiTagMultipleOutline,
|
||||||
mdiBookOutline,
|
mdiBookOutline,
|
||||||
mdiAccountCog,
|
mdiAccountCog,
|
||||||
@ -268,6 +269,7 @@ export const icons = {
|
|||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
tags: mdiTagMultipleOutline,
|
tags: mdiTagMultipleOutline,
|
||||||
|
tagArrowUp: mdiTagArrowUpOutline,
|
||||||
pages: mdiBookOutline,
|
pages: mdiBookOutline,
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
|
@ -9,12 +9,13 @@
|
|||||||
|
|
||||||
<!-- Viewer -->
|
<!-- Viewer -->
|
||||||
<section v-if="!edit" class="py-2">
|
<section v-if="!edit" class="py-2">
|
||||||
<div v-if="!byLabel">
|
<div v-if="!preferences.viewByLabel">
|
||||||
<draggable :value="listItems.unchecked" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUnchecked">
|
<draggable :value="listItems.unchecked" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUnchecked">
|
||||||
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
|
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id" class="my-2">
|
||||||
<ShoppingListItem
|
<ShoppingListItem
|
||||||
v-model="listItems.unchecked[index]"
|
v-model="listItems.unchecked[index]"
|
||||||
class="my-2 my-sm-0"
|
class="my-2 my-sm-0"
|
||||||
|
:show-label=true
|
||||||
:labels="allLabels || []"
|
:labels="allLabels || []"
|
||||||
:units="allUnits || []"
|
:units="allUnits || []"
|
||||||
:foods="allFoods || []"
|
:foods="allFoods || []"
|
||||||
@ -28,29 +29,43 @@
|
|||||||
|
|
||||||
<!-- View By Label -->
|
<!-- View By Label -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
|
<div v-for="(value, key, idx) in itemsByLabel" :key="key" class="mb-6">
|
||||||
<div @click="toggleShowChecked()">
|
<div @click="toggleShowChecked()">
|
||||||
<span>
|
<span v-if="idx || key !== $tc('shopping-list.no-label')">
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.tags }}
|
{{ $globals.icons.tags }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</span>
|
</span>
|
||||||
{{ key }}
|
{{ key }}
|
||||||
</div>
|
</div>
|
||||||
<v-lazy v-for="(item, index) in value" :key="item.id">
|
<draggable :value="value" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUncheckedByLabel(key, $event)">
|
||||||
<ShoppingListItem
|
<v-lazy v-for="(item, index) in value" :key="item.id" class="ml-2 my-2">
|
||||||
v-model="value[index]"
|
<ShoppingListItem
|
||||||
:labels="allLabels || []"
|
v-model="value[index]"
|
||||||
:units="allUnits || []"
|
:show-label=false
|
||||||
:foods="allFoods || []"
|
:labels="allLabels || []"
|
||||||
@checked="saveListItem"
|
:units="allUnits || []"
|
||||||
@save="saveListItem"
|
:foods="allFoods || []"
|
||||||
@delete="deleteListItem(item)"
|
@checked="saveListItem"
|
||||||
/>
|
@save="saveListItem"
|
||||||
</v-lazy>
|
@delete="deleteListItem(item)"
|
||||||
|
/>
|
||||||
|
</v-lazy>
|
||||||
|
</draggable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reorder Labels -->
|
||||||
|
<BaseDialog v-model="reorderLabelsDialog" :icon="$globals.icons.tagArrowUp" :title="$t('shopping-list.reorder-labels')">
|
||||||
|
<v-card height="fit-content" max-height="70vh" style="overflow-y: auto;">
|
||||||
|
<draggable :value="shoppingList.labelSettings" handle=".handle" class="my-2" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateLabelOrder">
|
||||||
|
<div v-for="(labelSetting, index) in shoppingList.labelSettings" :key="labelSetting.id">
|
||||||
|
<MultiPurposeLabelSection v-model="shoppingList.labelSettings[index]" />
|
||||||
|
</div>
|
||||||
|
</draggable>
|
||||||
|
</v-card>
|
||||||
|
</BaseDialog>
|
||||||
|
|
||||||
<!-- Create Item -->
|
<!-- Create Item -->
|
||||||
<div v-if="createEditorOpen">
|
<div v-if="createEditorOpen">
|
||||||
<ShoppingListItemEditor
|
<ShoppingListItemEditor
|
||||||
@ -65,6 +80,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mt-4 d-flex justify-end">
|
<div v-else class="mt-4 d-flex justify-end">
|
||||||
|
<BaseButton v-if="preferences.viewByLabel" color="info" class="mr-2" @click="reorderLabelsDialog = true">
|
||||||
|
<template #icon> {{ $globals.icons.tags }} </template>
|
||||||
|
{{ $t('shopping-list.reorder-labels') }}
|
||||||
|
</BaseButton>
|
||||||
<BaseButton create @click="createEditorOpen = true" />
|
<BaseButton create @click="createEditorOpen = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -192,11 +211,13 @@ import { useIdle, useToggle } from "@vueuse/core";
|
|||||||
import { useCopyList } from "~/composables/use-copy";
|
import { useCopyList } from "~/composables/use-copy";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useAsyncKey } from "~/composables/use-utils";
|
import { useAsyncKey } from "~/composables/use-utils";
|
||||||
|
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
|
||||||
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.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 RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||||
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
||||||
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
|
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
|
||||||
|
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||||
|
|
||||||
type CopyTypes = "plain" | "markdown";
|
type CopyTypes = "plain" | "markdown";
|
||||||
|
|
||||||
@ -208,18 +229,21 @@ interface PresentLabel {
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
draggable,
|
draggable,
|
||||||
|
MultiPurposeLabelSection,
|
||||||
ShoppingListItem,
|
ShoppingListItem,
|
||||||
RecipeList,
|
RecipeList,
|
||||||
ShoppingListItemEditor,
|
ShoppingListItemEditor,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
|
const preferences = useShoppingListPreferences();
|
||||||
|
|
||||||
const { idle } = useIdle(5 * 60 * 1000) // 5 minutes
|
const { idle } = useIdle(5 * 60 * 1000) // 5 minutes
|
||||||
const loadingCounter = ref(1);
|
const loadingCounter = ref(1);
|
||||||
const recipeReferenceLoading = ref(false);
|
const recipeReferenceLoading = ref(false);
|
||||||
const userApi = useUserApi();
|
const userApi = useUserApi();
|
||||||
|
|
||||||
const edit = ref(false);
|
const edit = ref(false);
|
||||||
const byLabel = ref(false);
|
const reorderLabelsDialog = ref(false);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const id = route.value.params.id;
|
const id = route.value.params.id;
|
||||||
@ -395,7 +419,33 @@ export default defineComponent({
|
|||||||
const { foods: allFoods } = useFoodStore();
|
const { foods: allFoods } = useFoodStore();
|
||||||
|
|
||||||
function sortByLabels() {
|
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(() => {
|
const presentLabels = computed(() => {
|
||||||
@ -442,7 +492,25 @@ export default defineComponent({
|
|||||||
items[noLabelText] = noLabel;
|
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, () => {
|
watch(shoppingList, () => {
|
||||||
@ -588,6 +656,24 @@ export default defineComponent({
|
|||||||
updateListItems();
|
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[]) {
|
async function deleteListItems(items: ShoppingListItemOut[]) {
|
||||||
if (!shoppingList.value) {
|
if (!shoppingList.value) {
|
||||||
return;
|
return;
|
||||||
@ -626,7 +712,6 @@ export default defineComponent({
|
|||||||
addRecipeReferenceToList,
|
addRecipeReferenceToList,
|
||||||
updateListItems,
|
updateListItems,
|
||||||
allLabels,
|
allLabels,
|
||||||
byLabel,
|
|
||||||
contextMenu,
|
contextMenu,
|
||||||
contextMenuAction,
|
contextMenuAction,
|
||||||
copyListItems,
|
copyListItems,
|
||||||
@ -640,8 +725,12 @@ export default defineComponent({
|
|||||||
listItems,
|
listItems,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
loadingCounter,
|
loadingCounter,
|
||||||
|
preferences,
|
||||||
presentLabels,
|
presentLabels,
|
||||||
removeRecipeReferenceToList,
|
removeRecipeReferenceToList,
|
||||||
|
reorderLabelsDialog,
|
||||||
|
toggleReorderLabelsDialog,
|
||||||
|
updateLabelOrder,
|
||||||
saveListItem,
|
saveListItem,
|
||||||
shoppingList,
|
shoppingList,
|
||||||
showChecked,
|
showChecked,
|
||||||
@ -649,6 +738,7 @@ export default defineComponent({
|
|||||||
toggleShowChecked,
|
toggleShowChecked,
|
||||||
uncheckAll,
|
uncheckAll,
|
||||||
updateIndexUnchecked,
|
updateIndexUnchecked,
|
||||||
|
updateIndexUncheckedByLabel,
|
||||||
allUnits,
|
allUnits,
|
||||||
allFoods,
|
allFoods,
|
||||||
};
|
};
|
||||||
|
@ -5,7 +5,11 @@ from sqlalchemy.ext.orderinglist import ordering_list
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
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_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import GUID, auto_init
|
from .._model_utils import GUID, auto_init
|
||||||
@ -99,6 +103,26 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
|
|||||||
pass
|
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):
|
class ShoppingList(SqlAlchemyBase, BaseMixins):
|
||||||
__tablename__ = "shopping_lists"
|
__tablename__ = "shopping_lists"
|
||||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
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(
|
recipe_references: Mapped[ShoppingListRecipeReference] = orm.relationship(
|
||||||
ShoppingListRecipeReference, cascade="all, delete, delete-orphan"
|
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")
|
extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
@ -9,7 +9,8 @@ from ._model_utils import auto_init
|
|||||||
from ._model_utils.guid import GUID
|
from ._model_utils.guid import GUID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from group import Group, ShoppingListItem
|
from group import Group
|
||||||
|
from group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||||
from recipe import IngredientFoodModel
|
from recipe import IngredientFoodModel
|
||||||
|
|
||||||
|
|
||||||
@ -24,6 +25,9 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
|||||||
|
|
||||||
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")
|
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")
|
||||||
foods: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", 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()
|
@auto_init()
|
||||||
def __init__(self, **_) -> None:
|
def __init__(self, **_) -> None:
|
||||||
|
@ -15,6 +15,7 @@ from mealie.db.models.group.shopping_list import (
|
|||||||
ShoppingList,
|
ShoppingList,
|
||||||
ShoppingListItem,
|
ShoppingListItem,
|
||||||
ShoppingListItemRecipeReference,
|
ShoppingListItemRecipeReference,
|
||||||
|
ShoppingListMultiPurposeLabel,
|
||||||
ShoppingListRecipeReference,
|
ShoppingListRecipeReference,
|
||||||
)
|
)
|
||||||
from mealie.db.models.group.webhooks import GroupWebhooksModel
|
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 (
|
from mealie.schema.group.group_shopping_list import (
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
ShoppingListItemRecipeRefOut,
|
ShoppingListItemRecipeRefOut,
|
||||||
|
ShoppingListMultiPurposeLabelOut,
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
ShoppingListRecipeRefOut,
|
ShoppingListRecipeRefOut,
|
||||||
)
|
)
|
||||||
@ -222,6 +224,12 @@ class AllRepositories:
|
|||||||
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
|
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
|
||||||
return RepositoryGeneric(self.session, PK_ID, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
|
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
|
@cached_property
|
||||||
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
||||||
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)
|
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)
|
||||||
|
@ -1,15 +1,24 @@
|
|||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
from mealie.schema.labels import MultiPurposeLabelSave
|
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 ._abstract_seeder import AbstractSeeder
|
||||||
from .resources import foods, labels, units
|
from .resources import foods, labels, units
|
||||||
|
|
||||||
|
|
||||||
class MultiPurposeLabelSeeder(AbstractSeeder):
|
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:
|
def get_file(self, locale: str | None = None) -> pathlib.Path:
|
||||||
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
|
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
|
||||||
return locale_path if locale_path.exists() else labels.en_US
|
return locale_path if locale_path.exists() else labels.en_US
|
||||||
@ -27,7 +36,7 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
|
|||||||
self.logger.info("Seeding MultiPurposeLabel")
|
self.logger.info("Seeding MultiPurposeLabel")
|
||||||
for label in self.load_data(locale):
|
for label in self.load_data(locale):
|
||||||
try:
|
try:
|
||||||
self.repos.group_multi_purpose_labels.create(label)
|
self.service.create_one(label)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(e)
|
self.logger.error(e)
|
||||||
|
|
||||||
|
@ -10,25 +10,28 @@ from mealie.routes._base.routers import MealieCrudRoute
|
|||||||
from mealie.schema.labels import (
|
from mealie.schema.labels import (
|
||||||
MultiPurposeLabelCreate,
|
MultiPurposeLabelCreate,
|
||||||
MultiPurposeLabelOut,
|
MultiPurposeLabelOut,
|
||||||
MultiPurposeLabelSave,
|
|
||||||
MultiPurposeLabelSummary,
|
MultiPurposeLabelSummary,
|
||||||
MultiPurposeLabelUpdate,
|
MultiPurposeLabelUpdate,
|
||||||
)
|
)
|
||||||
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
|
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
|
||||||
from mealie.schema.mapper import cast
|
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
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)
|
router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"], route_class=MealieCrudRoute)
|
||||||
|
|
||||||
|
|
||||||
@controller(router)
|
@controller(router)
|
||||||
class MultiPurposeLabelsController(BaseUserController):
|
class MultiPurposeLabelsController(BaseUserController):
|
||||||
|
@cached_property
|
||||||
|
def service(self):
|
||||||
|
return MultiPurposeLabelService(self.repos, self.group.id)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
if not self.user:
|
if not self.user:
|
||||||
raise Exception("No user is logged in.")
|
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
|
# CRUD Operations
|
||||||
@ -49,8 +52,7 @@ class MultiPurposeLabelsController(BaseUserController):
|
|||||||
|
|
||||||
@router.post("", response_model=MultiPurposeLabelOut)
|
@router.post("", response_model=MultiPurposeLabelOut)
|
||||||
def create_one(self, data: MultiPurposeLabelCreate):
|
def create_one(self, data: MultiPurposeLabelCreate):
|
||||||
save_data = cast(data, MultiPurposeLabelSave, group_id=self.user.group_id)
|
return self.service.create_one(data)
|
||||||
return self.mixins.create_one(save_data)
|
|
||||||
|
|
||||||
@router.get("/{item_id}", response_model=MultiPurposeLabelOut)
|
@router.get("/{item_id}", response_model=MultiPurposeLabelOut)
|
||||||
def get_one(self, item_id: UUID4):
|
def get_one(self, item_id: UUID4):
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.routes._base.base_controllers import BaseCrudController
|
from mealie.routes._base.base_controllers import BaseCrudController
|
||||||
@ -16,6 +16,7 @@ from mealie.schema.group.group_shopping_list import (
|
|||||||
ShoppingListItemsCollectionOut,
|
ShoppingListItemsCollectionOut,
|
||||||
ShoppingListItemUpdate,
|
ShoppingListItemUpdate,
|
||||||
ShoppingListItemUpdateBulk,
|
ShoppingListItemUpdateBulk,
|
||||||
|
ShoppingListMultiPurposeLabelUpdate,
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
ShoppingListPagination,
|
ShoppingListPagination,
|
||||||
ShoppingListRemoveRecipeParams,
|
ShoppingListRemoveRecipeParams,
|
||||||
@ -23,7 +24,6 @@ from mealie.schema.group.group_shopping_list import (
|
|||||||
ShoppingListSummary,
|
ShoppingListSummary,
|
||||||
ShoppingListUpdate,
|
ShoppingListUpdate,
|
||||||
)
|
)
|
||||||
from mealie.schema.mapper import cast
|
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
from mealie.schema.response.responses import SuccessResponse
|
from mealie.schema.response.responses import SuccessResponse
|
||||||
from mealie.services.event_bus_service.event_types import (
|
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):
|
class ShoppingListItemController(BaseCrudController):
|
||||||
@cached_property
|
@cached_property
|
||||||
def service(self):
|
def service(self):
|
||||||
return ShoppingListService(self.repos)
|
return ShoppingListService(self.repos, self.user, self.group)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
@ -154,7 +154,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
|
|||||||
class ShoppingListController(BaseCrudController):
|
class ShoppingListController(BaseCrudController):
|
||||||
@cached_property
|
@cached_property
|
||||||
def service(self):
|
def service(self):
|
||||||
return ShoppingListService(self.repos)
|
return ShoppingListService(self.repos, self.user, self.group)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
@ -179,9 +179,7 @@ class ShoppingListController(BaseCrudController):
|
|||||||
|
|
||||||
@router.post("", response_model=ShoppingListOut, status_code=201)
|
@router.post("", response_model=ShoppingListOut, status_code=201)
|
||||||
def create_one(self, data: ShoppingListCreate):
|
def create_one(self, data: ShoppingListCreate):
|
||||||
save_data = cast(data, ShoppingListSave, group_id=self.user.group_id)
|
shopping_list = self.service.create_one_list(data)
|
||||||
shopping_list = self.mixins.create_one(save_data)
|
|
||||||
|
|
||||||
if shopping_list:
|
if shopping_list:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
event_type=EventTypes.shopping_list_created,
|
event_type=EventTypes.shopping_list_created,
|
||||||
@ -197,14 +195,12 @@ class ShoppingListController(BaseCrudController):
|
|||||||
|
|
||||||
@router.put("/{item_id}", response_model=ShoppingListOut)
|
@router.put("/{item_id}", response_model=ShoppingListOut)
|
||||||
def update_one(self, item_id: UUID4, data: ShoppingListUpdate):
|
def update_one(self, item_id: UUID4, data: ShoppingListUpdate):
|
||||||
shopping_list = self.mixins.update_one(data, item_id) # type: ignore
|
shopping_list = self.mixins.update_one(data, item_id)
|
||||||
|
self.publish_event(
|
||||||
if shopping_list:
|
event_type=EventTypes.shopping_list_updated,
|
||||||
self.publish_event(
|
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=shopping_list.id),
|
||||||
event_type=EventTypes.shopping_list_updated,
|
message=self.t("notifications.generic-updated", name=shopping_list.name),
|
||||||
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
|
return shopping_list
|
||||||
|
|
||||||
@ -244,3 +240,23 @@ class ShoppingListController(BaseCrudController):
|
|||||||
|
|
||||||
publish_list_item_events(self.publish_event, items)
|
publish_list_item_events(self.publish_event, items)
|
||||||
return shopping_list
|
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
|
||||||
|
@ -14,7 +14,11 @@ from .group_events import (
|
|||||||
from .group_exports import GroupDataExport
|
from .group_exports import GroupDataExport
|
||||||
from .group_migration import DataMigrationCreate, SupportedMigrations
|
from .group_migration import DataMigrationCreate, SupportedMigrations
|
||||||
from .group_permissions import SetPermissions
|
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_seeder import SeederConfig
|
||||||
from .group_shopping_list import (
|
from .group_shopping_list import (
|
||||||
ShoppingListAddRecipeParams,
|
ShoppingListAddRecipeParams,
|
||||||
@ -28,6 +32,9 @@ from .group_shopping_list import (
|
|||||||
ShoppingListItemsCollectionOut,
|
ShoppingListItemsCollectionOut,
|
||||||
ShoppingListItemUpdate,
|
ShoppingListItemUpdate,
|
||||||
ShoppingListItemUpdateBulk,
|
ShoppingListItemUpdateBulk,
|
||||||
|
ShoppingListMultiPurposeLabelCreate,
|
||||||
|
ShoppingListMultiPurposeLabelOut,
|
||||||
|
ShoppingListMultiPurposeLabelUpdate,
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
ShoppingListPagination,
|
ShoppingListPagination,
|
||||||
ShoppingListRecipeRefOut,
|
ShoppingListRecipeRefOut,
|
||||||
@ -37,8 +44,20 @@ from .group_shopping_list import (
|
|||||||
ShoppingListUpdate,
|
ShoppingListUpdate,
|
||||||
)
|
)
|
||||||
from .group_statistics import GroupStatistics, GroupStorage
|
from .group_statistics import GroupStatistics, GroupStorage
|
||||||
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
|
from .invite_token import (
|
||||||
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
CreateInviteToken,
|
||||||
|
EmailInitationResponse,
|
||||||
|
EmailInvitation,
|
||||||
|
ReadInviteToken,
|
||||||
|
SaveInviteToken,
|
||||||
|
)
|
||||||
|
from .webhook import (
|
||||||
|
CreateWebhook,
|
||||||
|
ReadWebhook,
|
||||||
|
SaveWebhook,
|
||||||
|
WebhookPagination,
|
||||||
|
WebhookType,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CreateGroupPreferences",
|
"CreateGroupPreferences",
|
||||||
@ -73,6 +92,9 @@ __all__ = [
|
|||||||
"ShoppingListItemUpdate",
|
"ShoppingListItemUpdate",
|
||||||
"ShoppingListItemUpdateBulk",
|
"ShoppingListItemUpdateBulk",
|
||||||
"ShoppingListItemsCollectionOut",
|
"ShoppingListItemsCollectionOut",
|
||||||
|
"ShoppingListMultiPurposeLabelCreate",
|
||||||
|
"ShoppingListMultiPurposeLabelOut",
|
||||||
|
"ShoppingListMultiPurposeLabelUpdate",
|
||||||
"ShoppingListOut",
|
"ShoppingListOut",
|
||||||
"ShoppingListPagination",
|
"ShoppingListPagination",
|
||||||
"ShoppingListRecipeRefOut",
|
"ShoppingListRecipeRefOut",
|
||||||
|
@ -9,6 +9,8 @@ from pydantic.utils import GetterDict
|
|||||||
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
|
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
|
||||||
from mealie.schema._mealie import MealieModel
|
from mealie.schema._mealie import MealieModel
|
||||||
from mealie.schema._mealie.types import NoneFloat
|
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 (
|
from mealie.schema.recipe.recipe_ingredient import (
|
||||||
INGREDIENT_QTY_PRECISION,
|
INGREDIENT_QTY_PRECISION,
|
||||||
MAX_INGREDIENT_DENOMINATOR,
|
MAX_INGREDIENT_DENOMINATOR,
|
||||||
@ -186,6 +188,23 @@ class ShoppingListItemsCollectionOut(MealieModel):
|
|||||||
deleted_items: list[ShoppingListItemOut] = []
|
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):
|
class ShoppingListItemPagination(PaginationBase):
|
||||||
items: list[ShoppingListItemOut]
|
items: list[ShoppingListItemOut]
|
||||||
|
|
||||||
@ -217,6 +236,8 @@ class ShoppingListSave(ShoppingListCreate):
|
|||||||
|
|
||||||
class ShoppingListSummary(ShoppingListSave):
|
class ShoppingListSummary(ShoppingListSave):
|
||||||
id: UUID4
|
id: UUID4
|
||||||
|
recipe_references: list[ShoppingListRecipeRefOut]
|
||||||
|
label_settings: list[ShoppingListMultiPurposeLabelOut]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
@ -233,16 +254,25 @@ class ShoppingListPagination(PaginationBase):
|
|||||||
items: list[ShoppingListSummary]
|
items: list[ShoppingListSummary]
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListUpdate(ShoppingListSummary):
|
class ShoppingListUpdate(ShoppingListSave):
|
||||||
|
id: UUID4
|
||||||
list_items: list[ShoppingListItemOut] = []
|
list_items: list[ShoppingListItemOut] = []
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListOut(ShoppingListUpdate):
|
class ShoppingListOut(ShoppingListUpdate):
|
||||||
recipe_references: list[ShoppingListRecipeRefOut]
|
recipe_references: list[ShoppingListRecipeRefOut]
|
||||||
|
label_settings: list[ShoppingListMultiPurposeLabelOut]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
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):
|
class ShoppingListAddRecipeParams(MealieModel):
|
||||||
recipe_increment_quantity: float = 1
|
recipe_increment_quantity: float = 1
|
||||||
@ -252,10 +282,3 @@ class ShoppingListAddRecipeParams(MealieModel):
|
|||||||
|
|
||||||
class ShoppingListRemoveRecipeParams(MealieModel):
|
class ShoppingListRemoveRecipeParams(MealieModel):
|
||||||
recipe_decrement_quantity: float = 1
|
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()
|
|
||||||
|
45
mealie/services/group_services/labels_service.py
Normal file
45
mealie/services/group_services/labels_service.py
Normal 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
|
@ -6,6 +6,7 @@ from mealie.core.exceptions import UnexpectedNone
|
|||||||
from mealie.repos.repository_factory import AllRepositories
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
|
from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
|
||||||
from mealie.schema.group.group_shopping_list import (
|
from mealie.schema.group.group_shopping_list import (
|
||||||
|
ShoppingListCreate,
|
||||||
ShoppingListItemBase,
|
ShoppingListItemBase,
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
ShoppingListItemRecipeRefCreate,
|
ShoppingListItemRecipeRefCreate,
|
||||||
@ -13,18 +14,23 @@ from mealie.schema.group.group_shopping_list import (
|
|||||||
ShoppingListItemsCollectionOut,
|
ShoppingListItemsCollectionOut,
|
||||||
ShoppingListItemUpdate,
|
ShoppingListItemUpdate,
|
||||||
ShoppingListItemUpdateBulk,
|
ShoppingListItemUpdateBulk,
|
||||||
|
ShoppingListMultiPurposeLabelCreate,
|
||||||
|
ShoppingListSave,
|
||||||
)
|
)
|
||||||
from mealie.schema.recipe.recipe_ingredient import (
|
from mealie.schema.recipe.recipe_ingredient import (
|
||||||
IngredientFood,
|
IngredientFood,
|
||||||
IngredientUnit,
|
IngredientUnit,
|
||||||
RecipeIngredient,
|
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:
|
class ShoppingListService:
|
||||||
def __init__(self, repos: AllRepositories):
|
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
|
||||||
self.repos = repos
|
self.repos = repos
|
||||||
|
self.user = user
|
||||||
|
self.group = group
|
||||||
self.shopping_lists = repos.group_shopping_lists
|
self.shopping_lists = repos.group_shopping_lists
|
||||||
self.list_items = repos.group_shopping_list_item
|
self.list_items = repos.group_shopping_list_item
|
||||||
self.list_item_refs = repos.group_shopping_list_item_references
|
self.list_item_refs = repos.group_shopping_list_item_references
|
||||||
@ -463,3 +469,18 @@ class ShoppingListService:
|
|||||||
break
|
break
|
||||||
|
|
||||||
return self.shopping_lists.get_one(shopping_list.id), items # type: ignore
|
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)
|
||||||
|
@ -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
|
@ -11,7 +11,7 @@ from mealie.services.backups_v2.backup_v2 import BackupV2
|
|||||||
def dict_sorter(d: dict) -> Any:
|
def dict_sorter(d: dict) -> Any:
|
||||||
possible_keys = {"created_at", "id"}
|
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
|
# For Future Use
|
||||||
|
@ -276,6 +276,11 @@ def groups_shopping_lists_item_id(item_id):
|
|||||||
return f"{prefix}/groups/shopping/lists/{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):
|
def groups_shopping_lists_item_id_recipe_recipe_id(item_id, recipe_id):
|
||||||
"""`/api/groups/shopping/lists/{item_id}/recipe/{recipe_id}`"""
|
"""`/api/groups/shopping/lists/{item_id}/recipe/{recipe_id}`"""
|
||||||
return f"{prefix}/groups/shopping/lists/{item_id}/recipe/{recipe_id}"
|
return f"{prefix}/groups/shopping/lists/{item_id}/recipe/{recipe_id}"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user