1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 15:49:42 +02:00

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
This commit is contained in:
Hayden 2022-01-16 15:24:24 -09:00 committed by GitHub
parent f794208862
commit 92cf97e401
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 2556 additions and 685 deletions

View file

@ -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__":

View file

@ -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<Label, CreateLabel> {
export class MultiPurposeLabelsApi extends BaseCRUDAPI<MultiPurposeLabelOut, MultiPurposeLabelCreate> {
baseRoute = routes.labels;
itemRoute = routes.labelsId;
}

View file

@ -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<ShoppingList, ShoppingListCreate> {
export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListOut, ShoppingListCreate> {
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<ShoppingListItemOut, ShoppingListItemCreate> {
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);
}
}

View file

@ -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;
}

View file

@ -0,0 +1,33 @@
<template>
<v-list>
<v-list-item v-for="recipe in recipes" :key="recipe.id" :to="'/recipe/' + recipe.slug">
<v-list-item-avatar>
<v-icon class="pa-1 primary" dark> {{ $globals.icons.primary }} </v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ recipe.name }}
</v-list-item-title>
<v-list-item-subtitle>{{ recipe.description }}</v-list-item-subtitle>
</v-list-item-content>
<slot :name="'actions-' + recipe.id" :v-bind="{ item: recipe }"> </slot>
</v-list-item>
</v-list>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { RecipeSummary } from "~/types/api-types/recipe";
export default defineComponent({
props: {
recipes: {
type: Array as () => RecipeSummary[],
required: true,
},
},
setup() {
return {};
},
});
</script>

View file

@ -0,0 +1,56 @@
<template>
<v-chip v-bind="$attrs" label :color="label.color || undefined" :text-color="textColor">
{{ label.name }}
</v-chip>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { MultiPurposeLabelSummary } from "~/types/api-types/recipe";
export default defineComponent({
props: {
label: {
type: Object as () => MultiPurposeLabelSummary,
required: true,
},
},
setup(props) {
const textColor = computed(() => {
if (!props.label.color) {
return "black";
}
return pickTextColorBasedOnBgColorAdvanced(props.label.color, "white", "black");
});
/*
Function to pick the text color based on the background color.
Based on -> https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
*/
const ACCESSIBILITY_THRESHOLD = 0.179;
function pickTextColorBasedOnBgColorAdvanced(bgColor: string, lightColor: string, darkColor: string) {
const color = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor;
const r = parseInt(color.substring(0, 2), 16); // hexToR
const g = parseInt(color.substring(2, 4), 16); // hexToG
const b = parseInt(color.substring(4, 6), 16); // hexToB
const uicolors = [r / 255, g / 255, b / 255];
const c = uicolors.map((col) => {
if (col <= 0.03928) {
return col / 12.92;
}
return Math.pow((col + 0.055) / 1.055, 2.4);
});
const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
return L > ACCESSIBILITY_THRESHOLD ? darkColor : lightColor;
}
return {
textColor,
};
},
});
</script>

View file

