feat: Offline Shopping List (#3760)

Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-06-29 04:58:58 -05:00 committed by GitHub
parent 63a180ef2c
commit f4827abc1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 347 additions and 82 deletions

View File

@ -49,7 +49,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -63,7 +63,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@ -7,7 +7,7 @@
:class="attrs.class.sheet"
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
>
<v-list-item :to="'/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem">
<v-list-item :to="disabled ? '' : '/g/' + groupSlug + '/r/' + 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>
</v-list-item-avatar>
@ -56,6 +56,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
}
},
setup(props) {
const { $auth } = useContext();

View File

@ -69,7 +69,7 @@
</v-row>
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
<v-col cols="auto" style="width: 100%;">
<RecipeList :recipes="recipeList" :list-item="listItem" small tile />
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="isOffline" small tile />
</v-col>
</v-row>
<v-row v-if="listItem.checked" no-gutters class="mb-2">
@ -135,7 +135,11 @@ export default defineComponent({
recipes: {
type: Map<string, RecipeSummary>,
default: undefined,
}
},
isOffline: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { i18n } = useContext();

View File

@ -1,11 +1,12 @@
<template>
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
<b>{{ $t("banner-experimental.title") }}</b>
<div>{{ $t("banner-experimental.description") }}</div>
<div v-if="issue != ''" class="py-2">
<BannerWarning
:title="$tc('banner-experimental.title')"
:description="$tc('banner-experimental.description')"
>
<template v-if="issue" #default>
<a :href="issue" target="_blank">{{ $t("banner-experimental.issue-link-text") }}</a>
</div>
</v-alert>
</template>
</BannerWarning>
</template>
<script lang="ts">

View File

@ -0,0 +1,26 @@
<template>
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
<b v-if="title">{{ title }}</b>
<div v-if="description">{{ description }}</div>
<div v-if="$slots.default" class="py-2">
<slot></slot>
</div>
</v-alert>
</template>
<script lang="ts">
export default {
props: {
title: {
type: String,
required: false,
default: "",
},
description: {
type: String,
required: false,
default: "",
},
},
};
</script>

View File

@ -0,0 +1,164 @@
import { computed, ref } from "@nuxtjs/composition-api";
import { useLocalStorage } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import { ShoppingListItemOut } from "~/lib/api/types/group";
const localStorageKey = "shopping-list-queue";
const queueTimeout = 5 * 60 * 1000; // 5 minutes
type ItemQueueType = "create" | "update" | "delete";
interface ShoppingListQueue {
create: ShoppingListItemOut[];
update: ShoppingListItemOut[];
delete: ShoppingListItemOut[];
lastUpdate: number;
}
interface Storage {
[key: string]: ShoppingListQueue;
}
export function useShoppingListItemActions(shoppingListId: string) {
const api = useUserApi();
const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true });
const queue = storage.value[shoppingListId] ||= { create: [], update: [], delete: [], lastUpdate: Date.now()};
const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length);
if (queueEmpty.value) {
queue.lastUpdate = Date.now();
}
const isOffline = ref(false);
function removeFromQueue(queue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean {
const index = queue.findIndex(i => i.id === item.id);
if (index === -1) {
return false;
}
queue.splice(index, 1);
return true;
}
async function getList() {
const response = await api.shopping.lists.getOne(shoppingListId);
handleResponse(response);
return response.data;
}
function createItem(item: ShoppingListItemOut) {
removeFromQueue(queue.create, item);
queue.create.push(item);
}
function updateItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to update it
queue.create.push(item);
return;
}
removeFromQueue(queue.update, item);
queue.update.push(item);
}
function deleteItem(item: ShoppingListItemOut) {
const removedFromCreate = removeFromQueue(queue.create, item);
if (removedFromCreate) {
// this item hasn't been created yet, so we don't need to delete it
return;
}
removeFromQueue(queue.update, item);
removeFromQueue(queue.delete, item);
queue.delete.push(item);
}
function getQueueItems(itemQueueType: ItemQueueType) {
return queue[itemQueueType];
}
function clearQueueItems(itemQueueType: ItemQueueType | "all", itemIds: string[] | null = null) {
if (itemQueueType === "create" || itemQueueType === "all") {
queue.create = itemIds ? queue.create.filter(item => !itemIds.includes(item.id)) : [];
}
if (itemQueueType === "update" || itemQueueType === "all") {
queue.update = itemIds ? queue.update.filter(item => !itemIds.includes(item.id)) : [];
}
if (itemQueueType === "delete" || itemQueueType === "all") {
queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : [];
}
// Set the storage value explicitly so changes are saved in the browser.
storage.value[shoppingListId] = { ...queue };
}
/**
* Handles the response from the backend and sets the isOffline flag if necessary.
*/
function handleResponse(response: any) {
// TODO: is there a better way of checking for network errors?
isOffline.value = response.error?.message?.includes("Network Error") || false;
}
async function processQueueItems(
action: (items: ShoppingListItemOut[]) => Promise<any>,
itemQueueType: ItemQueueType,
) {
const queueItems = getQueueItems(itemQueueType);
if (!queueItems.length) {
return;
}
const itemsToProcess = [...queueItems];
await action(itemsToProcess)
.then((response) => {
handleResponse(response);
if (!isOffline.value) {
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
}
});
}
async function process() {
if(
!queue.create.length &&
!queue.update.length &&
!queue.delete.length
) {
return;
}
const data = await getList();
if (!data) {
return;
}
const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString();
if (data.updateAt && data.updateAt > cutoffDate) {
// If the queue is too far behind the shopping list to reliably do updates, we clear the queue
clearQueueItems("all");
} else {
// We send each bulk request one at a time, since the backend may merge items
await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete");
await processQueueItems((items) => api.shopping.items.updateMany(items), "update");
await processQueueItems((items) => api.shopping.items.createMany(items), "create");
}
// If we're online, the queue is fully processed, so we're up to date
if (!isOffline.value) {
queue.lastUpdate = Date.now();
}
}
return {
isOffline,
getList,
createItem,
updateItem,
deleteItem,
process,
};
}

