Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: more shopping list enhancements #2587

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default defineComponent({
}

.note {
line-height: 0.8em;
line-height: 1.25em;
font-size: 0.8em;
opacity: 0.7;
}
Expand Down
8 changes: 7 additions & 1 deletion frontend/components/Domain/Recipe/RecipeList.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<template>
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
<v-sheet v-for="recipe, index in recipes" :key="recipe.id" :class="attrs.class.sheet" :style="tile ? 'width: fit-content;' : 'width: 100%;'">
<v-sheet
v-for="recipe, index in recipes"
:key="recipe.id"
:elevation="2"
:class="attrs.class.sheet"
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
>
<v-list-item :to="'/recipe/' + recipe.slug" :class="attrs.class.listItem">
<v-list-item-avatar :class="attrs.class.avatar">
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
Expand Down
15 changes: 14 additions & 1 deletion frontend/pages/shopping-lists/_id.vue
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export default defineComponent({
// only update the list with the new value if we're not loading, to prevent UI jitter
if (!loadingCounter.value) {
shoppingList.value = newListValue;
sortListItems();
updateItemsByLabel();
}
}
Expand Down Expand Up @@ -473,6 +474,15 @@ export default defineComponent({

const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});

function sortListItems() {
if (!shoppingList.value?.listItems?.length) {
return;
}

// sort by position ascending, then createdAt descending
shoppingList.value.listItems.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1))
}

function updateItemsByLabel() {
const items: { [prop: string]: ShoppingListItemOut[] } = {};
const noLabelText = i18n.tc("shopping-list.no-label");
Expand Down Expand Up @@ -603,6 +613,7 @@ export default defineComponent({
});
}

sortListItems();
updateItemsByLabel();

loadingCounter.value += 1;
Expand Down Expand Up @@ -656,7 +667,9 @@ export default defineComponent({
loadingCounter.value += 1;

// make sure it's inserted into the end of the list, which may have been updated
createListItemData.value.position = shoppingList.value?.listItems?.length || 1;
createListItemData.value.position = shoppingList.value?.listItems?.length
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
: 0;
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
loadingCounter.value -= 1;

Expand Down
1 change: 1 addition & 0 deletions mealie/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async def start_scheduler():
tasks.purge_password_reset_tokens,
tasks.purge_group_data_exports,
tasks.create_mealplan_timeline_events,
tasks.delete_old_checked_list_items,
)

SchedulerRegistry.register_minutely(
Expand Down
4 changes: 2 additions & 2 deletions mealie/routes/groups/controller_shopping_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, self.user, self.group)
return ShoppingListService(self.repos, self.group)

@cached_property
def repo(self):
Expand Down Expand Up @@ -154,7 +154,7 @@ def delete_one(self, item_id: UUID4):
class ShoppingListController(BaseCrudController):
@cached_property
def service(self):
return ShoppingListService(self.repos, self.user, self.group)
return ShoppingListService(self.repos, self.group)

@cached_property
def repo(self):
Expand Down
5 changes: 2 additions & 3 deletions mealie/services/group_services/shopping_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@
)
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit, RecipeIngredient
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.schema.user.user import GroupInDB


class ShoppingListService:
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
def __init__(self, repos: AllRepositories, 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
Expand Down
2 changes: 2 additions & 0 deletions mealie/services/scheduler/tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .create_timeline_events import create_mealplan_timeline_events
from .delete_old_checked_shopping_list_items import delete_old_checked_list_items
from .post_webhooks import post_group_webhooks
from .purge_group_exports import purge_group_data_exports
from .purge_password_reset import purge_password_reset_tokens
Expand All @@ -7,6 +8,7 @@

__all__ = [
"create_mealplan_timeline_events",
"delete_old_checked_list_items",
"post_group_webhooks",
"purge_password_reset_tokens",
"purge_group_data_exports",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from collections.abc import Callable

from pydantic import UUID4

from mealie.db.db_setup import session_context
from mealie.repos.all_repositories import get_repositories
from mealie.routes.groups.controller_shopping_lists import publish_list_item_events
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.event_types import EventDocumentDataBase, EventTypes
from mealie.services.group_services.shopping_lists import ShoppingListService

MAX_CHECKED_ITEMS = 100


def _create_publish_event(event_bus_service: EventBusService, group_id: UUID4):
def publish_event(event_type: EventTypes, document_data: EventDocumentDataBase, message: str = ""):
event_bus_service.dispatch(
integration_id=DEFAULT_INTEGRATION_ID,
group_id=group_id,
event_type=event_type,
document_data=document_data,
message=message,
)

return publish_event


def _trim_list_items(shopping_list_service: ShoppingListService, shopping_list_id: UUID4, event_publisher: Callable):
pagination = PaginationQuery(
page=1,
per_page=-1,
query_filter=f'shopping_list_id="{shopping_list_id}" AND checked=true',
order_by="update_at",
order_direction=OrderDirection.desc,
)
query = shopping_list_service.list_items.page_all(pagination)
if len(query.items) <= MAX_CHECKED_ITEMS:
return

items_to_delete = query.items[MAX_CHECKED_ITEMS:]
items_response = shopping_list_service.bulk_delete_items([item.id for item in items_to_delete])
publish_list_item_events(event_publisher, items_response)


def delete_old_checked_list_items(group_id: UUID4 | None = None):
with session_context() as session:
repos = get_repositories(session)
if group_id is None:
# if not specified, we check all groups
groups = repos.groups.page_all(PaginationQuery(page=1, per_page=-1)).items

else:
group = repos.groups.get_one(group_id)
if not group:
raise Exception(f'Group not found: "{group_id}"')

groups = [group]

for group in groups:
event_bus_service = EventBusService(session=session, group_id=group.id)
shopping_list_service = ShoppingListService(repos, group)
shopping_list_data = repos.group_shopping_lists.by_group(group.id).page_all(
PaginationQuery(page=1, per_page=-1)
)
for shopping_list in shopping_list_data.items:
_trim_list_items(
shopping_list_service, shopping_list.id, _create_publish_event(event_bus_service, group.id)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from datetime import datetime

from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListItemOut, ShoppingListSave
from mealie.services.scheduler.tasks.delete_old_checked_shopping_list_items import (
MAX_CHECKED_ITEMS,
delete_old_checked_list_items,
)
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser


def test_cleanup(database: AllRepositories, unique_user: TestUser):
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
list_item_repo = database.group_shopping_list_item

shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
unchecked_items = list_item_repo.create_many(
[
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20))
]
)

# create them one at a time so the update timestamps are different
checked_items: list[ShoppingListItemOut] = []
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20)):
new_item = list_item_repo.create(
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
)
new_item.checked = True
checked_items.append(list_item_repo.update(new_item.id, new_item))

# make sure we see all items
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
assert shopping_list
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
for item in unchecked_items + checked_items:
assert item in shopping_list.list_items

checked_items.sort(key=lambda x: x.update_at or datetime.now(), reverse=True)
expected_kept_items = unchecked_items + checked_items[:MAX_CHECKED_ITEMS]
expected_deleted_items = checked_items[MAX_CHECKED_ITEMS:]

# make sure we only see the expected items
delete_old_checked_list_items()
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
assert shopping_list
assert len(shopping_list.list_items) == len(expected_kept_items)
for item in expected_kept_items:
assert item in shopping_list.list_items
for item in expected_deleted_items:
assert item not in shopping_list.list_items


def test_no_cleanup(database: AllRepositories, unique_user: TestUser):
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
list_item_repo = database.group_shopping_list_item

shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
unchecked_items = list_item_repo.create_many(
[
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
for _ in range(MAX_CHECKED_ITEMS)
]
)

# create them one at a time so the update timestamps are different
checked_items: list[ShoppingListItemOut] = []
for _ in range(MAX_CHECKED_ITEMS):
new_item = list_item_repo.create(
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
)
new_item.checked = True
checked_items.append(list_item_repo.update(new_item.id, new_item))

# make sure we see all items
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
assert shopping_list
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
for item in unchecked_items + checked_items:
assert item in shopping_list.list_items

# make sure we still see all items
delete_old_checked_list_items()
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
assert shopping_list
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
for item in unchecked_items + checked_items:
assert item in shopping_list.list_items
Loading