@ -1,65 +1,64 @@
<template>
<div v-if="!edit" class="small-checkboxes d-flex justify-space-between align-center">
<v-checkbox v-model="listItem.checked" hide-details dense :label="listItem.note" @change="$emit('checked')">
<div v-if="!edit" class="d-flex justify-space-between align-center">
<v-checkbox
v-model="listItem.checked"
color="null"
hide-details
dense
:label="listItem.note"
@change="$emit('checked')"
>
<template #label>
<div>
{{ listItem.quantity }} <v-icon size="16" class="mx-1"> {{ $globals.icons.close }} </v-icon>
{{ listItem.note }}
<div :class="listItem.checked ? 'strike-through' : ''">
{{ displayText }}
</div>
</template>
</v-checkbox>
<v-chip v-if="listItem.label" class="ml-auto mt-2" small label>
{{ listItem.label.name }}
</v-chip>
<v-menu offset-x left>
<template #activator="{ on, attrs }">
<v-btn small class="ml-2 mt-2 handle" icon v-bind="attrs" v-on="on">
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
<v-list-item-title>{{ action.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<MultiPurposeLabel v-if="listItem.label" :label="listItem.label" class="ml-auto mt-2" small />
<div style="min-width: 72px">
<v-menu offset-x left min-width="125px">
<template #activator="{ on, attrs }">
<v-btn small class="ml-2 mt-2 handle" icon v-bind="attrs" v-on="on">
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
<v-list-item-title>{{ action.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn small class="ml-2 mt-2 handle" icon @click="edit = true">
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
</div>
</div>
<div v-else class="my-1">
<v-card outlined>
<v-card-text>
<v-textarea v-model="listItem.note" hide-details label="Note" rows="1" auto-grow></v-textarea>
<div style="max-width: 300px" class="mt-3">
<v-autocomplete
v-model="listItem.labelId"
name=""
:items="labels"
item-value="id"
hide-details
item-text="name"
clearable
:prepend-inner-icon="$globals.icons.tags"
>
</v-autocomplete>
<v-checkbox v-model="listItem.isFood" hide-details label="Treat list item as a recipe ingredient" />
</div>
</v-card-text>
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
<v-btn icon @click="save">
<v-icon>
{{ $globals.icons.save }}
</v-icon>
</v-btn>
</v-card-actions>
</v-card>
<div v-else class="mb-1 mt-6">
<ShoppingListItemEditor
v-model="listItem"
:labels="labels"
:units="units"
:foods="foods"
@save="save"
@cancel="edit = !edit"
@delete="$emit('delete')"
@toggle-foods="listItem.isFood = !listItem.isFood"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
import { Label } from "~/api/class-interfaces/group-multiple-purpose-labels";
import { ShoppingListItemCreate } from "~/api/class-interfaces/group-shopping-lists";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import { ShoppingListItemCreate } from "~/types/api-types/group";
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe";
import { getDisplayText } from "~/composables/use-display-text";
interface actions {
text: string;
@ -71,24 +70,33 @@ const contextMenu: actions[] = [
text: "Edit",
event: "edit",
},
// {
// text: "Delete",
// event: "delete",
// },
// {
// text: "Move",
// event: "move",
// },
{
text: "Delete",
event: "delete",
},
{
text: "Transfer",
event: "transfer",
},
];
export default defineComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel },
props: {
value: {
type: Object as () => ShoppingListItemCreate,
required: true,
},
labels: {
type: Array as () => Label[],
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
units: {
type: Array as () => IngredientUnit[],
required: true,
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
},
@ -114,10 +122,6 @@ export default defineComponent({
edit.value = false;
}
function handle(event: string) {
console.log(event);
}
const updatedLabels = computed(() => {
return props.labels.map((label) => {
return {
@ -127,9 +131,13 @@ export default defineComponent({
});
});
const displayText = computed(() =>
getDisplayText(listItem.value.note, listItem.value.quantity, listItem.value.food, listItem.value.unit)
);
return {
displayText,
updatedLabels,
handle,
save,
contextHandler,
edit,
@ -139,3 +147,9 @@ export default defineComponent({
},
});
</script>
<style lang="css">
.strike-through {
text-decoration: line-through !important;
}
</style>

View file

@ -0,0 +1,129 @@
<template>
<div>
<v-card outlined>
<v-card-text class="pb-3 pt-1">
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
<InputLabelType
v-model="listItem.food"
:items="foods"
:item-id.sync="listItem.foodId"
label="Food"
:icon="$globals.icons.foods"
/>
<InputLabelType
v-model="listItem.unit"
:items="units"
:item-id.sync="listItem.unitId"
label="Units"
:icon="$globals.icons.units"
/>
</div>
<div class="d-md-flex align-center" style="gap: 20px">
<v-textarea v-model="listItem.note" hide-details label="Note" rows="1" auto-grow></v-textarea>
</div>
<div class="d-flex align-end" style="gap: 20px">
<div>
<InputQuantity v-model="listItem.quantity" />
</div>
<div style="max-width: 300px" class="mt-3 mr-auto">
<InputLabelType v-model="listItem.label" :items="labels" :item-id.sync="listItem.labelId" label="Label" />
</div>
<v-menu
v-if="listItem.recipeReferences && listItem.recipeReferences.length > 0"
open-on-hover
offset-y
left
top
>
<template #activator="{ on, attrs }">
<v-icon class="mt-auto" icon v-bind="attrs" color="warning" v-on="on">
{{ $globals.icons.alert }}
</v-icon>
</template>
<v-card max-width="350px" class="left-warning-border">
<v-card-text>
This item is linked to one or more recipe. Adjusting the units or foods will yield unexpected results
when adding or removing the recipe from this list.
</v-card-text>
</v-card>
</v-menu>
</div>
</v-card-text>
</v-card>
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.close,
text: $t('general.cancel'),
event: 'cancel',
},
{
icon: $globals.icons.foods,
text: 'Toggle Food',
event: 'toggle-foods',
},
{
icon: $globals.icons.save,
text: $t('general.save'),
event: 'save',
},
]"
@save="$emit('save')"
@cancel="$emit('cancel')"
@delete="$emit('delete')"
@toggle-foods="listItem.isFood = !listItem.isFood"
/>
</v-card-actions>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group";
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe";
export default defineComponent({
props: {
value: {
type: Object as () => ShoppingListItemCreate | ShoppingListItemOut,
required: true,
},
labels: {
type: Array as () => MultiPurposeLabelOut[],
required: true,
},
units: {
type: Array as () => IngredientUnit[],
required: true,
},
foods: {
type: Array as () => IngredientFood[],
required: true,
},
},
setup(props, context) {
const listItem = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
return {
listItem,
};
},
head: {
title: "vbase-nuxt",
},
});
</script>

View file

@ -0,0 +1,31 @@
<template>
<div>
<v-btn outlined class="rounded-xl my-1 mx-1" :to="to">
<v-icon v-if="icon != ''" left>
{{ icon }}
</v-icon>
{{ text }}
</v-btn>
</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
to: {
type: String,
required: true,
},
text: {
type: String,
default: "Link",
},
icon: {
type: String,
default: "",
},
},
});
</script>

View file

@ -0,0 +1,25 @@
<template>
<pre>
{{ prettyJson }}
</pre>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
data: {
type: Object,
required: true,
},
},
setup(props) {
const prettyJson = JSON.stringify(props.data, null, 2);
return {
prettyJson,
};
},
});
</script>

View file

@ -0,0 +1,66 @@
<template>
<v-text-field v-model="inputVal" label="Color">
<template #prepend>
<v-btn class="elevation-0" small height="30px" width="30px" :color="inputVal || 'grey'" @click="setRandomHex">
<v-icon color="white">
{{ $globals.icons.refreshCircle }}
</v-icon>
</v-btn>
</template>
<template #append>
<v-menu v-model="menu" left nudge-left="30" nudge-top="20" :close-on-content-click="false">
<template #activator="{ on }">
<v-icon v-on="on">
{{ $globals.icons.formatColorFill }}
</v-icon>
</template>
<v-card>
<v-card-text class="pa-0">
<v-color-picker v-model="inputVal" flat hide-inputs show-swatches swatches-max-height="200" />
</v-card-text>
</v-card>
</v-menu>
</template>
</v-text-field>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
value: {
type: String,
required: true,
},
},
setup(props, context) {
const menu = ref(false);
const inputVal = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
function getRandomHex() {
return "#000000".replace(/0/g, function () {
return (~~(Math.random() * 16)).toString(16);
});
}
function setRandomHex() {
inputVal.value = getRandomHex();
}
return {
menu,
setRandomHex,
inputVal,
};
},
});
</script>

View file

@ -0,0 +1,87 @@
<template>
<v-autocomplete
v-model="itemVal"
v-bind="$attrs"
item-text="name"
return-object
:items="items"
:prepend-icon="icon || $globals.icons.tags"
clearable
hide-details
/>
</template>
<script lang="ts">
/**
* The InputLabelType component is a wrapper for v-autocomplete. It is used to abstract the selection functionality
* of some common types within Mealie. This can mostly be used with any type of object provided it has a name and id
* property. The name property is used to display the name of the object in the autocomplete dropdown. The id property
* is used to store the id of the object in the itemId property.
*
* Supported Types
* - MultiPurposeLabel
* - RecipeIngredientFood
* - RecipeIngredientUnit
*
* TODO: Add RecipeTag / Category to this selector
* Future Supported Types
* - RecipeTags
* - RecipeCategories
*
* Both the ID and Item can be synced. The item can be synced using the v-model syntax and the itemId can be synced
* using the .sync syntax `item-id.sync="item.labelId"`
*/
import { defineComponent, computed } from "@nuxtjs/composition-api";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe";
export default defineComponent({
props: {
value: {
type: Object as () => MultiPurposeLabelSummary | IngredientFood | IngredientUnit,
required: false,
default: () => {
return {};
},
},
items: {
type: Array as () => Array<MultiPurposeLabelSummary | IngredientFood | IngredientUnit>,
required: true,
},
itemId: {
type: [String, Number],
default: undefined,
},
icon: {
type: String,
required: false,
default: undefined,
},
},
setup(props, context) {
const itemIdVal = computed({
get: () => {
return props.itemId || undefined;
},
set: (val) => {
context.emit("update:item-id", val);
},
});
const itemVal = computed({
get: () => {
return props.value;
},
set: (val) => {
itemIdVal.value = val?.id || undefined;
context.emit("input", val);
},
});
return {
itemVal,
itemIdVal,
};
},
});
</script>

View file

@ -0,0 +1,64 @@
<template>
<div class="d-flex align-center" style="max-width: 60px">
<v-text-field
v-model.number="quantity"
hide-details
label="Qty"
:min="min"
:max="max"
type="number"
class="rounded-xl"
small
text
>
</v-text-field>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
name: "VInputNumber",
props: {
label: {
type: String,
default: "Qty",
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 9999,
},
rules: {
type: Array,
default: () => [],
},
step: {
type: Number,
default: 1,
},
value: {
type: Number,
default: 0,
},
},
setup(props, context) {
const quantity = computed({
get: () => {
return Number(props.value);
},
set: (val) => {
context.emit("input", val);
},
});
return {
quantity,
};
},
});
</script>

View file

@ -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<Food[] | null> | null = null;
let foodStore: Ref<IngredientFood[] | null> | 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;

View file

@ -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,
};
}

View file

@ -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();
}

View file

@ -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",

View file

@ -15,6 +15,14 @@
<v-form ref="domCreateFoodForm">
<v-text-field v-model="workingFoodData.name" label="Name" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="workingFoodData.description" label="Description"></v-text-field>
<v-autocomplete
v-model="workingFoodData.labelId"
clearable
:items="allLabels"
item-value="id"
item-text="name"
>
</v-autocomplete>
</v-form>
</v-card-text>
</BaseDialog>
@ -50,6 +58,11 @@
</v-expand-transition>
<v-data-table :headers="headers" :items="foods || []" item-key="id" class="elevation-0" :search="search">
<template #item.label="{ item }">
<v-chip v-if="item.label" label>
{{ item.label.name }}
</v-chip>
</template>
<template #item.actions="{ item }">
<div class="d-flex justify-end">
<BaseButton
@ -79,8 +92,10 @@
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, computed } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useFoods } from "~/composables/recipes";
import { validators } from "~/composables/use-validators";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
export default defineComponent({
layout: "admin",
setup() {
@ -111,6 +126,7 @@ export default defineComponent({
{ text: "Id", value: "id" },
{ text: "Name", value: "name" },
{ text: "Description", value: "description" },
{ text: "Label", value: "label" },
{ text: "", value: "actions", sortable: false },
],
filter: false,
@ -118,7 +134,20 @@ export default defineComponent({
search: "",
});
const userApi = useUserApi();
const allLabels = ref([] as MultiPurposeLabelSummary[]);
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
allLabels.value = data ?? [];
}
refreshLabels();
return {
allLabels,
refreshLabels,
...toRefs(state),
actions,
dialog,

View file

@ -1,26 +1,32 @@
<template>
<v-container v-if="shoppingList" class="narrow-container">
<v-container v-if="shoppingList" class="md-container">
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
</template>
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerExperimental issue="https://github.com/hay-kot/mealie/issues/916" />
<!-- Viewer -->
<section v-if="!edit" class="py-2">
<div v-if="!byLabel">
<draggable :value="shoppingList.listItems" handle=".handle" @input="updateIndex">
<ShoppingListItem
v-for="(item, index) in listItems.unchecked"
:key="item.id"
v-model="listItems.unchecked[index]"
:labels="allLabels"
@checked="saveList"
@save="saveList"
/>
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
<ShoppingListItem
v-model="listItems.unchecked[index]"
class="my-2 my-sm-0"
:labels="allLabels"
:units="allUnits || []"
:foods="allFoods || []"
@checked="saveListItem(item)"
@save="saveListItem(item)"
@delete="deleteListItem(item)"
/>
</v-lazy>
</draggable>
</div>
<!-- View By Label -->
<div v-else>
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
<div @click="toggleShowChecked()">
@ -31,19 +37,84 @@
</span>
{{ key }}
</div>
<div v-for="item in value" :key="item.id" class="small-checkboxes d-flex justify-space-between align-center">
<v-checkbox v-model="item.checked" hide-details dense :label="item.note" @change="saveList">
<template #label>
<div>
{{ item.quantity }} <v-icon size="16" class="mx-1"> {{ $globals.icons.close }} </v-icon>
{{ item.note }}
</div>
</template>
</v-checkbox>
</div>
<v-lazy v-for="(item, index) in value" :key="item.id">
<ShoppingListItem
v-model="value[index]"
:labels="allLabels"
:units="allUnits || []"
:foods="allFoods || []"
@checked="saveListItem(item)"
@save="saveListItem(item)"
@delete="deleteListItem(item)"
/>
</v-lazy>
</div>
</div>
<!-- Create Item -->
<div v-if="createEditorOpen">
<ShoppingListItemEditor
v-model="createListItemData"
class="my-4"
:labels="allLabels"
:units="allUnits || []"
:foods="allFoods || []"
@delete="createEditorOpen = false"
@cancel="createEditorOpen = false"
@save="createListItem"
/>
</div>
<div v-else class="mt-4 d-flex justify-end">
<BaseButton create @click="createEditorOpen = true" />
</div>
<!-- Action Bar -->
<div class="d-flex justify-end mb-4 mt-2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.contentCopy,
text: '',
event: 'edit',
children: [
{
icon: $globals.icons.contentCopy,
text: 'Copy as Text',
event: 'copy-plain',
},
{
icon: $globals.icons.contentCopy,
text: 'Copy as Markdown',
event: 'copy-markdown',
},
],
},
{
icon: $globals.icons.delete,
text: 'Delete Checked',
event: 'delete',
},
{
icon: $globals.icons.tags,
text: 'Toggle Label Sort',
event: 'sort-by-labels',
},
{
icon: $globals.icons.checkboxBlankOutline,
text: 'Uncheck All Items',
event: 'uncheck',
},
]"
@edit="edit = true"
@delete="deleteChecked"
@uncheck="uncheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')"
/>
</div>
<!-- Checked Items -->
<div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6">
<button @click="toggleShowChecked()">
<span>
@ -56,133 +127,60 @@
<v-divider class="my-4"></v-divider>
<v-expand-transition>
<div v-show="showChecked">
<div v-for="item in listItems.checked" :key="item.id" class="d-flex justify-space-between align-center">
<v-checkbox v-model="item.checked" color="gray" class="my-n2" :label="item.note" @change="saveList">
<template #label>
<div style="text-decoration: line-through">
{{ item.quantity }} x
{{ item.note }}
</div>
</template>
</v-checkbox>
<div v-for="(item, idx) in listItems.checked" :key="item.id">
<ShoppingListItem
v-model="listItems.checked[idx]"
class="strike-through-note"
:labels="allLabels"
:units="allUnits || []"
:foods="allFoods || []"
@checked="saveListItem(item)"
@save="saveListItem(item)"
@delete="deleteListItem(item)"
/>
</div>
</div>
</v-expand-transition>
</div>
</section>
<!-- Editor -->
<section v-else>
<draggable :value="shoppingList.listItems" handle=".handle" @input="updateIndex">
<div v-for="(item, index) in shoppingList.listItems" :key="index" class="d-flex">
<div class="number-input-container">
<v-text-field v-model="shoppingList.listItems[index].quantity" class="mx-1" type="number" label="Qty" />
</div>
<v-text-field v-model="item.note" :label="$t('general.name')"> </v-text-field>
<v-menu offset-x left>
<template #activator="{ on, attrs }">
<v-btn icon class="mt-3" v-bind="attrs" v-on="on">
<v-icon class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
<!-- Recipe References -->
<v-lazy v-if="shoppingList.recipeReferences && shoppingList.recipeReferences.length > 0">
<section>
<div>
<span>
<v-icon left class="mb-1">
{{ $globals.icons.primary }}
</v-icon>
</span>
{{ shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0 }} Linked Recipes
</div>
<v-divider class="my-4"></v-divider>
<RecipeList :recipes="listRecipes">
<template v-for="(recipe, index) in listRecipes" #[`actions-${recipe.id}`]>
<v-list-item-action :key="'item-actions-decrease' + recipe.id">
<v-btn icon @click.prevent="removeRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(itm, idx) in contextMenu"
:key="idx"
@click="contextMenuAction(itm.action, item, index)"
>
<v-list-item-title>{{ itm.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<div v-if="item.isFood">Is Food</div>
</div>
</draggable>
</v-list-item-action>
<div :key="'item-actions-quantity' + recipe.id" class="pl-3">
{{ shoppingList.recipeReferences[index].recipeQuantity }}
</div>
<v-list-item-action :key="'item-actions-increase' + recipe.id">
<v-btn icon @click.prevent="addRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</v-list-item-action>
</template>
</RecipeList>
</section>
</v-lazy>
<v-divider class="my-2" />
<!-- Create Form -->
<v-form @submit.prevent="ingredientCreate()">
<v-checkbox v-model="createIngredient.isFood" label="Treat list item as a recipe ingredient" />
<div class="d-flex">
<div class="number-input-container">
<v-text-field v-model="createIngredient.quantity" class="mx-1" type="number" label="Qty" />
</div>
<v-text-field v-model="createIngredient.note" :label="$t('recipe.note')"> </v-text-field>
</div>
<div v-if="createIngredient.isFood">Is Food</div>
<v-autocomplete
v-model="createIngredient.labelId"
clearable
name=""
:items="allLabels"
item-value="id"
item-text="name"
>
</v-autocomplete>
<div class="d-flex justify-end">
<BaseButton type="submit" create> </BaseButton>
</div>
</v-form>
</section>
<div class="d-flex justify-end my-4">
<BaseButtonGroup
v-if="!edit"
:buttons="[
{
icon: $globals.icons.contentCopy,
text: '',
event: 'edit',
children: [
{
icon: $globals.icons.contentCopy,
text: 'Copy as Text',
event: 'copy-plain',
},
{
icon: $globals.icons.contentCopy,
text: 'Copy as Markdown',
event: 'copy-markdown',
},
],
},
{
icon: $globals.icons.delete,
text: 'Delete Checked',
event: 'delete',
},
{
icon: $globals.icons.tags,
text: 'Toggle Label Sort',
event: 'sort-by-labels',
},
{
icon: $globals.icons.checkboxBlankOutline,
text: 'Uncheck All Items',
event: 'uncheck',
},
{
icon: $globals.icons.primary,
text: 'Add Recipe',
event: 'recipe',
},
{
icon: $globals.icons.edit,
text: 'Edit List',
event: 'edit',
},
]"
@edit="edit = true"
@delete="deleteChecked"
@uncheck="uncheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')"
/>
<BaseButton v-else save @click="saveList" />
</div>
<v-lazy>
<div class="d-flex justify-end mt-10">
<ButtonLink to="/shopping-lists/labels" text="Manage Labels" :icon="$globals.icons.tags" />
</div>
</v-lazy>
</v-container>
</template>
@ -190,14 +188,17 @@
import draggable from "vuedraggable";
import { defineComponent, useAsync, useRoute, computed, ref } from "@nuxtjs/composition-api";
import { useClipboard, useToggle } from "@vueuse/core";
import { ShoppingListItemCreate } from "~/api/class-interfaces/group-shopping-lists";
import { useToggle } from "@vueuse/core";
import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
import { useAsyncKey, uuid4 } from "~/composables/use-utils";
import { alert } from "~/composables/use-toast";
import { Label } from "~/api/class-interfaces/group-multiple-purpose-labels";
import { useAsyncKey } from "~/composables/use-utils";
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import BannerExperimental from "~/components/global/BannerExperimental.vue";
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { getDisplayText } from "~/composables/use-display-text";
type CopyTypes = "plain" | "markdown";
interface PresentLabel {
@ -209,7 +210,8 @@ export default defineComponent({
components: {
draggable,
ShoppingListItem,
BannerExperimental,
RecipeList,
ShoppingListItemEditor,
},
setup() {
const userApi = useUserApi();
@ -220,6 +222,9 @@ export default defineComponent({
const route = useRoute();
const id = route.value.params.id;
// ===============================================================
// Shopping List Actions
const shoppingList = useAsync(async () => {
return await fetchShoppingList();
}, useAsyncKey());
@ -233,121 +238,40 @@ export default defineComponent({
shoppingList.value = await fetchShoppingList();
}
async function saveList() {
if (!shoppingList.value) {
return;
}
// Set Position
shoppingList.value.listItems = shoppingList.value.listItems.map((itm: ShoppingListItemCreate, idx: number) => {
itm.position = idx;
return itm;
});
await userApi.shopping.lists.updateOne(id, shoppingList.value);
refresh();
edit.value = false;
}
// =====================================
// Ingredient CRUD
// List Item CRUD
const listItems = computed(() => {
return {
checked: shoppingList.value?.listItems.filter((item) => item.checked),
unchecked: shoppingList.value?.listItems.filter((item) => !item.checked),
checked: shoppingList.value?.listItems?.filter((item) => item.checked) ?? [],
unchecked: shoppingList.value?.listItems?.filter((item) => !item.checked) ?? [],
};
});
const createIngredient = ref(ingredientResetFactory());
function ingredientResetFactory() {
return {
id: null,
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems.length || 1,
isFood: false,
quantity: 1,
note: "",
unit: null,
food: null,
labelId: null,
};
}
function ingredientCreate() {
const item = { ...createIngredient.value, id: uuid4() };
shoppingList.value?.listItems.push(item);
createIngredient.value = ingredientResetFactory();
}
function updateIndex(data: ShoppingListItemCreate[]) {
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = data;
}
if (!edit.value) {
saveList();
}
}
const [showChecked, toggleShowChecked] = useToggle(false);
// =====================================
// Copy List Items
const { copy, copied, isSupported } = useClipboard();
const copy = useCopyList();
function getItemsAsPlain(items: ShoppingListItemCreate[]) {
return items
.map((item) => {
return `${item.quantity} x ${item.unit?.name || ""} ${item.food?.name || ""} ${item.note || ""}`.replace(
/\s+/g,
" "
);
})
.join("\n");
}
function getItemsAsMarkdown(items: ShoppingListItemCreate[]) {
return items
.map((item) => {
return `- [ ] ${item.quantity} x ${item.unit?.name || ""} ${item.food?.name || ""} ${
item.note || ""
}`.replace(/\s+/g, " ");
})
.join("\n");
}
async function copyListItems(copyType: CopyTypes) {
if (!isSupported) {
alert.error("Copy to clipboard is not supported in your browser or environment.");
}
console.log("copyListItems", copyType);
const items = shoppingList.value?.listItems.filter((item) => !item.checked);
function copyListItems(copyType: CopyTypes) {
const items = shoppingList.value?.listItems?.filter((item) => !item.checked);
if (!items) {
return;
}
let text = "";
const text = items.map((itm) => getDisplayText(itm.note, itm.quantity, itm.food, itm.unit));
switch (copyType) {
case "markdown":
text = getItemsAsMarkdown(items);
copy.copyMarkdownCheckList(text);
break;
default:
text = getItemsAsPlain(items);
copy.copyPlain(text);
break;
}
await copy(text);
if (copied) {
alert.success(`Copied ${items.length} items to clipboard`);
}
}
// =====================================
@ -355,29 +279,27 @@ export default defineComponent({
function uncheckAll() {
let hasChanged = false;
shoppingList.value?.listItems.forEach((item) => {
shoppingList.value?.listItems?.forEach((item) => {
if (item.checked) {
hasChanged = true;
item.checked = false;
}
});
if (hasChanged) {
saveList();
updateListItems();
}
}
function deleteChecked() {
const unchecked = shoppingList.value?.listItems.filter((item) => !item.checked);
const checked = shoppingList.value?.listItems?.filter((item) => item.checked);
if (unchecked?.length === shoppingList.value?.listItems.length) {
if (!checked || checked?.length === 0) {
return;
}
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = unchecked || [];
}
deleteListItems(checked);
saveList();
refresh();
}
// =====================================
@ -393,7 +315,7 @@ export default defineComponent({
{ title: "Ingredient", action: contextActions.setIngredient },
];
function contextMenuAction(action: string, item: ShoppingListItemCreate, idx: number) {
function contextMenuAction(action: string, item: ShoppingListItemOut, idx: number) {
if (!shoppingList.value?.listItems) {
return;
}
@ -411,9 +333,20 @@ export default defineComponent({
}
// =====================================
// Labels
// Labels, Units, Foods
// TODO: Extract to Composable
const allLabels = ref([] as Label[]);
const allLabels = ref([] as MultiPurposeLabelOut[]);
const allUnits = useAsync(async () => {
const { data } = await userApi.units.getAll();
return data ?? [];
}, useAsyncKey());
const allFoods = useAsync(async () => {
const { data } = await userApi.foods.getAll();
return data ?? [];
}, useAsyncKey());
function sortByLabels() {
byLabel.value = !byLabel.value;
@ -422,10 +355,9 @@ export default defineComponent({
const presentLabels = computed(() => {
const labels: PresentLabel[] = [];
shoppingList.value?.listItems.forEach((item) => {
if (item.labelId) {
shoppingList.value?.listItems?.forEach((item) => {
if (item.labelId && item.label) {
labels.push({
// @ts-ignore TODO
name: item.label.name,
id: item.labelId,
});
@ -442,7 +374,11 @@ export default defineComponent({
"No Label": [] as ShoppingListItemCreate[],
};
shoppingList.value?.listItems.forEach((item) => {
shoppingList.value?.listItems?.forEach((item) => {
if (item.checked) {
return;
}
if (item.labelId) {
if (item.label && item.label.name in items) {
items[item.label.name].push(item);
@ -468,26 +404,164 @@ export default defineComponent({
refreshLabels();
// =====================================
// Add/Remove Recipe References
const listRecipes = computed<Array<any>>(() => {
return shoppingList.value?.recipeReferences?.map((ref) => ref.recipe) ?? [];
});
async function addRecipeReferenceToList(recipeId: number) {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.lists.addRecipe(shoppingList.value.id, recipeId);
if (data) {
refresh();
}
}
async function removeRecipeReferenceToList(recipeId: number) {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId);
if (data) {
refresh();
}
}
// =====================================
// List Item CRUD
async function saveListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.items.updateOne(item.id, item);
if (data) {
refresh();
}
}
async function deleteListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.items.deleteOne(item.id);
if (data) {
refresh();
}
}
// =====================================
// Create New Item
const createEditorOpen = ref(false);
const createListItemData = ref<ShoppingListItemCreate>(ingredientResetFactory());
function ingredientResetFactory(): ShoppingListItemCreate {
return {
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems?.length || 1,
isFood: false,
quantity: 1,
note: "",
unit: undefined,
food: undefined,
labelId: undefined,
};
}
async function createListItem() {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
if (data) {
createListItemData.value = ingredientResetFactory();
createEditorOpen.value = false;
refresh();
}
}
function updateIndex(data: ShoppingListItemOut[]) {
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = data;
}
updateListItems();
}
async function deleteListItems(items: ShoppingListItemOut[]) {
if (!shoppingList.value) {
return;
}
const { data } = await userApi.shopping.items.deleteMany(items);
if (data) {
refresh();
}
}
async function updateListItems() {
if (!shoppingList.value?.listItems) {
return;
}
// Set Position
shoppingList.value.listItems = shoppingList.value.listItems.map((itm: ShoppingListItemOut, idx: number) => {
itm.position = idx;
return itm;
});
const { data } = await userApi.shopping.items.updateMany(shoppingList.value.listItems);
if (data) {
refresh();
}
}
return {
itemsByLabel,
byLabel,
presentLabels,
addRecipeReferenceToList,
updateListItems,
allLabels,
copyListItems,
sortByLabels,
uncheckAll,
showChecked,
toggleShowChecked,
createIngredient,
contextMenuAction,
byLabel,
contextMenu,
contextMenuAction,
copyListItems,
createEditorOpen,
createListItem,
createListItemData,
deleteChecked,
listItems,
updateIndex,
saveList,
deleteListItem,
edit,
itemsByLabel,
listItems,
listRecipes,
presentLabels,
removeRecipeReferenceToList,
saveListItem,
shoppingList,
ingredientCreate,
showChecked,
sortByLabels,
toggleShowChecked,
uncheckAll,
updateIndex,
allUnits,
allFoods,
};
},
head() {
@ -503,3 +577,4 @@ export default defineComponent({
max-width: 50px;
}
</style>

View file

@ -32,6 +32,9 @@
</v-card-title>
</v-card>
</section>
<div class="d-flex justify-end mt-10">
<ButtonLink to="/shopping-lists/labels" text="Manage Labels" :icon="$globals.icons.tags" />
</div>
</v-container>
</template>

View file

@ -0,0 +1,266 @@
<template>
<v-container class="narrow-container">
<BaseDialog v-model="createDialog" title="New Label" :icon="$globals.icons.tags" @submit="createLabel">
<v-card-text>
<v-text-field v-model="createLabelData.name" :label="$t('general.name')"> </v-text-field>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="deleteDialog"
:title="$t('general.confirm')"
:icon="$globals.icons.alert"
color="error"
@confirm="confirmDelete"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
</template>
<template #title> Shopping Lists Labels </template>
</BasePageTitle>
<BaseButton create @click="createDialog = true" />
<section v-if="labels" class="mt-4">
<v-text-field v-model="searchInput" :label="$t('sidebar.search')" clearable>
<template #prepend>
<v-icon>{{ $globals.icons.search }}</v-icon>
</template>
</v-text-field>
<v-sheet v-for="(label, index) in results" :key="label.id">
<div class="d-flex px-2 py-2 pt-3">
<MultiPurposeLabel :label="label" />
<div class="ml-auto">
<v-btn v-if="!isOpen[label.id]" class="mx-1" icon @click.prevent="deleteLabel(label.id)">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
<v-btn v-if="!isOpen[label.id]" class="mx-1" icon @click="toggleIsOpen(label)">
<v-icon>
{{ $globals.icons.edit }}
</v-icon>
</v-btn>
</div>
</div>
<v-card-text v-if="isOpen[label.id]">
<div class="d-md-flex" style="gap: 30px">
<v-text-field v-model="labels[index].name" :label="$t('general.name')"> </v-text-field>
<div style="max-width: 300px">
<InputColor v-model="labels[index].color" />
</div>
</div>
<div class="d-flex justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: 'Delete',
event: 'delete',
},
{
icon: $globals.icons.close,
text: 'Cancel',
event: 'cancel',
},
{
icon: $globals.icons.save,
text: 'Save',
event: 'save',
},
]"
@cancel="resetToLastGoodValue(label, index)"
@save="updateLabel(label)"
@delete="deleteLabel(label.id)"
/>
</div>
</v-card-text>
<v-divider></v-divider>
</v-sheet>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, useAsync, computed } from "@nuxtjs/composition-api";
import Fuse from "fuse.js";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { MultiPurposeLabelSummary } from "~/types/api-types/labels";
export default defineComponent({
components: { MultiPurposeLabel },
setup() {
// ==========================================================
// API Operations
const api = useUserApi();
const deleteDialog = ref(false);
const deleteTargetId = ref("");
async function confirmDelete() {
await api.multiPurposeLabels.deleteOne(deleteTargetId.value);
refreshLabels();
deleteTargetId.value = "";
}
function deleteLabel(itemId: string) {
deleteTargetId.value = itemId;
deleteDialog.value = true;
}
const createDialog = ref(false);
const createLabelData = ref({
name: "",
color: "",
});
async function createLabel() {
createLabelData.value.color = getRandomHex();
const { data } = await api.multiPurposeLabels.createOne(createLabelData.value);
if (data) {
refreshLabels();
}
}
async function updateLabel(label: MultiPurposeLabelSummary) {
const { data } = await api.multiPurposeLabels.updateOne(label.id, label);
if (data) {
refreshLabels();
toggleIsOpen(label);
}
}
const labels = useAsync(async () => {
const { data } = await api.multiPurposeLabels.getAll();
return data;
}, useAsyncKey());
async function refreshLabels() {
const { data } = await api.multiPurposeLabels.getAll();
labels.value = data ?? [];
}
// ==========================================================
// Component Helpers
const lastGoodValue = ref<{ [key: string]: MultiPurposeLabelSummary }>({});
function saveLastGoodValue(label: MultiPurposeLabelSummary) {
lastGoodValue.value[label.id] = { ...label };
}
function resetToLastGoodValue(label: MultiPurposeLabelSummary, index: number) {
const lgv = lastGoodValue.value[label.id];
if (lgv && labels.value) {
labels.value[index] = lgv;
labels.value = [...labels.value];
}
toggleIsOpen(label);
}
const isOpen = ref<{ [key: string]: boolean }>({});
function toggleIsOpen(label: MultiPurposeLabelSummary) {
isOpen.value[label.id] = !isOpen.value[label.id];
if (isOpen.value[label.id]) {
saveLastGoodValue(label);
}
isOpen.value = { ...isOpen.value };
}
// ==========================================================
// Color Generators
function getRandomHex() {
const letters = "BCDEF".split("");
let color = "#";
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * letters.length)];
}
return color;
}
function setRandomHex(labelIndex: number) {
if (!labels.value) {
return;
}
labels.value[labelIndex].color = getRandomHex();
labels.value = [...labels.value];
}
// ==========================================================
// Search / Filter
const searchInput = ref("");
const labelNames = computed(() => {
return labels.value?.map((label) => label.name) ?? [];
});
const fuseOpts = {
shouldSort: true,
threshold: 0.5,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
keys: ["name"],
};
const fuse = computed(() => {
return new Fuse(labelNames.value, fuseOpts);
});
const results = computed(() => {
if (!searchInput.value) {
return labels.value;
}
const foundName = fuse.value.search(searchInput.value).map((result) => result.item);
return labels.value?.filter((label) => foundName.includes(label.name)) ?? [];
});
return {
saveLastGoodValue,
resetToLastGoodValue,
deleteDialog,
deleteTargetId,
confirmDelete,
createLabelData,
createLabel,
createDialog,
results,
searchInput,
updateLabel,
deleteLabel,
setRandomHex,
toggleIsOpen,
isOpen,
labels,
refreshLabels,
};
},
head: {
title: "Shopping List Labels",
},
});
</script>

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -10,3 +10,7 @@ export interface ErrorResponse {
error?: boolean;
exception?: string;
}
export interface SuccessResponse {
message: string;
error?: boolean;
}

View file

@ -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_ {}

View file

@ -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;

View file

@ -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;

View file

@ -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"

View file

@ -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 <b>{APP_VERSION}</b>, 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,

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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 = {

View file

@ -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)

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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)

View file

@ -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](

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -1 +1,2 @@
# GENERATED CODE - DO NOT MODIFY BY HAND
from .recipe_keys import *

View file

@ -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)

View file

@ -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 *

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,5 @@
from .app_routes import *
from .assertion_helpers import *
from .factories import *
from .fixture_schemas import *
from .user_login import *

View file

@ -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()