View File

@ -810,6 +810,8 @@
"items-checked-count": "No items checked|One item checked|{count} items checked",
"no-label": "No Label",
"completed-on": "Completed on {date}",
"you-are-offline": "You are offline",
"you-are-offline-description": "Not all features are available while offline. You can still add, modify, and remove items, but you will not be able to sync your changes to the server until you are back online.",
"are-you-sure-you-want-to-check-all-items": "Are you sure you want to check all items?",
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?"

View File

@ -21,6 +21,7 @@ const routes = {
shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`,
shoppingListItems: `${prefix}/groups/shopping/items`,
shoppingListItemsCreateBulk: `${prefix}/groups/shopping/items/create-bulk`,
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
};
@ -49,6 +50,10 @@ export class ShoppingListItemsApi extends BaseCRUDAPI<
baseRoute = routes.shoppingListItems;
itemRoute = routes.shoppingListItemsId;
async createMany(items: ShoppingListItemCreate[]) {
return await this.requests.post(routes.shoppingListItemsCreateBulk, items);
}
async updateMany(items: ShoppingListItemOut[]) {
return await this.requests.put(routes.shoppingListItems, items);
}

View File

@ -27,6 +27,11 @@
</template>
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerWarning
v-if="isOffline"
:title="$tc('shopping-list.you-are-offline')"
:description="$tc('shopping-list.you-are-offline-description')"
/>
<!-- Viewer -->
<section v-if="!edit" class="py-2">
@ -41,6 +46,7 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -69,6 +75,7 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -125,6 +132,7 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@delete="createEditorOpen = false"
@cancel="createEditorOpen = false"
@save="createListItem"
@ -133,6 +141,7 @@
<div v-else class="mt-4 d-flex justify-end">
<BaseButton
v-if="preferences.viewByLabel" edit class="mr-2"
:disabled="isOffline"
@click="toggleReorderLabelsDialog">
<template #icon> {{ $globals.icons.tags }} </template>
{{ $t('shopping-list.reorder-labels') }}
@ -212,6 +221,7 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -234,10 +244,10 @@
{{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }}
</div>
<v-divider class="my-4"></v-divider>
<RecipeList :recipes="Array.from(recipeMap.values())" show-description>
<RecipeList :recipes="Array.from(recipeMap.values())" show-description :disabled="isOffline">
<template v-for="(recipe, index) in recipeMap.values()" #[`actions-${recipe.id}`]>
<v-list-item-action :key="'item-actions-decrease' + recipe.id">
<v-btn icon @click.prevent="removeRecipeReferenceToList(recipe.id)">
<v-btn icon :disabled="isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon>
</v-btn>
</v-list-item-action>
@ -245,7 +255,7 @@
{{ shoppingList.recipeReferences[index].recipeQuantity }}
</div>
<v-list-item-action :key="'item-actions-increase' + recipe.id">
<v-btn icon @click.prevent="addRecipeReferenceToList(recipe.id)">
<v-btn icon :disabled="isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</v-list-item-action>
@ -256,7 +266,11 @@
<v-lazy>
<div class="d-flex justify-end">
<BaseButton edit @click="toggleSettingsDialog">
<BaseButton
edit
:disabled="isOffline"
@click="toggleSettingsDialog"
>
<template #icon> {{ $globals.icons.cog }} </template>
{{ $t('general.settings') }}
</BaseButton>
@ -264,8 +278,12 @@
</v-lazy>
<v-lazy>
<div class="d-flex justify-end mt-10">
<ButtonLink :to="`/group/data/labels`" :text="$tc('shopping-list.manage-labels')" :icon="$globals.icons.tags" />
<div v-if="!isOffline" class="d-flex justify-end mt-10">
<ButtonLink
:to="`/group/data/labels`"
:text="$tc('shopping-list.manage-labels')"
:icon="$globals.icons.tags"
/>
</div>
</v-lazy>
</v-container>
@ -280,12 +298,14 @@ import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group";
import { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group";
import { UserSummary } from "~/lib/api/types/user";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { uuid4 } from "~/composables/use-utils";
type CopyTypes = "plain" | "markdown";
@ -320,6 +340,7 @@ export default defineComponent({
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const id = route.value.params.id;
const shoppingListItemActions = useShoppingListItemActions(id);
const state = reactive({
checkAllDialog: false,
@ -332,13 +353,25 @@ export default defineComponent({
const shoppingList = ref<ShoppingListOut | null>(null);
async function fetchShoppingList() {
const { data } = await userApi.shopping.lists.getOne(id);
const data = await shoppingListItemActions.getList();
return data;
}
async function refresh() {
loadingCounter.value += 1;
const newListValue = await fetchShoppingList();
try {
await shoppingListItemActions.process();
} catch (error) {
console.error(error);
}
let newListValue = null
try {
newListValue = await fetchShoppingList();
} catch (error) {
console.error(error);
}
loadingCounter.value -= 1;
// only update the list with the new value if we're not loading, to prevent UI jitter
@ -346,18 +379,21 @@ export default defineComponent({
return;
}
shoppingList.value = newListValue;
// if we're not connected to the network, this will be null, so we don't want to clear the list
if (newListValue) {
shoppingList.value = newListValue;
}
updateListItemOrder();
}
function updateListItemOrder() {
if (!preserveItemOrder.value) {
groupAndSortListItemsByFood();
updateItemsByLabel();
} else {
sortListItems();
updateItemsByLabel();
}
updateItemsByLabel();
}
// constantly polls for changes
@ -391,11 +427,13 @@ export default defineComponent({
// start polling
loadingCounter.value -= 1;
const pollFrequency = 5000;
pollForChanges(); // populate initial list
// max poll time = pollFrequency * maxAttempts = 24 hours
// we use a long max poll time since polling stops when the user is idle anyway
const pollFrequency = 5000;
const maxAttempts = 17280;
let attempts = 0;
const maxAttempts = 3;
const pollTimer: ReturnType<typeof setInterval> = setInterval(() => { pollForChanges() }, pollFrequency);
onUnmounted(() => {
@ -655,6 +693,14 @@ export default defineComponent({
items: ShoppingListItemOut[];
}
function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) {
return (
((a.position || 0) > (b.position || 0)) ||
((a.createdAt || "") < (b.createdAt || ""))
? 1 : -1
);
}
function groupAndSortListItemsByFood() {
if (!shoppingList.value?.listItems?.length) {
return;
@ -678,16 +724,14 @@ export default defineComponent({
}
});
// sort group items by position ascending, then createdAt descending
const listItemGroups = Array.from(listItemGroupsMap.values());
listItemGroups.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1));
listItemGroups.sort(sortItems);
// sort group items by position ascending, then createdAt descending, and aggregate them
// sort group items, then aggregate them
const sortedItems: ShoppingListItemOut[] = [];
let nextPosition = 0;
listItemGroups.forEach((listItemGroup) => {
// @ts-ignore none of these fields are undefined
listItemGroup.items.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1));
listItemGroup.items.sort(sortItems);
listItemGroup.items.forEach((item) => {
item.position = nextPosition;
nextPosition += 1;
@ -703,9 +747,7 @@ export default defineComponent({
return;
}
// sort by position ascending, then createdAt descending
// @ts-ignore none of these fields are undefined
shoppingList.value.listItems.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1))
shoppingList.value.listItems.sort(sortItems)
}
function updateItemsByLabel() {
@ -812,7 +854,7 @@ export default defineComponent({
* checked it will also append that item to the end of the list so that the unchecked items
* are at the top of the list.
*/
async function saveListItem(item: ShoppingListItemOut) {
function saveListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
@ -839,38 +881,34 @@ export default defineComponent({
}
updateListItemOrder();
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.updateOne(item.id, item);
loadingCounter.value -= 1;
if (data) {
refresh();
}
shoppingListItemActions.updateItem(item);
refresh();
}
async function deleteListItem(item: ShoppingListItemOut) {
function deleteListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.deleteOne(item.id);
loadingCounter.value -= 1;
shoppingListItemActions.deleteItem(item);
if (data) {
refresh();
// remove the item from the list immediately so the user sees the change
if (shoppingList.value.listItems) {
shoppingList.value.listItems = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
}
refresh();
}
// =====================================
// Create New Item
const createEditorOpen = ref(false);
const createListItemData = ref<ShoppingListItemCreate>(listItemFactory());
const createListItemData = ref<ShoppingListItemOut>(listItemFactory());
function listItemFactory(isFood = false): ShoppingListItemCreate {
function listItemFactory(isFood = false): ShoppingListItemOut {
return {
id: uuid4(),
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems?.length || 1,
@ -883,7 +921,7 @@ export default defineComponent({
};
}
async function createListItem() {
function createListItem() {
if (!shoppingList.value) {
return;
}
@ -899,13 +937,22 @@ export default defineComponent({
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);
createListItemData.value.createdAt = new Date().toISOString();
createListItemData.value.updateAt = createListItemData.value.createdAt;
updateListItemOrder();
shoppingListItemActions.createItem(createListItemData.value);
loadingCounter.value -= 1;
if (data) {
createListItemData.value = listItemFactory(createListItemData.value.isFood || false);
refresh();
}
if (shoppingList.value.listItems) {
// add the item to the list immediately so the user sees the change
shoppingList.value.listItems.push(createListItemData.value);
updateListItemOrder();
}
createListItemData.value = listItemFactory(createListItemData.value.isFood || false);
refresh();
}
function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) {
@ -941,21 +988,24 @@ export default defineComponent({
return updateIndexUnchecked(allUncheckedItems);
}
async function deleteListItems(items: ShoppingListItemOut[]) {
function deleteListItems(items: ShoppingListItemOut[]) {
if (!shoppingList.value) {
return;
}
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.deleteMany(items);
loadingCounter.value -= 1;
if (data) {
refresh();
items.forEach((item) => {
shoppingListItemActions.deleteItem(item);
});
// remove the items from the list immediately so the user sees the change
if (shoppingList.value?.listItems) {
const deletedItems = new Set(items.map(item => item.id));
shoppingList.value.listItems = shoppingList.value.listItems.filter((itm) => !deletedItems.has(itm.id));
}
refresh();
}
async function updateListItems() {
function updateListItems() {
if (!shoppingList.value?.listItems) {
return;
}
@ -966,13 +1016,10 @@ export default defineComponent({
return itm;
});
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.updateMany(shoppingList.value.listItems);
loadingCounter.value -= 1;
if (data) {
refresh();
}
shoppingList.value.listItems.forEach((item) => {
shoppingListItemActions.updateItem(item);
});
refresh();
}
// ===============================================================
@ -1026,6 +1073,7 @@ export default defineComponent({
getLabelColor,
groupSlug,
itemsByLabel,
isOffline: shoppingListItemActions.isOffline,
listItems,
loadingCounter,
preferences,

View File

@ -6,6 +6,7 @@ import AppLoader from "@/components/global/AppLoader.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BannerWarning from "@/components/global/BannerWarning.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
@ -45,6 +46,7 @@ declare module "vue" {
AppToolbar: typeof AppToolbar;
AutoForm: typeof AutoForm;
BannerExperimental: typeof BannerExperimental;
BannerWarning: typeof BannerWarning;
BaseButton: typeof BaseButton;
BaseButtonGroup: typeof BaseButtonGroup;
BaseCardSectionTitle: typeof BaseCardSectionTitle;

View File

@ -15,10 +15,9 @@ from .._model_utils import GUID, auto_init
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
if TYPE_CHECKING:
from group import Group
from users import User
from ..recipe import RecipeModel
from ..users import User
from .group import Group
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
@ -73,7 +72,7 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
)
model_config = ConfigDict(exclude={"id", "label", "food", "unit"})
model_config = ConfigDict(exclude={"label", "food", "unit"})
@api_extras
@auto_init()

View File

@ -136,11 +136,7 @@ class ShoppingListItemController(BaseCrudController):
def delete_many(self, ids: list[UUID4] = Query(None)):
items = self.service.bulk_delete_items(ids)
publish_list_item_events(self.publish_event, items)
message = (
f"Successfully deleted {len(items.deleted_items)} {'item' if len(items.deleted_items) == 1 else 'items'}"
)
return SuccessResponse.respond(message=message)
return SuccessResponse.respond()
@item_router.delete("/{item_id}", response_model=SuccessResponse)
def delete_one(self, item_id: UUID4):

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import UUID4, ConfigDict, field_validator, model_validator
from sqlalchemy.orm import joinedload, selectinload
@ -75,8 +76,21 @@ class ShoppingListItemBase(RecipeIngredientBase):
class ShoppingListItemCreate(ShoppingListItemBase):
id: UUID4 | None = None
"""The unique id of the item to create. If not supplied, one will be generated."""
recipe_references: list[ShoppingListItemRecipeRefCreate] = []
@field_validator("id", mode="before")
def validate_id(cls, v):
v = v or None
if not v or isinstance(v, UUID):
return v
try:
return UUID(v)
except Exception:
return None
class ShoppingListItemUpdate(ShoppingListItemBase):
recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = []

View File

@ -22,7 +22,7 @@ class SuccessResponse(BaseModel):
error: bool = False
@classmethod
def respond(cls, message: str) -> dict:
def respond(cls, message: str = "") -> dict:
"""
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException