From 92cf97e4012ce1bbc44457de9274829cda62fb83 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 16 Jan 2022 15:24:24 -0900 Subject: [PATCH] Feature/shopping lists second try (#927) * generate types * use generated types * ui updates * init button link for common styles * add links * setup label views * add delete confirmation * reset when not saved * link label to foods and auto set when adding to shopping list * generate types * use inheritence to manage exception handling * fix schema generation and add test for open_api generation * add header to api docs * move list consilidation to service * split list and list items controller * shopping list/list item tests - PARTIAL * enable recipe add/remove in shopping lists * generate types * linting * init global utility components * update types and add list item api * fix import cycle and database error * add container and border classes * new recipe list component * fix tests * breakout item editor * refactor item editor * update bulk actions * update input / color contrast * type generation * refactor controller dependencies * include food/unit editor * remove console.logs * fix and update type generation * fix incorrect type for column * fix postgres error * fix delete by variable * auto remove refs * fix typo --- dev/code-generation/gen_frontend_types.py | 29 +- .../group-multiple-purpose-labels.ts | 12 +- .../class-interfaces/group-shopping-lists.ts | 69 +- frontend/assets/main.css | 8 + .../components/Domain/Recipe/RecipeList.vue | 33 + .../Domain/ShoppingList/MultiPurposeLabel.vue | 56 ++ .../Domain/ShoppingList/ShoppingListItem.vue | 144 +++-- .../ShoppingList/ShoppingListItemEditor.vue | 129 ++++ frontend/components/global/ButtonLink.vue | 31 + frontend/components/global/DevDumpJson.vue | 25 + frontend/components/global/InputColor.vue | 66 ++ frontend/components/global/InputLabelType.vue | 87 +++ frontend/components/global/InputQuantity.vue | 64 ++ .../composables/recipes/use-recipe-foods.ts | 14 +- frontend/composables/use-copy.ts | 49 ++ frontend/composables/use-display-text.ts | 39 ++ frontend/package.json | 2 +- frontend/pages/admin/toolbox/foods.vue | 29 + frontend/pages/shopping-lists/_id.vue | 605 ++++++++++-------- frontend/pages/shopping-lists/index.vue | 3 + frontend/pages/shopping-lists/labels.vue | 266 ++++++++ frontend/types/api-types/admin.ts | 11 + frontend/types/api-types/cookbook.ts | 15 +- frontend/types/api-types/events.ts | 36 -- frontend/types/api-types/group.ts | 118 +++- frontend/types/api-types/labels.ts | 47 +- frontend/types/api-types/meal-plan.ts | 10 + frontend/types/api-types/recipe.ts | 17 + frontend/types/api-types/response.ts | 4 + frontend/types/api-types/static.ts | 8 + frontend/types/api-types/user.ts | 10 + frontend/types/components.d.ts | 10 + frontend/yarn.lock | 8 +- mealie/app.py | 26 +- mealie/db/models/group/events.py | 2 +- mealie/db/models/group/shopping_list.py | 44 +- mealie/db/models/labels.py | 1 + mealie/db/models/recipe/recipe.py | 12 + mealie/repos/repository_factory.py | 26 +- mealie/repos/repository_generic.py | 13 + mealie/repos/repository_shopping_list.py | 50 +- mealie/routes/_base/abc_controller.py | 8 + .../routes/admin/admin_management_groups.py | 10 - mealie/routes/admin/admin_management_users.py | 10 - mealie/routes/comments/__init__.py | 8 - mealie/routes/groups/__init__.py | 1 + .../groups/controller_group_notifications.py | 15 +- mealie/routes/groups/controller_labels.py | 16 +- .../groups/controller_shopping_lists.py | 89 ++- mealie/routes/unit_and_foods/foods.py | 28 +- mealie/routes/unit_and_foods/units.py | 10 - mealie/schema/group/group_shopping_list.py | 50 +- mealie/schema/labels/multi_purpose_label.py | 14 +- mealie/schema/recipe/recipe.py | 21 +- mealie/schema/recipe/recipe_bulk_actions.py | 10 +- mealie/schema/recipe/recipe_ingredient.py | 25 +- mealie/schema/static/__init__.py | 1 + .../services/group_services/shopping_lists.py | 163 ++++- tests/fixtures/__init__.py | 1 + tests/fixtures/fixture_recipe.py | 33 + tests/fixtures/fixture_shopping_lists.py | 85 +++ tests/integration_tests/test_openapi.py | 6 + .../test_group_shopping_list_items.py | 199 ++++++ .../test_group_shopping_lists.py | 201 ++++++ tests/utils/__init__.py | 1 + tests/utils/assertion_helpers.py | 8 + 66 files changed, 2556 insertions(+), 685 deletions(-) create mode 100644 frontend/components/Domain/Recipe/RecipeList.vue create mode 100644 frontend/components/Domain/ShoppingList/MultiPurposeLabel.vue create mode 100644 frontend/components/Domain/ShoppingList/ShoppingListItemEditor.vue create mode 100644 frontend/components/global/ButtonLink.vue create mode 100644 frontend/components/global/DevDumpJson.vue create mode 100644 frontend/components/global/InputColor.vue create mode 100644 frontend/components/global/InputLabelType.vue create mode 100644 frontend/components/global/InputQuantity.vue create mode 100644 frontend/composables/use-copy.ts create mode 100644 frontend/composables/use-display-text.ts create mode 100644 frontend/pages/shopping-lists/labels.vue create mode 100644 frontend/types/api-types/static.ts create mode 100644 tests/fixtures/fixture_shopping_lists.py create mode 100644 tests/integration_tests/test_openapi.py create mode 100644 tests/integration_tests/user_group_tests/test_group_shopping_list_items.py create mode 100644 tests/integration_tests/user_group_tests/test_group_shopping_lists.py diff --git a/dev/code-generation/gen_frontend_types.py b/dev/code-generation/gen_frontend_types.py index 1960ce3fb..946db4153 100644 --- a/dev/code-generation/gen_frontend_types.py +++ b/dev/code-generation/gen_frontend_types.py @@ -2,6 +2,7 @@ from pathlib import Path from jinja2 import Template from pydantic2ts import generate_typescript_defs +from rich import print # ============================================================ # Global Compoenents Generator @@ -73,22 +74,44 @@ def generate_typescript_types() -> None: schema_path = PROJECT_DIR / "mealie" / "schema" types_dir = PROJECT_DIR / "frontend" / "types" / "api-types" + ignore_dirs = ["__pycache__", "static"] + + skipped_files: list[Path] = [] + skipped_dirs: list[Path] = [] + failed_modules: list[Path] = [] + for module in schema_path.iterdir(): + if module.is_dir() and module.stem in ignore_dirs: + skipped_dirs.append(module) + continue if not module.is_dir() or not module.joinpath("__init__.py").is_file(): + skipped_files.append(module) continue ts_out_name = module.name.replace("_", "-") + ".ts" - out_path = types_dir.joinpath(ts_out_name) - print(module) # noqa try: path_as_module = path_to_module(module) generate_typescript_defs(path_as_module, str(out_path), exclude=("CamelModel")) except Exception as e: - print(f"Failed to generate {module}") # noqa + failed_modules.append(module) + print("\nModule Errors:", module, "-----------------") print(e) # noqa + print("Finished Module Errors:", module, "-----------------\n") + + print("\nšŸ“ Skipped Directories:") # noqa + for skipped_dir in skipped_dirs: + print(" šŸ“", skipped_dir.name) # noqa + + print("šŸ“„ Skipped Files:") # noqa + for f in skipped_files: + print(" šŸ“„", f.name) # noqa + + print("āŒ Failed Modules:") # noqa + for f in failed_modules: + print(" āŒ", f.name) # noqa if __name__ == "__main__": diff --git a/frontend/api/class-interfaces/group-multiple-purpose-labels.ts b/frontend/api/class-interfaces/group-multiple-purpose-labels.ts index 8d587fd9e..98623a621 100644 --- a/frontend/api/class-interfaces/group-multiple-purpose-labels.ts +++ b/frontend/api/class-interfaces/group-multiple-purpose-labels.ts @@ -1,4 +1,5 @@ import { BaseCRUDAPI } from "../_base"; +import { MultiPurposeLabelCreate, MultiPurposeLabelOut } from "~/types/api-types/labels"; const prefix = "/api"; @@ -7,16 +8,7 @@ const routes = { labelsId: (id: string | number) => `${prefix}/groups/labels/${id}`, }; -export interface CreateLabel { - name: string; -} - -export interface Label extends CreateLabel { - id: string; - groupId: string; -} - -export class MultiPurposeLabelsApi extends BaseCRUDAPI { +export class MultiPurposeLabelsApi extends BaseCRUDAPI { baseRoute = routes.labels; itemRoute = routes.labelsId; } diff --git a/frontend/api/class-interfaces/group-shopping-lists.ts b/frontend/api/class-interfaces/group-shopping-lists.ts index aa3f1da47..f84df5f90 100644 --- a/frontend/api/class-interfaces/group-shopping-lists.ts +++ b/frontend/api/class-interfaces/group-shopping-lists.ts @@ -1,6 +1,11 @@ import { BaseCRUDAPI } from "../_base"; import { ApiRequestInstance } from "~/types/api"; -import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe"; +import { + ShoppingListCreate, + ShoppingListItemCreate, + ShoppingListItemOut, + ShoppingListOut, +} from "~/types/api-types/group"; const prefix = "/api"; @@ -8,53 +13,49 @@ const routes = { shoppingLists: `${prefix}/groups/shopping/lists`, shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`, shoppingListIdAddRecipe: (id: string, recipeId: number) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`, + + shoppingListItems: `${prefix}/groups/shopping/items`, + shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`, }; -export interface ShoppingListItemCreate { - id: string; - shoppingListId: string; - checked: boolean; - position: number; - note: string; - quantity: number; - - isFood: boolean; - unit: IngredientUnit | null; - food: IngredientFood | null; - - labelId: string | null; - label?: { - id: string; - name: string; - }; -} - -export interface ShoppingListCreate { - name: string; -} - -export interface ShoppingListSummary extends ShoppingListCreate { - id: string; - groupId: string; -} - -export interface ShoppingList extends ShoppingListSummary { - listItems: ShoppingListItemCreate[]; -} - -export class ShoppingListsApi extends BaseCRUDAPI { +export class ShoppingListsApi extends BaseCRUDAPI { baseRoute = routes.shoppingLists; itemRoute = routes.shoppingListsId; async addRecipe(itemId: string, recipeId: number) { return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {}); } + + async removeRecipe(itemId: string, recipeId: number) { + return await this.requests.delete(routes.shoppingListIdAddRecipe(itemId, recipeId)); + } +} + +export class ShoppingListItemsApi extends BaseCRUDAPI { + baseRoute = routes.shoppingListItems; + itemRoute = routes.shoppingListItemsId; + + async updateMany(items: ShoppingListItemOut[]) { + return await this.requests.put(routes.shoppingListItems, items); + } + + async deleteMany(items: ShoppingListItemOut[]) { + let query = "?"; + + items.forEach((item) => { + query += `ids=${item.id}&`; + }); + + return await this.requests.delete(routes.shoppingListItems + query); + } } export class ShoppingApi { public lists: ShoppingListsApi; + public items: ShoppingListItemsApi; constructor(requests: ApiRequestInstance) { this.lists = new ShoppingListsApi(requests); + this.items = new ShoppingListItemsApi(requests); } } diff --git a/frontend/assets/main.css b/frontend/assets/main.css index 5dd067b74..160ed0d6d 100644 --- a/frontend/assets/main.css +++ b/frontend/assets/main.css @@ -11,6 +11,10 @@ max-width: 800px !important; } +.md-container { + max-width: 950px !important; +} + .theme--dark.v-application { background-color: var(--v-background-base, #121212) !important; } @@ -27,6 +31,10 @@ border-left: 5px solid var(--v-primary-base) !important; } +.left-warning-border { + border-left: 5px solid var(--v-warning-base) !important; +} + .handle { cursor: grab; } diff --git a/frontend/components/Domain/Recipe/RecipeList.vue b/frontend/components/Domain/Recipe/RecipeList.vue new file mode 100644 index 000000000..fce9d60b4 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeList.vue @@ -0,0 +1,33 @@ + + + \ No newline at end of file diff --git a/frontend/components/Domain/ShoppingList/MultiPurposeLabel.vue b/frontend/components/Domain/ShoppingList/MultiPurposeLabel.vue new file mode 100644 index 000000000..0a32827b0 --- /dev/null +++ b/frontend/components/Domain/ShoppingList/MultiPurposeLabel.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index f8728f0cd..317bfe79f 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -1,65 +1,64 @@ + + \ No newline at end of file diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItemEditor.vue b/frontend/components/Domain/ShoppingList/ShoppingListItemEditor.vue new file mode 100644 index 000000000..57498a329 --- /dev/null +++ b/frontend/components/Domain/ShoppingList/ShoppingListItemEditor.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/ButtonLink.vue b/frontend/components/global/ButtonLink.vue new file mode 100644 index 000000000..13bd50fe2 --- /dev/null +++ b/frontend/components/global/ButtonLink.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/DevDumpJson.vue b/frontend/components/global/DevDumpJson.vue new file mode 100644 index 000000000..f10e88565 --- /dev/null +++ b/frontend/components/global/DevDumpJson.vue @@ -0,0 +1,25 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/InputColor.vue b/frontend/components/global/InputColor.vue new file mode 100644 index 000000000..878b32a90 --- /dev/null +++ b/frontend/components/global/InputColor.vue @@ -0,0 +1,66 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/InputLabelType.vue b/frontend/components/global/InputLabelType.vue new file mode 100644 index 000000000..af9aa7282 --- /dev/null +++ b/frontend/components/global/InputLabelType.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/InputQuantity.vue b/frontend/components/global/InputQuantity.vue new file mode 100644 index 000000000..9eef5c8e1 --- /dev/null +++ b/frontend/components/global/InputQuantity.vue @@ -0,0 +1,64 @@ + + + \ No newline at end of file diff --git a/frontend/composables/recipes/use-recipe-foods.ts b/frontend/composables/recipes/use-recipe-foods.ts index 7c06917b5..9b5d90366 100644 --- a/frontend/composables/recipes/use-recipe-foods.ts +++ b/frontend/composables/recipes/use-recipe-foods.ts @@ -1,10 +1,10 @@ import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api"; import { useAsyncKey } from "../use-utils"; import { useUserApi } from "~/composables/api"; -import { Food } from "~/api/class-interfaces/recipe-foods"; -import { VForm} from "~/types/vuetify"; +import { VForm } from "~/types/vuetify"; +import { IngredientFood } from "~/types/api-types/recipe"; -let foodStore: Ref | null = null; +let foodStore: Ref | null = null; export const useFoods = function () { const api = useUserApi(); @@ -16,6 +16,7 @@ export const useFoods = function () { id: 0, name: "", description: "", + labelId: "", }); const actions = { @@ -64,6 +65,7 @@ export const useFoods = function () { } loading.value = true; + console.log(workingFoodData); const { data } = await api.foods.updateOne(workingFoodData.id, workingFoodData); if (data && foodStore?.value) { this.refreshAll(); @@ -81,11 +83,13 @@ export const useFoods = function () { workingFoodData.id = 0; workingFoodData.name = ""; workingFoodData.description = ""; + workingFoodData.labelId = ""; }, - setWorking(item: Food) { + setWorking(item: IngredientFood) { workingFoodData.id = item.id; workingFoodData.name = item.name; - workingFoodData.description = item.description; + workingFoodData.description = item.description || ""; + workingFoodData.labelId = item.labelId || ""; }, flushStore() { foodStore = null; diff --git a/frontend/composables/use-copy.ts b/frontend/composables/use-copy.ts new file mode 100644 index 000000000..e4c126c80 --- /dev/null +++ b/frontend/composables/use-copy.ts @@ -0,0 +1,49 @@ +import { useClipboard } from "@vueuse/core"; +import { alert } from "./use-toast"; + +export function useCopyList() { + const { copy, isSupported } = useClipboard(); + + function checkClipboard() { + if (!isSupported) { + alert.error("Your browser does not support clipboard"); + return false; + } + + return true; + } + + function copyPlain(list: string[]) { + if (!checkClipboard()) return; + + const text = list.join("\n"); + copyText(text, list.length); + } + + function copyMarkdown(list: string[]) { + if (!checkClipboard()) return; + + const text = list.map((item) => `- ${item}`).join("\n"); + copyText(text, list.length); + } + + function copyMarkdownCheckList(list: string[]) { + if (!checkClipboard()) return; + + const text = list.map((item) => `- [ ] ${item}`).join("\n"); + copyText(text, list.length); + } + + function copyText(text: string, len: number) { + copy(text).then(() => { + alert.success(`Copied ${len} items to clipboard`); + }); + } + + return { + copyPlain, + copyMarkdown, + copyMarkdownCheckList, + }; +} + diff --git a/frontend/composables/use-display-text.ts b/frontend/composables/use-display-text.ts new file mode 100644 index 000000000..1976aace6 --- /dev/null +++ b/frontend/composables/use-display-text.ts @@ -0,0 +1,39 @@ +/** + * use-display-text module contains helpful utility functions to compute the display text when provided + * with the food, units, quantity, and notes. + */ + +import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe"; + +export function getDisplayText( + notes = "", + quantity: number | null = null, + food: IngredientFood | null = null, + unit: IngredientUnit | null = null +): string { + // Fallback to note only if no food or unit is provided + if (food === null && unit === null) { + return `${quantity || ""} ${notes}`.trim(); + } + + // Otherwise build the display text + let displayText = ""; + + if (quantity) { + displayText += quantity; + } + + if (unit) { + displayText += ` ${unit.name}`; + } + + if (food) { + displayText += ` ${food.name}`; + } + + if (notes) { + displayText += ` ${notes}`; + } + + return displayText.trim(); +} diff --git a/frontend/package.json b/frontend/package.json index 92db66e4e..18f56ad4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,7 @@ "@vueuse/core": "^6.8.0", "core-js": "^3.15.1", "date-fns": "^2.23.0", - "fuse.js": "^6.4.6", + "fuse.js": "^6.5.3", "nuxt": "^2.15.8", "v-jsoneditor": "^1.4.5", "vuedraggable": "^2.24.3", diff --git a/frontend/pages/admin/toolbox/foods.vue b/frontend/pages/admin/toolbox/foods.vue index 2c0da8c7a..80a912835 100644 --- a/frontend/pages/admin/toolbox/foods.vue +++ b/frontend/pages/admin/toolbox/foods.vue @@ -15,6 +15,14 @@ + + @@ -50,6 +58,11 @@ + diff --git a/frontend/pages/shopping-lists/labels.vue b/frontend/pages/shopping-lists/labels.vue new file mode 100644 index 000000000..c3f83ebb9 --- /dev/null +++ b/frontend/pages/shopping-lists/labels.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/types/api-types/admin.ts b/frontend/types/api-types/admin.ts index 2b329c93e..ae226ee08 100644 --- a/frontend/types/api-types/admin.ts +++ b/frontend/types/api-types/admin.ts @@ -49,6 +49,7 @@ export interface CheckAppConfig { emailReady?: boolean; ldapReady?: boolean; baseUrlSet?: boolean; + isUpToDate?: boolean; } export interface ChowdownURL { url: string; @@ -141,11 +142,21 @@ export interface CreateIngredientUnit { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; +} export interface CreateIngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; } export interface RecipeStep { id?: string; diff --git a/frontend/types/api-types/cookbook.ts b/frontend/types/api-types/cookbook.ts index ab1592514..54e35b64a 100644 --- a/frontend/types/api-types/cookbook.ts +++ b/frontend/types/api-types/cookbook.ts @@ -23,8 +23,8 @@ export interface ReadCookBook { slug?: string; position?: number; categories?: CategoryBase[]; - id: number; groupId: string; + id: number; } export interface RecipeCategoryResponse { name: string; @@ -98,11 +98,21 @@ export interface CreateIngredientUnit { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; +} export interface CreateIngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; } export interface RecipeStep { id?: string; @@ -163,8 +173,8 @@ export interface RecipeCookBook { slug?: string; position?: number; categories: RecipeCategoryResponse[]; - id: number; groupId: string; + id: number; } export interface SaveCookBook { name: string; @@ -180,5 +190,6 @@ export interface UpdateCookBook { slug?: string; position?: number; categories?: CategoryBase[]; + groupId: string; id: number; } diff --git a/frontend/types/api-types/events.ts b/frontend/types/api-types/events.ts index d5b45234f..205c3012c 100644 --- a/frontend/types/api-types/events.ts +++ b/frontend/types/api-types/events.ts @@ -6,13 +6,7 @@ */ export type EventCategory = "general" | "recipe" | "backup" | "scheduled" | "migration" | "group" | "user"; -export type DeclaredTypes = "General" | "Discord" | "Gotify" | "Pushover" | "Home Assistant"; -export type GotifyPriority = "low" | "moderate" | "normal" | "high"; -export interface Discord { - webhookId: string; - webhookToken: string; -} export interface Event { id?: number; title: string; @@ -20,40 +14,10 @@ export interface Event { timeStamp?: string; category?: EventCategory & string; } -export interface EventNotificationIn { - id?: number; - name?: string; - type?: DeclaredTypes & string; - general?: boolean; - recipe?: boolean; - backup?: boolean; - scheduled?: boolean; - migration?: boolean; - group?: boolean; - user?: boolean; - notificationUrl?: string; -} -export interface EventNotificationOut { - id?: number; - name?: string; - type?: DeclaredTypes & string; - general?: boolean; - recipe?: boolean; - backup?: boolean; - scheduled?: boolean; - migration?: boolean; - group?: boolean; - user?: boolean; -} export interface EventsOut { total: number; events: Event[]; } -export interface Gotify { - hostname: string; - token: string; - priority?: GotifyPriority & string; -} export interface TestEvent { id?: number; testUrl?: string; diff --git a/frontend/types/api-types/group.ts b/frontend/types/api-types/group.ts index 963f8e335..7be8348ea 100644 --- a/frontend/types/api-types/group.ts +++ b/frontend/types/api-types/group.ts @@ -179,8 +179,16 @@ export interface GroupEventNotifierUpdate { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; +} export interface IngredientUnit { name: string; description?: string; @@ -188,11 +196,6 @@ export interface IngredientUnit { abbreviation?: string; id: number; } -export interface MultiPurposeLabelSummary { - name: string; - groupId: string; - id: string; -} export interface ReadGroupPreferences { privateGroup?: boolean; firstDayOfWeek?: number; @@ -218,6 +221,59 @@ export interface ReadWebhook { groupId: string; id: number; } +export interface RecipeSummary { + id?: number; + userId?: string; + groupId?: string; + name?: string; + slug?: string; + image?: unknown; + recipeYield?: string; + totalTime?: string; + prepTime?: string; + cookTime?: string; + performTime?: string; + description?: string; + recipeCategory?: RecipeTag[]; + tags?: RecipeTag[]; + tools?: RecipeTool[]; + rating?: number; + orgURL?: string; + recipeIngredient?: RecipeIngredient[]; + dateAdded?: string; + dateUpdated?: string; +} +export interface RecipeTag { + name: string; + slug: string; +} +export interface RecipeTool { + name: string; + slug: string; + id?: number; + onHand?: boolean; +} +export interface RecipeIngredient { + title?: string; + note?: string; + unit?: IngredientUnit | CreateIngredientUnit; + food?: IngredientFood | CreateIngredientFood; + disableAmount?: boolean; + quantity?: number; + referenceId?: string; +} +export interface CreateIngredientUnit { + name: string; + description?: string; + fraction?: boolean; + abbreviation?: string; +} +export interface CreateIngredientFood { + name: string; + description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; +} export interface SaveInviteToken { usesLeft: number; groupId: string; @@ -236,9 +292,6 @@ export interface SetPermissions { canInvite?: boolean; canOrganize?: boolean; } -/** - * Create Shopping List - */ export interface ShoppingListCreate { name?: string; } @@ -253,8 +306,12 @@ export interface ShoppingListItemCreate { unit?: IngredientUnit; foodId?: number; food?: IngredientFood; - recipeId?: number; labelId?: string; + recipeReferences?: ShoppingListItemRecipeRef[]; +} +export interface ShoppingListItemRecipeRef { + recipeId: number; + recipeQuantity: number; } export interface ShoppingListItemOut { shoppingListId: string; @@ -267,38 +324,55 @@ export interface ShoppingListItemOut { unit?: IngredientUnit; foodId?: number; food?: IngredientFood; - recipeId?: number; labelId?: string; + recipeReferences?: ShoppingListItemRecipeRefOut[]; id: string; label?: MultiPurposeLabelSummary; } -/** - * Create Shopping List - */ +export interface ShoppingListItemRecipeRefOut { + recipeId: number; + recipeQuantity: number; + id: string; + shoppingListItemId: string; +} +export interface ShoppingListItemUpdate { + shoppingListId: string; + checked?: boolean; + position?: number; + isFood?: boolean; + note?: string; + quantity?: number; + unitId?: number; + unit?: IngredientUnit; + foodId?: number; + food?: IngredientFood; + labelId?: string; + recipeReferences?: ShoppingListItemRecipeRef[]; + id: string; +} export interface ShoppingListOut { name?: string; groupId: string; id: string; listItems?: ShoppingListItemOut[]; + recipeReferences: ShoppingListRecipeRefOut[]; +} +export interface ShoppingListRecipeRefOut { + id: string; + shoppingListId: string; + recipeId: number; + recipeQuantity: number; + recipe: RecipeSummary; } -/** - * Create Shopping List - */ export interface ShoppingListSave { name?: string; groupId: string; } -/** - * Create Shopping List - */ export interface ShoppingListSummary { name?: string; groupId: string; id: string; } -/** - * Create Shopping List - */ export interface ShoppingListUpdate { name?: string; groupId: string; diff --git a/frontend/types/api-types/labels.ts b/frontend/types/api-types/labels.ts index 58951d2e3..a8fc4c046 100644 --- a/frontend/types/api-types/labels.ts +++ b/frontend/types/api-types/labels.ts @@ -5,55 +5,30 @@ /* Do not modify it by hand - just update the pydantic models and then re-run the script */ -export interface IngredientFood { - name: string; - description?: string; - id: number; -} export interface MultiPurposeLabelCreate { name: string; + color?: string; } export interface MultiPurposeLabelOut { name: string; - groupId: string; - id: string; - shoppingListItems?: ShoppingListItemOut[]; - foods?: IngredientFood[]; -} -export interface ShoppingListItemOut { - shoppingListId: string; - checked?: boolean; - position?: number; - isFood?: boolean; - note?: string; - quantity?: number; - unitId?: number; - unit?: IngredientUnit; - foodId?: number; - food?: IngredientFood; - recipeId?: number; - labelId?: string; - id: string; - label?: MultiPurposeLabelSummary; -} -export interface IngredientUnit { - name: string; - description?: string; - fraction?: boolean; - abbreviation?: string; - id: number; -} -export interface MultiPurposeLabelSummary { - name: string; + color?: string; groupId: string; id: string; } export interface MultiPurposeLabelSave { name: string; + color?: string; groupId: string; } -export interface MultiPurposeLabelUpdate { +export interface MultiPurposeLabelSummary { name: string; + color?: string; + groupId: string; + id: string; +} +export interface MultiPurposeLabelUpdate { + name: string; + color?: string; groupId: string; id: string; } diff --git a/frontend/types/api-types/meal-plan.ts b/frontend/types/api-types/meal-plan.ts index 9c768f932..4a117e185 100644 --- a/frontend/types/api-types/meal-plan.ts +++ b/frontend/types/api-types/meal-plan.ts @@ -115,11 +115,21 @@ export interface CreateIngredientUnit { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; +} export interface CreateIngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; } export interface SavePlanEntry { date: string; diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index 4290b164b..6d77c26ed 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -41,6 +41,14 @@ export interface CategoryIn { export interface CreateIngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; +} +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; } export interface CreateIngredientUnit { name: string; @@ -73,6 +81,9 @@ export interface CreateRecipeByUrlBulk { export interface DeleteRecipes { recipes: string[]; } +export interface ExportBase { + recipes: string[]; +} export interface ExportRecipes { recipes: string[]; exportType?: ExportTypes & string; @@ -88,6 +99,8 @@ export interface IngredientConfidence { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } /** @@ -304,3 +317,7 @@ export interface SlugResponse {} export interface TagIn { name: string; } +export interface UnitFoodBase { + name: string; + description?: string; +} diff --git a/frontend/types/api-types/response.ts b/frontend/types/api-types/response.ts index b01b46a1a..145ab1de5 100644 --- a/frontend/types/api-types/response.ts +++ b/frontend/types/api-types/response.ts @@ -10,3 +10,7 @@ export interface ErrorResponse { error?: boolean; exception?: string; } +export interface SuccessResponse { + message: string; + error?: boolean; +} diff --git a/frontend/types/api-types/static.ts b/frontend/types/api-types/static.ts new file mode 100644 index 000000000..55ced4b1b --- /dev/null +++ b/frontend/types/api-types/static.ts @@ -0,0 +1,8 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface _Master_ {} diff --git a/frontend/types/api-types/user.ts b/frontend/types/api-types/user.ts index 99954cc4d..4e53eef86 100644 --- a/frontend/types/api-types/user.ts +++ b/frontend/types/api-types/user.ts @@ -165,11 +165,21 @@ export interface CreateIngredientUnit { export interface IngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; id: number; } +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; +} export interface CreateIngredientFood { name: string; description?: string; + labelId?: string; + label?: MultiPurposeLabelSummary; } export interface ResetPassword { token: string; diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index e48e9e3b0..0c2432d30 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -10,13 +10,18 @@ import BannerExperimental from "@/components/global/BannerExperimental.vue"; import BaseDialog from "@/components/global/BaseDialog.vue"; import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue"; + import InputLabelType from "@/components/global/InputLabelType.vue"; import BaseStatCard from "@/components/global/BaseStatCard.vue"; + import DevDumpJson from "@/components/global/DevDumpJson.vue"; + import InputQuantity from "@/components/global/InputQuantity.vue"; import ToggleState from "@/components/global/ToggleState.vue"; import AppButtonCopy from "@/components/global/AppButtonCopy.vue"; + import InputColor from "@/components/global/InputColor.vue"; import BaseDivider from "@/components/global/BaseDivider.vue"; import AutoForm from "@/components/global/AutoForm.vue"; import AppButtonUpload from "@/components/global/AppButtonUpload.vue"; import BasePageTitle from "@/components/global/BasePageTitle.vue"; + import ButtonLink from "@/components/global/ButtonLink.vue"; import TheSnackbar from "@/components/layout/TheSnackbar.vue"; import AppHeader from "@/components/layout/AppHeader.vue"; @@ -38,13 +43,18 @@ declare module "vue" { BannerExperimental: typeof BannerExperimental; BaseDialog: typeof BaseDialog; RecipeJsonEditor: typeof RecipeJsonEditor; + InputLabelType: typeof InputLabelType; BaseStatCard: typeof BaseStatCard; + DevDumpJson: typeof DevDumpJson; + InputQuantity: typeof InputQuantity; ToggleState: typeof ToggleState; AppButtonCopy: typeof AppButtonCopy; + InputColor: typeof InputColor; BaseDivider: typeof BaseDivider; AutoForm: typeof AutoForm; AppButtonUpload: typeof AppButtonUpload; BasePageTitle: typeof BasePageTitle; + ButtonLink: typeof ButtonLink; // Layout Components TheSnackbar: typeof TheSnackbar; AppHeader: typeof AppHeader; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9f69756e2..374ac4ea3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5776,10 +5776,10 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -fuse.js@^6.4.6: - version "6.4.6" - resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.6.tgz#62f216c110e5aa22486aff20be7896d19a059b79" - integrity sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw== +fuse.js@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93" + integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg== gensync@^1.0.0-beta.2: version "1.0.0-beta.2" diff --git a/mealie/app.py b/mealie/app.py index a9a317dfc..9ca15e22e 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -15,9 +15,33 @@ from mealie.services.scheduler import SchedulerRegistry, SchedulerService, tasks logger = get_logger() settings = get_app_settings() +description = f""" +Mealie is a web application for managing your recipes, meal plans, and shopping lists. This is the Restful +API interactive documentation that can be used to explore the API. If you're justing getting started with +the API and want to get started quickly, you can use the [API Usage | Mealie Docs](https://hay-kot.github.io/mealie/documentation/getting-started/api-usage/) +as a reference for how to get started. + + +As of this release {APP_VERSION}, Mealie is still in rapid development and therefore some of these APIs may change from version to version. + + +If you have any questions or comments about mealie, please use the discord server to talk to the developers or other community members. +If you'd like to file an issue, please use the [GitHub Issue Tracker | Mealie](https://github.com/hay-kot/mealie/issues/new/choose) + + +## Helpful Links +- [Home Page](https://mealie.io) +- [Documentation](https://hay-kot.github.io/mealie/) +- [Discord](https://discord.gg/QuStdQGSGK) +- [Demo](https://demo.mealie.io) +- [Beta](https://beta.mealie.io) + + +""" + app = FastAPI( title="Mealie", - description="A place for all your recipes", + description=description, version=APP_VERSION, docs_url=settings.DOCS_URL, redoc_url=settings.REDOC_URL, diff --git a/mealie/db/models/group/events.py b/mealie/db/models/group/events.py index 2eb8f886e..639974cdb 100644 --- a/mealie/db/models/group/events.py +++ b/mealie/db/models/group/events.py @@ -48,7 +48,7 @@ class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins): id = Column(GUID, primary_key=True, default=GUID.generate) name = Column(String, nullable=False) - enabled = Column(String, default=True, nullable=False) + enabled = Column(Boolean, default=True, nullable=False) apprise_url = Column(String, nullable=False) group = orm.relationship("Group", back_populates="group_event_notifiers", single_parent=True) diff --git a/mealie/db/models/group/shopping_list.py b/mealie/db/models/group/shopping_list.py index 1dc459689..b226c065c 100644 --- a/mealie/db/models/group/shopping_list.py +++ b/mealie/db/models/group/shopping_list.py @@ -8,6 +8,20 @@ from .._model_utils import GUID, auto_init from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel +class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): + __tablename__ = "shopping_list_item_recipe_reference" + id = Column(GUID, primary_key=True, default=GUID.generate) + + shopping_list_item_id = Column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True) + recipe_id = Column(Integer, ForeignKey("recipes.id")) + recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs") + recipe_quantity = Column(Float, nullable=False) + + @auto_init() + def __init__(self, **_) -> None: + pass + + class ShoppingListItem(SqlAlchemyBase, BaseMixins): __tablename__ = "shopping_list_items" @@ -16,7 +30,6 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins): shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id")) # Meta - recipe_id = Column(Integer, nullable=True) is_ingredient = Column(Boolean, default=True) position = Column(Integer, nullable=False, default=0) checked = Column(Boolean, default=False) @@ -36,8 +49,30 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins): label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="shopping_list_items") + # Recipe Reference + recipe_references = orm.relationship(ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan") + class Config: - exclude = {"id", "label"} + exclude = {"id", "label", "food", "unit"} + + @auto_init() + def __init__(self, **_) -> None: + pass + + +class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): + __tablename__ = "shopping_list_recipe_reference" + id = Column(GUID, primary_key=True, default=GUID.generate) + + shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) + + recipe_id = Column(Integer, ForeignKey("recipes.id")) + recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs") + + recipe_quantity = Column(Float, nullable=False) + + class Config: + exclude = {"id", "recipe"} @auto_init() def __init__(self, **_) -> None: @@ -59,6 +94,11 @@ class ShoppingList(SqlAlchemyBase, BaseMixins): collection_class=ordering_list("position"), ) + recipe_references = orm.relationship(ShoppingListRecipeReference, cascade="all, delete, delete-orphan") + + class Config: + exclude = {"id", "list_items"} + @auto_init() def __init__(self, **_) -> None: pass diff --git a/mealie/db/models/labels.py b/mealie/db/models/labels.py index eb4928281..a6b791a3a 100644 --- a/mealie/db/models/labels.py +++ b/mealie/db/models/labels.py @@ -10,6 +10,7 @@ class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): __tablename__ = "multi_purpose_labels" id = Column(GUID, default=GUID.generate, primary_key=True) name = Column(String(255), nullable=False) + color = Column(String(10), nullable=False, default="") group_id = Column(GUID, ForeignKey("groups.id")) group = orm.relationship("Group", back_populates="labels") diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 39ff0ce2f..b1b822018 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -106,6 +106,18 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): date_added = sa.Column(sa.Date, default=date.today) date_updated = sa.Column(sa.DateTime) + # Shopping List Refs + shopping_list_refs = orm.relationship( + "ShoppingListRecipeReference", + back_populates="recipe", + cascade="all, delete-orphan", + ) + shopping_list_item_refs = orm.relationship( + "ShoppingListItemRecipeReference", + back_populates="recipe", + cascade="all, delete-orphan", + ) + class Config: get_attr = "slug" exclude = { diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index d4972d45a..479d807f6 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -9,7 +9,12 @@ from mealie.db.models.group.events import GroupEventNotifierModel from mealie.db.models.group.exports import GroupDataExportsModel from mealie.db.models.group.invite_tokens import GroupInviteToken from mealie.db.models.group.preferences import GroupPreferencesModel -from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem +from mealie.db.models.group.shopping_list import ( + ShoppingList, + ShoppingListItem, + ShoppingListItemRecipeReference, + ShoppingListRecipeReference, +) from mealie.db.models.group.webhooks import GroupWebhooksModel from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.category import Category @@ -28,7 +33,12 @@ from mealie.schema.events import Event as EventSchema from mealie.schema.group.group_events import GroupEventNotifierOut from mealie.schema.group.group_exports import GroupDataExport from mealie.schema.group.group_preferences import ReadGroupPreferences -from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut +from mealie.schema.group.group_shopping_list import ( + ShoppingListItemOut, + ShoppingListItemRecipeRefOut, + ShoppingListOut, + ShoppingListRecipeRefOut, +) from mealie.schema.group.invite_token import ReadInviteToken from mealie.schema.group.webhook import ReadWebhook from mealie.schema.labels import MultiPurposeLabelOut @@ -188,6 +198,18 @@ class AllRepositories: def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]: return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut) + @cached_property + def group_shopping_list_item_references( + self, + ) -> RepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]: + return RepositoryGeneric(self.session, pk_id, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut) + + @cached_property + def group_shopping_list_recipe_refs( + self, + ) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]: + return RepositoryGeneric(self.session, pk_id, ShoppingListRecipeReference, ShoppingListRecipeRefOut) + @cached_property def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]: return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut) diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 085b5b2cd..530b54e3c 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -311,3 +311,16 @@ class RepositoryGeneric(Generic[T, D]): eff_schema.from_orm(x) for x in self.session.query(self.sql_model).filter(attribute_name == attr_match).all() # noqa: 711 ] + + def create_many(self, documents: list[T]) -> list[T]: + new_documents = [] + for document in documents: + document = document if isinstance(document, dict) else document.dict() + new_document = self.sql_model(session=self.session, **document) + new_documents.append(new_document) + + self.session.add_all(new_documents) + self.session.commit() + self.session.refresh(new_documents) + + return [self.schema.from_orm(x) for x in new_documents] diff --git a/mealie/repos/repository_shopping_list.py b/mealie/repos/repository_shopping_list.py index d181e8e65..69f39bc95 100644 --- a/mealie/repos/repository_shopping_list.py +++ b/mealie/repos/repository_shopping_list.py @@ -1,59 +1,11 @@ from pydantic import UUID4 -from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem +from mealie.db.models.group.shopping_list import ShoppingList from mealie.schema.group.group_shopping_list import ShoppingListOut, ShoppingListUpdate from .repository_generic import RepositoryGeneric class RepositoryShoppingList(RepositoryGeneric[ShoppingListOut, ShoppingList]): - def _consolidate(self, item_list: list[ShoppingListItem]) -> ShoppingListItem: - """ - consolidate itterates through the shopping list provided and returns - a consolidated list where all items that are matched against multiple values are - de-duplicated and only the first item is kept where the quantity is updated accoridngly. - """ - - def can_merge(item1: ShoppingListItem, item2: ShoppingListItem) -> bool: - """ - can_merge checks if the two items can be merged together. - """ - can_merge_return = False - - # If the items have the same food and unit they can be merged. - if item1.unit == item2.unit and item1.food == item2.food: - can_merge_return = True - - # If no food or units are present check against the notes field. - if not all([item1.food, item1.unit, item2.food, item2.unit]): - can_merge_return = item1.note == item2.note - - # Otherwise Assume They Can't Be Merged - - return can_merge_return - - consolidated_list: list[ShoppingListItem] = [] - checked_items: list[int] = [] - - for base_index, base_item in enumerate(item_list): - if base_index in checked_items: - continue - - checked_items.append(base_index) - for inner_index, inner_item in enumerate(item_list): - if inner_index in checked_items: - continue - if can_merge(base_item, inner_item): - base_item.quantity += inner_item.quantity - checked_items.append(inner_index) - - consolidated_list.append(base_item) - - return consolidated_list - def update(self, item_id: UUID4, data: ShoppingListUpdate) -> ShoppingListOut: - """ - update updates the shopping list item with the provided data. - """ - data.list_items = self._consolidate(data.list_items) return super().update(item_id, data) diff --git a/mealie/routes/_base/abc_controller.py b/mealie/routes/_base/abc_controller.py index e0900f766..b8ad87f8f 100644 --- a/mealie/routes/_base/abc_controller.py +++ b/mealie/routes/_base/abc_controller.py @@ -1,8 +1,10 @@ from abc import ABC from functools import cached_property +from typing import Type from fastapi import Depends +from mealie.core.exceptions import mealie_registered_exceptions from mealie.repos.all_repositories import AllRepositories from mealie.routes._base.checks import OperationChecks from mealie.routes._base.dependencies import SharedDependencies @@ -27,6 +29,12 @@ class BaseUserController(ABC): deps: SharedDependencies = Depends(SharedDependencies.user) + def registered_exceptions(self, ex: Type[Exception]) -> str: + registered = { + **mealie_registered_exceptions(self.deps.t), + } + return registered.get(ex, "An unexpected error occurred.") + @cached_property def repos(self): return AllRepositories(self.deps.session) diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py index 899296801..d719df06a 100644 --- a/mealie/routes/admin/admin_management_groups.py +++ b/mealie/routes/admin/admin_management_groups.py @@ -1,10 +1,8 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends, HTTPException, status from pydantic import UUID4 -from mealie.core.exceptions import mealie_registered_exceptions from mealie.schema.group.group import GroupAdminUpdate from mealie.schema.mapper import mapper from mealie.schema.query import GetAll @@ -29,14 +27,6 @@ class AdminUserManagementRoutes(BaseAdminController): return self.deps.repos.groups - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations diff --git a/mealie/routes/admin/admin_management_users.py b/mealie/routes/admin/admin_management_users.py index 443fac6bd..ac47ba93d 100644 --- a/mealie/routes/admin/admin_management_users.py +++ b/mealie/routes/admin/admin_management_users.py @@ -1,10 +1,8 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 -from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base import BaseAdminController, controller from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins @@ -25,14 +23,6 @@ class AdminUserManagementRoutes(BaseAdminController): return self.deps.repos.users - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations diff --git a/mealie/routes/comments/__init__.py b/mealie/routes/comments/__init__.py index 12374be08..8d7b3d8ff 100644 --- a/mealie/routes/comments/__init__.py +++ b/mealie/routes/comments/__init__.py @@ -26,14 +26,6 @@ class RecipeCommentRoutes(BaseUserController): def repo(self): return self.deps.repos.comments - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index cab9d78a2..a61c7c78a 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -25,5 +25,6 @@ router.include_router(controller_invitations.router) router.include_router(controller_migrations.router) router.include_router(controller_group_reports.router) router.include_router(controller_shopping_lists.router) +router.include_router(controller_shopping_lists.item_router) router.include_router(controller_labels.router) router.include_router(controller_group_notifications.router) diff --git a/mealie/routes/groups/controller_group_notifications.py b/mealie/routes/groups/controller_group_notifications.py index 4c32e78e6..1372a5e26 100644 --- a/mealie/routes/groups/controller_group_notifications.py +++ b/mealie/routes/groups/controller_group_notifications.py @@ -1,12 +1,10 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 -from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller -from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins from mealie.schema.group.group_events import ( GroupEventNotifierCreate, @@ -23,8 +21,7 @@ router = APIRouter(prefix="/groups/events/notifications", tags=["Group: Event No @controller(router) -class GroupEventsNotifierController: - deps: SharedDependencies = Depends(SharedDependencies.user) +class GroupEventsNotifierController(BaseUserController): event_bus: EventBusService = Depends(EventBusService) @cached_property @@ -34,14 +31,6 @@ class GroupEventsNotifierController: return self.deps.repos.group_event_notifier.by_group(self.deps.acting_user.group_id) - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations diff --git a/mealie/routes/groups/controller_labels.py b/mealie/routes/groups/controller_labels.py index 19c1c6976..84a95a7fb 100644 --- a/mealie/routes/groups/controller_labels.py +++ b/mealie/routes/groups/controller_labels.py @@ -1,12 +1,10 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends from pydantic import UUID4 -from mealie.core.exceptions import mealie_registered_exceptions +from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller -from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import CrudMixins from mealie.schema.labels import ( MultiPurposeLabelCreate, @@ -22,9 +20,7 @@ router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"] @controller(router) -class MultiPurposeLabelsController: - deps: SharedDependencies = Depends(SharedDependencies.user) - +class MultiPurposeLabelsController(BaseUserController): @cached_property def repo(self): if not self.deps.acting_user: @@ -32,14 +28,6 @@ class MultiPurposeLabelsController: return self.deps.repos.group_multi_purpose_labels.by_group(self.deps.acting_user.group_id) - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index af60e1ce9..6398be52c 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -1,15 +1,16 @@ from functools import cached_property -from typing import Type -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import UUID4 -from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins from mealie.schema.group.group_shopping_list import ( ShoppingListCreate, + ShoppingListItemCreate, + ShoppingListItemOut, + ShoppingListItemUpdate, ShoppingListOut, ShoppingListSave, ShoppingListSummary, @@ -17,10 +18,75 @@ from mealie.schema.group.group_shopping_list import ( ) from mealie.schema.mapper import cast from mealie.schema.query import GetAll +from mealie.schema.response.responses import SuccessResponse from mealie.services.event_bus_service.event_bus_service import EventBusService from mealie.services.event_bus_service.message_types import EventTypes from mealie.services.group_services.shopping_lists import ShoppingListService +item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"]) + + +@controller(item_router) +class ShoppingListItemController(BaseUserController): + @cached_property + def service(self): + return ShoppingListService(self.repos) + + @cached_property + def repo(self): + return self.deps.repos.group_shopping_list_item + + @cached_property + def mixins(self): + return CrudMixins[ShoppingListItemCreate, ShoppingListItemOut, ShoppingListItemCreate]( + self.repo, + self.deps.logger, + ) + + @item_router.put("", response_model=list[ShoppingListItemOut]) + def update_many(self, data: list[ShoppingListItemUpdate]): + # TODO: Convert to update many with single call + + all_updates = [] + keep_ids = [] + + for item in self.service.consolidate_list_items(data): + updated_data = self.mixins.update_one(item, item.id) + all_updates.append(updated_data) + keep_ids.append(updated_data.id) + + for item in data: + if item.id not in keep_ids: + self.mixins.delete_one(item.id) + + return all_updates + + @item_router.delete("", response_model=SuccessResponse) + def delete_many(self, ids: list[UUID4] = Query(None)): + x = 0 + for item_id in ids: + self.mixins.delete_one(item_id) + x += 1 + + return SuccessResponse.respond(message=f"Successfully deleted {x} items") + + @item_router.post("", response_model=ShoppingListItemOut, status_code=201) + def create_one(self, data: ShoppingListItemCreate): + return self.mixins.create_one(data) + + @item_router.get("/{item_id}", response_model=ShoppingListItemOut) + def get_one(self, item_id: UUID4): + return self.mixins.get_one(item_id) + + @item_router.put("/{item_id}", response_model=ShoppingListItemOut) + def update_one(self, item_id: UUID4, data: ShoppingListItemUpdate): + return self.mixins.update_one(data, item_id) + + @item_router.delete("/{item_id}", response_model=ShoppingListItemOut) + def delete_one(self, item_id: UUID4): + return self.mixins.delete_one(item_id) # type: ignore + + router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) @@ -34,23 +100,12 @@ class ShoppingListController(BaseUserController): @cached_property def repo(self): - if not self.deps.acting_user: - raise Exception("No user is logged in.") - return self.deps.repos.group_shopping_lists.by_group(self.deps.acting_user.group_id) - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - # ======================================================================= # CRUD Operations - @property + @cached_property def mixins(self) -> CrudMixins: return CrudMixins(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") @@ -58,7 +113,7 @@ class ShoppingListController(BaseUserController): def get_all(self, q: GetAll = Depends(GetAll)): return self.repo.get_all(start=q.start, limit=q.limit, override_schema=ShoppingListSummary) - @router.post("", response_model=ShoppingListOut) + @router.post("", response_model=ShoppingListOut, status_code=201) def create_one(self, data: ShoppingListCreate): save_data = cast(data, ShoppingListSave, group_id=self.deps.acting_user.group_id) val = self.mixins.create_one(save_data) @@ -74,7 +129,7 @@ class ShoppingListController(BaseUserController): @router.get("/{item_id}", response_model=ShoppingListOut) def get_one(self, item_id: UUID4): - return self.repo.get_one(item_id) + return self.mixins.get_one(item_id) @router.put("/{item_id}", response_model=ShoppingListOut) def update_one(self, item_id: UUID4, data: ShoppingListUpdate): diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py index 850c3d0f2..96d6a155b 100644 --- a/mealie/routes/unit_and_foods/foods.py +++ b/mealie/routes/unit_and_foods/foods.py @@ -1,14 +1,12 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends -from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit +from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood router = APIRouter(prefix="/foods", tags=["Recipes: Foods"]) @@ -19,38 +17,30 @@ class IngredientFoodsController(BaseUserController): def repo(self): return self.deps.repos.ingredient_foods - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - @cached_property def mixins(self): - return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit]( + return CrudMixins[CreateIngredientFood, IngredientFood, CreateIngredientFood]( self.repo, self.deps.logger, self.registered_exceptions, ) - @router.get("", response_model=list[IngredientUnit]) + @router.get("", response_model=list[IngredientFood]) def get_all(self, q: GetAll = Depends(GetAll)): return self.repo.get_all(start=q.start, limit=q.limit) - @router.post("", response_model=IngredientUnit, status_code=201) - def create_one(self, data: CreateIngredientUnit): + @router.post("", response_model=IngredientFood, status_code=201) + def create_one(self, data: CreateIngredientFood): return self.mixins.create_one(data) - @router.get("/{item_id}", response_model=IngredientUnit) + @router.get("/{item_id}", response_model=IngredientFood) def get_one(self, item_id: int): return self.mixins.get_one(item_id) - @router.put("/{item_id}", response_model=IngredientUnit) - def update_one(self, item_id: int, data: CreateIngredientUnit): + @router.put("/{item_id}", response_model=IngredientFood) + def update_one(self, item_id: int, data: CreateIngredientFood): return self.mixins.update_one(data, item_id) - @router.delete("/{item_id}", response_model=IngredientUnit) + @router.delete("/{item_id}", response_model=IngredientFood) def delete_one(self, item_id: int): return self.mixins.delete_one(item_id) diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py index 36164cce9..05bef2514 100644 --- a/mealie/routes/unit_and_foods/units.py +++ b/mealie/routes/unit_and_foods/units.py @@ -1,9 +1,7 @@ from functools import cached_property -from typing import Type from fastapi import APIRouter, Depends -from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import CrudMixins @@ -19,14 +17,6 @@ class IngredientUnitsController(BaseUserController): def repo(self): return self.deps.repos.ingredient_units - def registered_exceptions(self, ex: Type[Exception]) -> str: - - registered = { - **mealie_registered_exceptions(self.deps.t), - } - - return registered.get(ex, "An unexpected error occurred.") - @cached_property def mixins(self): return CrudMixins[CreateIngredientUnit, IngredientUnit, CreateIngredientUnit]( diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index d62383bf5..1cebf0dbf 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Optional from fastapi_camelcase import CamelModel @@ -6,6 +8,19 @@ from pydantic import UUID4 from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit +class ShoppingListItemRecipeRef(CamelModel): + recipe_id: int + recipe_quantity: float + + +class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRef): + id: UUID4 + shopping_list_item_id: UUID4 + + class Config: + orm_mode = True + + class ShoppingListItemCreate(CamelModel): shopping_list_id: UUID4 checked: bool = False @@ -16,30 +31,41 @@ class ShoppingListItemCreate(CamelModel): note: Optional[str] = "" quantity: float = 1 unit_id: int = None - unit: IngredientUnit = None + unit: Optional[IngredientUnit] food_id: int = None - food: IngredientFood = None - recipe_id: Optional[int] = None + food: Optional[IngredientFood] label_id: Optional[UUID4] = None + recipe_references: list[ShoppingListItemRecipeRef] = [] -class ShoppingListItemOut(ShoppingListItemCreate): +class ShoppingListItemUpdate(ShoppingListItemCreate): id: UUID4 - label: "Optional[MultiPurposeLabelSummary]" = None + + +class ShoppingListItemOut(ShoppingListItemUpdate): + label: Optional[MultiPurposeLabelSummary] + recipe_references: list[ShoppingListItemRecipeRefOut] = [] class Config: orm_mode = True class ShoppingListCreate(CamelModel): - """ - Create Shopping List - """ - name: str = None +class ShoppingListRecipeRefOut(CamelModel): + id: UUID4 + shopping_list_id: UUID4 + recipe_id: int + recipe_quantity: float + recipe: RecipeSummary + + class Config: + orm_mode = True + + class ShoppingListSave(ShoppingListCreate): group_id: UUID4 @@ -56,10 +82,14 @@ class ShoppingListUpdate(ShoppingListSummary): class ShoppingListOut(ShoppingListUpdate): + recipe_references: list[ShoppingListRecipeRefOut] + class Config: orm_mode = True -from mealie.schema.labels import MultiPurposeLabelSummary +from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary +from mealie.schema.recipe.recipe import RecipeSummary +ShoppingListRecipeRefOut.update_forward_refs() ShoppingListItemOut.update_forward_refs() diff --git a/mealie/schema/labels/multi_purpose_label.py b/mealie/schema/labels/multi_purpose_label.py index e305e9e8f..8d3807d70 100644 --- a/mealie/schema/labels/multi_purpose_label.py +++ b/mealie/schema/labels/multi_purpose_label.py @@ -1,11 +1,12 @@ +from __future__ import annotations + from fastapi_camelcase import CamelModel from pydantic import UUID4 -from mealie.schema.recipe import IngredientFood - class MultiPurposeLabelCreate(CamelModel): name: str + color: str = "" class MultiPurposeLabelSave(MultiPurposeLabelCreate): @@ -24,13 +25,14 @@ class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): class MultiPurposeLabelOut(MultiPurposeLabelUpdate): - shopping_list_items: "list[ShoppingListItemOut]" = [] - foods: list[IngredientFood] = [] + # shopping_list_items: list[ShoppingListItemOut] = [] + # foods: list[IngredientFood] = [] class Config: orm_mode = True -from mealie.schema.group.group_shopping_list import ShoppingListItemOut +# from mealie.schema.recipe.recipe_ingredient import IngredientFood +# from mealie.schema.group.group_shopping_list import ShoppingListItemOut -MultiPurposeLabelOut.update_forward_refs() +# MultiPurposeLabelOut.update_forward_refs() diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 516d3a111..49af7acf7 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime from pathlib import Path from typing import Any, Optional @@ -13,7 +15,6 @@ from mealie.db.models.recipe.recipe import RecipeModel from .recipe_asset import RecipeAsset from .recipe_comments import RecipeCommentOut -from .recipe_ingredient import RecipeIngredient from .recipe_notes import RecipeNote from .recipe_nutrition import Nutrition from .recipe_settings import RecipeSettings @@ -91,25 +92,25 @@ class RecipeSummary(CamelModel): class Config: orm_mode = True - @validator("tags", always=True, pre=True) + @validator("tags", always=True, pre=True, allow_reuse=True) def validate_tags(cats: list[Any]): # type: ignore if isinstance(cats, list) and cats and isinstance(cats[0], str): return [RecipeTag(name=c, slug=slugify(c)) for c in cats] return cats - @validator("recipe_category", always=True, pre=True) + @validator("recipe_category", always=True, pre=True, allow_reuse=True) def validate_categories(cats: list[Any]): # type: ignore if isinstance(cats, list) and cats and isinstance(cats[0], str): return [RecipeCategory(name=c, slug=slugify(c)) for c in cats] return cats - @validator("group_id", always=True, pre=True) + @validator("group_id", always=True, pre=True, allow_reuse=True) def validate_group_id(group_id: Any): if isinstance(group_id, int): return uuid4() return group_id - @validator("user_id", always=True, pre=True) + @validator("user_id", always=True, pre=True, allow_reuse=True) def validate_user_id(user_id: Any): if isinstance(user_id, int): return uuid4() @@ -164,14 +165,14 @@ class Recipe(RecipeSummary): "extras": {x.key_name: x.value for x in name_orm.extras}, } - @validator("slug", always=True, pre=True) + @validator("slug", always=True, pre=True, allow_reuse=True) def validate_slug(slug: str, values): if not values.get("name"): return slug return slugify(values["name"]) - @validator("recipe_ingredient", always=True, pre=True) + @validator("recipe_ingredient", always=True, pre=True, allow_reuse=True) def validate_ingredients(recipe_ingredient, values): if not recipe_ingredient or not isinstance(recipe_ingredient, list): return recipe_ingredient @@ -180,3 +181,9 @@ class Recipe(RecipeSummary): return [RecipeIngredient(note=x) for x in recipe_ingredient] return recipe_ingredient + + +from mealie.schema.recipe.recipe_ingredient import RecipeIngredient + +RecipeSummary.update_forward_refs() +Recipe.update_forward_refs() diff --git a/mealie/schema/recipe/recipe_bulk_actions.py b/mealie/schema/recipe/recipe_bulk_actions.py index b29cd8b3e..ba5f50411 100644 --- a/mealie/schema/recipe/recipe_bulk_actions.py +++ b/mealie/schema/recipe/recipe_bulk_actions.py @@ -9,23 +9,23 @@ class ExportTypes(str, enum.Enum): JSON = "json" -class _ExportBase(CamelModel): +class ExportBase(CamelModel): recipes: list[str] -class ExportRecipes(_ExportBase): +class ExportRecipes(ExportBase): export_type: ExportTypes = ExportTypes.JSON -class AssignCategories(_ExportBase): +class AssignCategories(ExportBase): categories: list[CategoryBase] -class AssignTags(_ExportBase): +class AssignTags(ExportBase): tags: list[TagBase] -class DeleteRecipes(_ExportBase): +class DeleteRecipes(ExportBase): pass diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 8fb33b097..d27f449db 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -1,21 +1,21 @@ +from __future__ import annotations + import enum from typing import Optional, Union from uuid import UUID, uuid4 from fastapi_camelcase import CamelModel -from pydantic import Field - -uuid4() +from pydantic import UUID4, Field -class CreateIngredientFood(CamelModel): +class UnitFoodBase(CamelModel): name: str description: str = "" -class CreateIngredientUnit(CreateIngredientFood): - fraction: bool = True - abbreviation: str = "" +class CreateIngredientFood(UnitFoodBase): + label_id: UUID4 = None + label: MultiPurposeLabelSummary = None class IngredientFood(CreateIngredientFood): @@ -25,6 +25,11 @@ class IngredientFood(CreateIngredientFood): orm_mode = True +class CreateIngredientUnit(UnitFoodBase): + fraction: bool = True + abbreviation: str = "" + + class IngredientUnit(CreateIngredientUnit): id: int @@ -77,3 +82,9 @@ class IngredientsRequest(CamelModel): class IngredientRequest(CamelModel): parser: RegisteredParser = RegisteredParser.nlp ingredient: str + + +from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary + +CreateIngredientFood.update_forward_refs() +IngredientFood.update_forward_refs() diff --git a/mealie/schema/static/__init__.py b/mealie/schema/static/__init__.py index d4f3b74d1..8c375705a 100644 --- a/mealie/schema/static/__init__.py +++ b/mealie/schema/static/__init__.py @@ -1 +1,2 @@ +# GENERATED CODE - DO NOT MODIFY BY HAND from .recipe_keys import * diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py index 8afe3b64a..cb28a68eb 100644 --- a/mealie/services/group_services/shopping_lists.py +++ b/mealie/services/group_services/shopping_lists.py @@ -1,19 +1,96 @@ from pydantic import UUID4 from mealie.repos.repository_factory import AllRepositories -from mealie.schema.group import ShoppingListOut -from mealie.schema.group.group_shopping_list import ShoppingListItemCreate +from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut +from mealie.schema.group.group_shopping_list import ( + ShoppingListItemOut, + ShoppingListItemRecipeRef, + ShoppingListItemUpdate, +) class ShoppingListService: def __init__(self, repos: AllRepositories): self.repos = repos - self.repo = repos.group_shopping_lists + self.shopping_lists = repos.group_shopping_lists + self.list_items = repos.group_shopping_list_item + self.list_item_refs = repos.group_shopping_list_item_references + self.list_refs = repos.group_shopping_list_recipe_refs + + @staticmethod + def can_merge(item1: ShoppingListItemOut, item2: ShoppingListItemOut) -> bool: + """ + can_merge checks if the two items can be merged together. + """ + + # If no food or units are present check against the notes field. + if not all([item1.food, item1.unit, item2.food, item2.unit]): + return item1.note == item2.note + + # If the items have the same food and unit they can be merged. + if item1.unit == item2.unit and item1.food == item2.food: + return True + + # Otherwise Assume They Can't Be Merged + return False + + def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]: + """ + itterates through the shopping list provided and returns + a consolidated list where all items that are matched against multiple values are + de-duplicated and only the first item is kept where the quantity is updated accoridngly. + """ + + consolidated_list: list[ShoppingListItemOut] = [] + checked_items: list[int] = [] + + for base_index, base_item in enumerate(item_list): + if base_index in checked_items: + continue + + checked_items.append(base_index) + for inner_index, inner_item in enumerate(item_list): + if inner_index in checked_items: + continue + if ShoppingListService.can_merge(base_item, inner_item): + # Set Quantity + base_item.quantity += inner_item.quantity + + # Set References + new_refs = [] + for ref in inner_item.recipe_references: + ref.shopping_list_item_id = base_item.id + new_refs.append(ref) + + base_item.recipe_references.extend(new_refs) + checked_items.append(inner_index) + + consolidated_list.append(base_item) + + return consolidated_list + + def consolidate_and_save(self, data: list[ShoppingListItemUpdate]): + # TODO: Convert to update many with single call + + all_updates = [] + keep_ids = [] + + for item in self.consolidate_list_items(data): + updated_data = self.list_items.update(item.id, item) + all_updates.append(updated_data) + keep_ids.append(updated_data.id) + + for item in data: + if item.id not in keep_ids: + self.list_items.delete(item.id) + + return all_updates + + # ======================================================================= + # Methods def add_recipe_ingredients_to_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: recipe = self.repos.recipes.get_one(recipe_id, "id") - shopping_list = self.repo.get_one(list_id) - to_create = [] for ingredient in recipe.recipe_ingredient: @@ -23,6 +100,12 @@ class ShoppingListService: except AttributeError: pass + label_id = None + try: + label_id = ingredient.food.label.id + except AttributeError: + pass + unit_id = None try: unit_id = ingredient.unit.id @@ -32,19 +115,77 @@ class ShoppingListService: to_create.append( ShoppingListItemCreate( shopping_list_id=list_id, - is_food=True, + is_food=not recipe.settings.disable_amount, food_id=food_id, unit_id=unit_id, quantity=ingredient.quantity, note=ingredient.note, + label_id=label_id, recipe_id=recipe_id, + recipe_references=[ + ShoppingListItemRecipeRef( + recipe_id=recipe_id, + recipe_quantity=ingredient.quantity, + ) + ], ) ) - shopping_list.list_items.extend(to_create) - return self.repo.update(shopping_list.id, shopping_list) + for item in to_create: + self.repos.group_shopping_list_item.create(item) + + updated_list = self.shopping_lists.get_one(list_id) + updated_list.list_items = self.consolidate_and_save(updated_list.list_items) + + not_found = True + for refs in updated_list.recipe_references: + if refs.recipe_id == recipe_id: + refs.recipe_quantity += 1 + not_found = False + + if not_found: + updated_list.recipe_references.append(ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=1)) + + updated_list = self.shopping_lists.update(updated_list.id, updated_list) + + return updated_list def remove_recipe_ingredients_from_list(self, list_id: UUID4, recipe_id: int) -> ShoppingListOut: - shopping_list = self.repo.get_one(list_id) - shopping_list.list_items = [x for x in shopping_list.list_items if x.recipe_id != recipe_id] - return self.repo.update(shopping_list.id, shopping_list) + shopping_list = self.shopping_lists.get_one(list_id) + + for item in shopping_list.list_items: + found = False + + for ref in item.recipe_references: + remove_qty = 0 + + if ref.recipe_id == recipe_id: + self.list_item_refs.delete(ref.id) + item.recipe_references.remove(ref) + found = True + remove_qty = ref.recipe_quantity + break # only remove one instance of the recipe for each item + + # If the item was found decrement the quantity by the remove_qty + if found: + item.quantity = item.quantity - remove_qty + + if item.quantity <= 0: + self.list_items.delete(item.id) + else: + self.list_items.update(item.id, item) + + # Decrament the list recipe reference count + for ref in shopping_list.recipe_references: + if ref.recipe_id == recipe_id: + ref.recipe_quantity -= 1 + + if ref.recipe_quantity <= 0: + self.list_refs.delete(ref.id) + + else: + self.list_refs.update(ref.id, ref) + break + + # Save Changes + return self.shopping_lists.get(shopping_list.id) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 30fde1a6a..89a102a21 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -2,4 +2,5 @@ from .fixture_admin import * from .fixture_database import * from .fixture_recipe import * from .fixture_routes import * +from .fixture_shopping_lists import * from .fixture_users import * diff --git a/tests/fixtures/fixture_recipe.py b/tests/fixtures/fixture_recipe.py index 891e9752d..6223a5d0e 100644 --- a/tests/fixtures/fixture_recipe.py +++ b/tests/fixtures/fixture_recipe.py @@ -1,5 +1,11 @@ +import sqlalchemy from pytest import fixture +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_ingredient import RecipeIngredient +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases @@ -16,3 +22,30 @@ def raw_recipe_no_image(): @fixture(scope="session") def recipe_store(): return get_recipe_test_cases() + + +@fixture(scope="function") +def recipe_ingredient_only(database: AllRepositories, unique_user: TestUser): + # Create a recipe + recipe = Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(10), + recipe_ingredient=[ + RecipeIngredient(note="Ingredient 1"), + RecipeIngredient(note="Ingredient 2"), + RecipeIngredient(note="Ingredient 3"), + RecipeIngredient(note="Ingredient 4"), + RecipeIngredient(note="Ingredient 5"), + RecipeIngredient(note="Ingredient 6"), + ], + ) + + model = database.recipes.create(recipe) + + yield model + + try: + database.recipes.delete(model.slug) + except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test + pass diff --git a/tests/fixtures/fixture_shopping_lists.py b/tests/fixtures/fixture_shopping_lists.py new file mode 100644 index 000000000..5c37b0e89 --- /dev/null +++ b/tests/fixtures/fixture_shopping_lists.py @@ -0,0 +1,85 @@ +import pytest +import sqlalchemy +from pydantic import UUID4 + +from mealie.repos.repository_factory import AllRepositories +from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListOut, ShoppingListSave +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +def create_item(list_id: UUID4) -> dict: + return { + "shopping_list_id": str(list_id), + "checked": False, + "position": 0, + "is_food": False, + "note": random_string(10), + "quantity": 1, + "unit_id": None, + "unit": None, + "food_id": None, + "food": None, + "recipe_id": None, + "label_id": None, + } + + +@pytest.fixture(scope="function") +def shopping_lists(database: AllRepositories, unique_user: TestUser): + + models: list[ShoppingListOut] = [] + + for _ in range(3): + model = database.group_shopping_lists.create( + ShoppingListSave(name=random_string(10), group_id=unique_user.group_id), + ) + + models.append(model) + + yield models + + for model in models: + try: + database.group_shopping_lists.delete(model.id) + except Exception: # Entry Deleted in Test + pass + + +@pytest.fixture(scope="function") +def shopping_list(database: AllRepositories, unique_user: TestUser): + + model = database.group_shopping_lists.create( + ShoppingListSave(name=random_string(10), group_id=unique_user.group_id), + ) + + yield model + + try: + database.group_shopping_lists.delete(model.id) + except Exception: # Entry Deleted in Test + pass + + +@pytest.fixture(scope="function") +def list_with_items(database: AllRepositories, unique_user: TestUser): + list_model = database.group_shopping_lists.create( + ShoppingListSave(name=random_string(10), group_id=unique_user.group_id), + ) + + for _ in range(10): + database.group_shopping_list_item.create( + ShoppingListItemCreate( + **create_item(list_model.id), + ) + ) + + # refresh model + list_model = database.group_shopping_lists.get(list_model.id) + + yield list_model + + try: + database.group_shopping_lists.delete(list_model.id) + except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test + pass diff --git a/tests/integration_tests/test_openapi.py b/tests/integration_tests/test_openapi.py new file mode 100644 index 000000000..34ba6e269 --- /dev/null +++ b/tests/integration_tests/test_openapi.py @@ -0,0 +1,6 @@ +from fastapi.testclient import TestClient + + +def test_openapi_returns_json(api_client: TestClient): + response = api_client.get("openapi.json") + assert response.status_code == 200 diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py new file mode 100644 index 000000000..55e9da74b --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py @@ -0,0 +1,199 @@ +import random +from uuid import uuid4 + +from fastapi.testclient import TestClient +from pydantic import UUID4 + +from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut +from tests import utils +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + shopping = "/api/groups/shopping" + items = shopping + "/items" + + def item(item_id: str) -> str: + return f"{Routes.items}/{item_id}" + + def shopping_list(list_id: str) -> str: + return f"{Routes.shopping}/lists/{list_id}" + + +def create_item(list_id: UUID4) -> dict: + return { + "shopping_list_id": str(list_id), + "checked": False, + "position": 0, + "is_food": False, + "note": random_string(10), + "quantity": 1, + "unit_id": None, + "unit": None, + "food_id": None, + "food": None, + "recipe_id": None, + "label_id": None, + } + + +def serialize_list_items(list_items: list[ShoppingListItemOut]) -> list: + as_dict = [] + for item in list_items: + item_dict = item.dict(by_alias=True) + item_dict["shoppingListId"] = str(item.shopping_list_id) + item_dict["id"] = str(item.id) + as_dict.append(item_dict) + + return as_dict + + +def test_shopping_list_items_create_one( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut +) -> None: + item = create_item(shopping_list.id) + + response = api_client.post(Routes.items, json=item, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 201) + + # Test Item is Getable + created_item_id = as_json["id"] + response = api_client.get(Routes.item(created_item_id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + # Ensure List Id is Set + assert as_json["shoppingListId"] == str(shopping_list.id) + + # Test Item In List + response = api_client.get(Routes.shopping_list(shopping_list.id), headers=unique_user.token) + response_list = utils.assert_derserialize(response, 200) + + assert len(response_list["listItems"]) == 1 + + # Check Item Id's + assert response_list["listItems"][0]["id"] == created_item_id + + +def test_shopping_list_items_get_one( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + + for _ in range(3): + item = random.choice(list_with_items.list_items) + + response = api_client.get(Routes.item(item.id), headers=unique_user.token) + assert response.status_code == 200 + + +def test_shopping_list_items_get_one_404(api_client: TestClient, unique_user: TestUser) -> None: + response = api_client.get(Routes.item(uuid4()), headers=unique_user.token) + assert response.status_code == 404 + + +def test_shopping_list_items_update_one( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + for _ in range(3): + item = random.choice(list_with_items.list_items) + + item.note = random_string(10) + + update_data = create_item(list_with_items.id) + update_data["id"] = str(item.id) + + response = api_client.put(Routes.item(item.id), json=update_data, headers=unique_user.token) + item_json = utils.assert_derserialize(response, 200) + assert item_json["note"] == update_data["note"] + + +def test_shopping_list_items_delete_one( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + item = random.choice(list_with_items.list_items) + + # Delete Item + response = api_client.delete(Routes.item(item.id), headers=unique_user.token) + assert response.status_code == 200 + + # Validate Get Item Returns 404 + response = api_client.get(Routes.item(item.id), headers=unique_user.token) + assert response.status_code == 404 + + +def test_shopping_list_items_update_many(api_client: TestClient, unique_user: TestUser) -> None: + assert True + + +def test_shopping_list_items_update_many_reorder( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + list_items = list_with_items.list_items + + # reorder list in random order + random.shuffle(list_items) + + # update List posiitons and serialize + as_dict = [] + for i, item in enumerate(list_items): + item.position = i + item_dict = item.dict(by_alias=True) + item_dict["shoppingListId"] = str(list_with_items.id) + item_dict["id"] = str(item.id) + as_dict.append(item_dict) + + # update list + response = api_client.put(Routes.items, json=as_dict, headers=unique_user.token) + assert response.status_code == 200 + + # retrieve list and check positions against list + response = api_client.get(Routes.shopping_list(list_with_items.id), headers=unique_user.token) + response_list = utils.assert_derserialize(response, 200) + + for i, item in enumerate(response_list["listItems"]): + assert item["position"] == i + assert item["id"] == str(list_items[i].id) + + +def test_shopping_list_items_update_many_consolidates_common_items( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + list_items = list_with_items.list_items + + master_note = random_string(10) + + # set quantity and note to trigger consolidation + for li in list_items: + li.quantity = 1 + li.note = master_note + + # update list + response = api_client.put(Routes.items, json=serialize_list_items(list_items), headers=unique_user.token) + assert response.status_code == 200 + + # retrieve list and check positions against list + response = api_client.get(Routes.shopping_list(list_with_items.id), headers=unique_user.token) + response_list = utils.assert_derserialize(response, 200) + + assert len(response_list["listItems"]) == 1 + assert response_list["listItems"][0]["quantity"] == len(list_items) + assert response_list["listItems"][0]["note"] == master_note + + +def test_shopping_list_items_update_many_remove_recipe_with_other_items( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + # list_items = list_with_items.list_items + pass diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py new file mode 100644 index 000000000..888972a15 --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -0,0 +1,201 @@ +import random + +from fastapi.testclient import TestClient + +from mealie.schema.group.group_shopping_list import ShoppingListOut +from mealie.schema.recipe.recipe import Recipe +from tests import utils +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/groups/shopping/lists" + + def item(item_id: str) -> str: + return f"{Routes.base}/{item_id}" + + def add_recipe(item_id: str, recipe_id: str) -> str: + return f"{Routes.item(item_id)}/recipe/{recipe_id}" + + +def test_shopping_lists_get_all(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]): + all_lists = api_client.get(Routes.base, headers=unique_user.token) + assert all_lists.status_code == 200 + all_lists = all_lists.json() + + assert len(all_lists) == len(shopping_lists) + + known_ids = [str(model.id) for model in shopping_lists] + + for list_ in all_lists: + assert list_["id"] in known_ids + + +def test_shopping_lists_create_one(api_client: TestClient, unique_user: TestUser): + payload = { + "name": random_string(10), + } + + response = api_client.post(Routes.base, json=payload, headers=unique_user.token) + response_list = utils.assert_derserialize(response, 201) + + assert response_list["name"] == payload["name"] + assert response_list["groupId"] == str(unique_user.group_id) + + +def test_shopping_lists_get_one(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]): + shopping_list = shopping_lists[0] + + response = api_client.get(Routes.item(shopping_list.id), headers=unique_user.token) + assert response.status_code == 200 + + response_list = response.json() + + assert response_list["id"] == str(shopping_list.id) + assert response_list["name"] == shopping_list.name + assert response_list["groupId"] == str(shopping_list.group_id) + + +def test_shopping_lists_update_one( + api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut] +): + sample_list = random.choice(shopping_lists) + + payload = { + "name": random_string(10), + "id": str(sample_list.id), + "groupId": str(sample_list.group_id), + "listItems": [], + } + + response = api_client.put(Routes.item(sample_list.id), json=payload, headers=unique_user.token) + assert response.status_code == 200 + + response_list = response.json() + + assert response_list["id"] == str(sample_list.id) + assert response_list["name"] == payload["name"] + assert response_list["groupId"] == str(sample_list.group_id) + + +def test_shopping_lists_delete_one( + api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut] +): + sample_list = random.choice(shopping_lists) + + response = api_client.delete(Routes.item(sample_list.id), headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + assert response.status_code == 404 + + +def test_shopping_lists_add_recipe( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], + recipe_ingredient_only: Recipe, +): + sample_list = random.choice(shopping_lists) + + recipe = recipe_ingredient_only + + response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token) + assert response.status_code == 200 + + # Get List and Check for Ingredients + + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient] + + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + # Check Recipe Reference was added with quantity 1 + refs = item["recipeReferences"] + + assert len(refs) == 1 + + assert refs[0]["recipeId"] == recipe.id + + +def test_shopping_lists_remove_recipe( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], + recipe_ingredient_only: Recipe, +): + sample_list = random.choice(shopping_lists) + + recipe = recipe_ingredient_only + + response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token) + assert response.status_code == 200 + + # Get List and Check for Ingredients + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient] + + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + # Remove Recipe + response = api_client.delete(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token) + + # Get List and Check for Ingredients + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == 0 + assert len(as_json["recipeReferences"]) == 0 + + +def test_shopping_lists_remove_recipe_multiple_quantity( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], + recipe_ingredient_only: Recipe, +): + sample_list = random.choice(shopping_lists) + + recipe = recipe_ingredient_only + + for _ in range(3): + response = api_client.post(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token) + assert response.status_code == 200 + + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient] + + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + # Remove Recipe + response = api_client.delete(Routes.add_recipe(sample_list.id, recipe.id), headers=unique_user.token) + + # Get List and Check for Ingredients + response = api_client.get(Routes.item(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + # All Items Should Still Exists + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + # Quantity Should Equal 2 Start with 3 remove 1) + for item in as_json["listItems"]: + assert item["quantity"] == 2.0 + + refs = as_json["recipeReferences"] + assert len(refs) == 1 + assert refs[0]["recipeId"] == recipe.id diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index 86ea70970..8fe114632 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1,4 +1,5 @@ from .app_routes import * +from .assertion_helpers import * from .factories import * from .fixture_schemas import * from .user_login import * diff --git a/tests/utils/assertion_helpers.py b/tests/utils/assertion_helpers.py index c0da8ee1d..371f4a36e 100644 --- a/tests/utils/assertion_helpers.py +++ b/tests/utils/assertion_helpers.py @@ -1,3 +1,6 @@ +from requests import Response + + def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list = None) -> None: """ Itterates through a list of keys and checks if they are in the the provided ignore_keys list, @@ -15,3 +18,8 @@ def assert_ignore_keys(dict1: dict, dict2: dict, ignore_keys: list = None) -> No continue else: assert value == dict2[key] + + +def assert_derserialize(response: Response, expected_status_code=200) -> dict: + assert response.status_code == expected_status_code + return response.json()